Browse Source

Merge remote-tracking branch 'origin/master' into feat/181356-news-inappnotification-impl

Ryotaro Nagahara 1 month ago
parent
commit
7669cb59d7
100 changed files with 8709 additions and 549 deletions
  1. 0 144
      .claude/agents/security-reviewer.md
  2. 83 0
      .claude/commands/create-next-version-branch.md
  3. 307 0
      .claude/commands/kiro/spec-cleanup.md
  4. 40 4
      .claude/commands/learn.md
  5. 107 6
      .claude/commands/tdd.md
  6. 22 0
      .claude/hooks/session-start.sh
  7. 21 0
      .claude/rules/coding-style.md
  8. 38 0
      .claude/rules/testing.md
  9. 49 2
      .claude/settings.json
  10. 0 27
      .claude/skills/learned/.gitkeep
  11. 122 0
      .claude/skills/learned/essential-test-design/SKILL.md
  12. 68 10
      .claude/skills/learned/essential-test-patterns/SKILL.md
  13. 50 4
      .claude/skills/monorepo-overview/SKILL.md
  14. 11 3
      .claude/skills/tech-stack/SKILL.md
  15. 5 2
      .devcontainer/app/devcontainer.json
  16. 4 0
      .devcontainer/pdf-converter/devcontainer.json
  17. 0 2
      .github/dependabot.yml
  18. 5 5
      .github/mergify.yml
  19. 15 34
      .github/workflows/ci-app-prod.yml
  20. 3 3
      .github/workflows/ci-app.yml
  21. 4 4
      .github/workflows/ci-pdf-converter.yml
  22. 4 4
      .github/workflows/ci-slackbot-proxy.yml
  23. 2 2
      .github/workflows/release-rc.yml
  24. 7 12
      .github/workflows/release-subpackages.yml
  25. 1 1
      .github/workflows/reusable-app-build-image.yml
  26. 25 30
      .github/workflows/reusable-app-prod.yml
  27. 0 88
      .github/workflows/reusable-app-reg-suit.yml
  28. 1 0
      .gitignore
  29. 466 0
      .kiro/specs/auto-scroll/design.md
  30. 91 0
      .kiro/specs/auto-scroll/requirements.md
  31. 246 0
      .kiro/specs/auto-scroll/research.md
  32. 24 0
      .kiro/specs/auto-scroll/spec.json
  33. 154 0
      .kiro/specs/auto-scroll/tasks.md
  34. 268 0
      .kiro/specs/collaborative-editor/design.md
  35. 79 0
      .kiro/specs/collaborative-editor/requirements.md
  36. 69 0
      .kiro/specs/collaborative-editor/research.md
  37. 22 0
      .kiro/specs/collaborative-editor/spec.json
  38. 3 0
      .kiro/specs/collaborative-editor/tasks.md
  39. 153 0
      .kiro/specs/hotkeys/design.md
  40. 101 0
      .kiro/specs/hotkeys/requirements.md
  41. 23 0
      .kiro/specs/hotkeys/spec.json
  42. 29 0
      .kiro/specs/hotkeys/tasks.md
  43. 670 0
      .kiro/specs/migrate-logger-to-pino/design.md
  44. 156 0
      .kiro/specs/migrate-logger-to-pino/requirements.md
  45. 224 0
      .kiro/specs/migrate-logger-to-pino/research.md
  46. 23 0
      .kiro/specs/migrate-logger-to-pino/spec.json
  47. 263 0
      .kiro/specs/migrate-logger-to-pino/tasks.md
  48. 764 0
      .kiro/specs/oauth2-email-support/design.md
  49. 57 0
      .kiro/specs/oauth2-email-support/requirements.md
  50. 449 0
      .kiro/specs/oauth2-email-support/research.md
  51. 23 0
      .kiro/specs/oauth2-email-support/spec.json
  52. 41 0
      .kiro/specs/oauth2-email-support/tasks.md
  53. 233 0
      .kiro/specs/official-docker-image/design.md
  54. 82 0
      .kiro/specs/official-docker-image/requirements.md
  55. 288 0
      .kiro/specs/official-docker-image/research.md
  56. 22 0
      .kiro/specs/official-docker-image/spec.json
  57. 193 0
      .kiro/specs/official-docker-image/tasks.md
  58. 284 0
      .kiro/specs/presentation/design.md
  59. 26 0
      .kiro/specs/presentation/requirements.md
  60. 84 0
      .kiro/specs/presentation/research.md
  61. 23 0
      .kiro/specs/presentation/spec.json
  62. 359 0
      .kiro/specs/suggest-path/design.md
  63. 77 0
      .kiro/specs/suggest-path/requirements.md
  64. 145 0
      .kiro/specs/suggest-path/research.md
  65. 23 0
      .kiro/specs/suggest-path/spec.json
  66. 82 0
      .kiro/specs/suggest-path/tasks.md
  67. 14 8
      .kiro/steering/structure.md
  68. 4 18
      .kiro/steering/tdd.md
  69. 53 9
      .kiro/steering/tech.md
  70. 1 21
      .mcp.json
  71. 0 2
      .npmrc
  72. 64 8
      .serena/project.yml
  73. 3 6
      AGENTS.md
  74. 86 1
      CHANGELOG.md
  75. 2 0
      CLAUDE.md
  76. 3 4
      README.md
  77. 3 4
      README_JP.md
  78. 74 0
      apps/app/.claude/rules/package-dependencies.md
  79. 47 2
      apps/app/.claude/skills/app-commands/SKILL.md
  80. 1 1
      apps/app/.claude/skills/app-specific-patterns/SKILL.md
  81. 124 0
      apps/app/.claude/skills/build-optimization/SKILL.md
  82. 90 0
      apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md
  83. 302 0
      apps/app/.claude/skills/learned/page-save-origin-semantics/SKILL.md
  84. 116 0
      apps/app/.claude/skills/vendor-styles-components/SKILL.md
  85. 2 0
      apps/app/.env.development
  86. 6 0
      apps/app/.gitignore
  87. 13 1
      apps/app/AGENTS.md
  88. 18 0
      apps/app/CLAUDE.md
  89. 30 0
      apps/app/bin/assemble-prod.sh
  90. 44 0
      apps/app/bin/check-next-symlinks.sh
  91. 60 0
      apps/app/bin/measure-chunk-stats.sh
  92. 17 11
      apps/app/bin/openapi/definition-apiv3.js
  93. 1 0
      apps/app/bin/openapi/generate-spec-apiv3.sh
  94. 42 0
      apps/app/bin/postbuild-server.ts
  95. 5 1
      apps/app/config/logger/config.dev.ts
  96. 5 1
      apps/app/config/logger/config.prod.ts
  97. 2 8
      apps/app/config/next-i18next.config.js
  98. 79 51
      apps/app/docker/Dockerfile
  99. 74 4
      apps/app/docker/Dockerfile.dockerignore
  100. 11 1
      apps/app/docker/README.md

+ 0 - 144
.claude/agents/security-reviewer.md

@@ -1,144 +0,0 @@
----
-name: security-reviewer
-description: Security vulnerability detection specialist for GROWI. Use after writing code that handles user input, authentication, API endpoints, or sensitive data. Flags secrets, injection, XSS, and OWASP Top 10 vulnerabilities.
-tools: Read, Write, Edit, Bash, Grep, Glob
-model: opus
----
-
-# Security Reviewer
-
-You are a security specialist focused on identifying vulnerabilities in the GROWI codebase. Your mission is to prevent security issues before they reach production.
-
-## GROWI Security Stack
-
-GROWI uses these security measures:
-- **helmet**: Security headers
-- **express-mongo-sanitize**: NoSQL injection prevention
-- **xss**, **rehype-sanitize**: XSS prevention
-- **Passport.js**: Authentication (Local, LDAP, SAML, OAuth)
-
-## Security Review Workflow
-
-### 1. Automated Checks
-```bash
-# Check for vulnerable dependencies
-pnpm audit
-
-# Search for potential secrets
-grep -r "api[_-]?key\|password\|secret\|token" --include="*.ts" --include="*.tsx" .
-```
-
-### 2. OWASP Top 10 Checklist
-
-1. **Injection (NoSQL)** - Are Mongoose queries safe? No string concatenation in queries?
-2. **Broken Authentication** - Passwords hashed? Sessions secure? Passport configured correctly?
-3. **Sensitive Data Exposure** - Secrets in env vars? HTTPS enforced? Logs sanitized?
-4. **Broken Access Control** - Authorization on all routes? CORS configured?
-5. **Security Misconfiguration** - Helmet enabled? Debug mode off in production?
-6. **XSS** - Output escaped? Content-Security-Policy set?
-7. **Components with Vulnerabilities** - `pnpm audit` clean?
-8. **Insufficient Logging** - Security events logged?
-
-## Vulnerability Patterns
-
-### Hardcoded Secrets (CRITICAL)
-```typescript
-// ❌ CRITICAL
-const apiKey = "sk-xxxxx"
-
-// ✅ CORRECT
-const apiKey = process.env.API_KEY
-```
-
-### NoSQL Injection (CRITICAL)
-```typescript
-// ❌ CRITICAL: Unsafe query
-const user = await User.findOne({ email: req.body.email, password: req.body.password })
-
-// ✅ CORRECT: Use express-mongo-sanitize middleware + validate input
-```
-
-### XSS (HIGH)
-```typescript
-// ❌ HIGH: Direct HTML insertion
-element.innerHTML = userInput
-
-// ✅ CORRECT: Use textContent or sanitize
-element.textContent = userInput
-// OR use xss library
-import xss from 'xss'
-element.innerHTML = xss(userInput)
-```
-
-### SSRF (HIGH)
-```typescript
-// ❌ HIGH: User-controlled URL
-const response = await fetch(userProvidedUrl)
-
-// ✅ CORRECT: Validate URL against allowlist
-const allowedDomains = ['api.example.com']
-const url = new URL(userProvidedUrl)
-if (!allowedDomains.includes(url.hostname)) {
-  throw new Error('Invalid URL')
-}
-```
-
-### Authorization Check (CRITICAL)
-```typescript
-// ❌ CRITICAL: No authorization
-app.get('/api/page/:id', async (req, res) => {
-  const page = await Page.findById(req.params.id)
-  res.json(page)
-})
-
-// ✅ CORRECT: Check user access
-app.get('/api/page/:id', loginRequired, async (req, res) => {
-  const page = await Page.findById(req.params.id)
-  if (!page.isAccessibleBy(req.user)) {
-    return res.status(403).json({ error: 'Forbidden' })
-  }
-  res.json(page)
-})
-```
-
-## Security Report Format
-
-```markdown
-## Security Review Summary
-- **Critical Issues:** X
-- **High Issues:** Y
-- **Risk Level:** 🔴 HIGH / 🟡 MEDIUM / 🟢 LOW
-
-### Issues Found
-1. **[SEVERITY]** Description @ `file:line`
-   - Impact: ...
-   - Fix: ...
-```
-
-## When to Review
-
-**ALWAYS review when:**
-- New API endpoints added
-- Authentication/authorization changed
-- User input handling added
-- Database queries modified
-- File upload features added
-- Dependencies updated
-
-## Best Practices
-
-1. **Defense in Depth** - Multiple security layers
-2. **Least Privilege** - Minimum permissions
-3. **Fail Securely** - Errors don't expose data
-4. **Separation of Concerns** - Isolate security-critical code
-5. **Keep it Simple** - Complex code has more vulnerabilities
-6. **Don't Trust Input** - Validate everything
-7. **Update Regularly** - Keep dependencies current
-
-## Emergency Response
-
-If CRITICAL vulnerability found:
-1. Document the issue
-2. Provide secure code fix
-3. Check if vulnerability was exploited
-4. Rotate any exposed secrets

+ 83 - 0
.claude/commands/create-next-version-branch.md

@@ -0,0 +1,83 @@
+---
+name: create-next-version-branch
+description: Create development and release branches with GitHub Release for the next version. Usage: /create-next-version-branch dev/{major}.{minor}.x
+---
+
+# Create Next Version Branch
+
+Automate the creation of development branches and GitHub Release for a new GROWI version.
+
+## Input
+
+The argument `$ARGUMENTS` must be a branch name in the format `dev/{major}.{minor}.x` (e.g., `dev/7.5.x`).
+
+## Procedure
+
+### Step 1: Parse and Validate Input
+
+1. Parse `$ARGUMENTS` to extract `{major}` and `{minor}` from the `dev/{major}.{minor}.x` pattern
+2. If the format is invalid, display an error and stop:
+   - Must match `dev/{number}.{number}.x`
+3. Set the following variables:
+   - `DEV_BRANCH`: `dev/{major}.{minor}.x`
+   - `RELEASE_BRANCH`: `release/{major}.{minor}.x`
+   - `TAG_NAME`: `v{major}.{minor}.x-base`
+   - `RELEASE_TITLE`: `v{major}.{minor}.x Base Release`
+
+### Step 2: Create and Push the Development Branch
+
+1. Confirm with the user before proceeding
+2. Create and push `DEV_BRANCH` from the current HEAD:
+   ```bash
+   git checkout -b {DEV_BRANCH}
+   git push origin {DEV_BRANCH}
+   ```
+
+### Step 3: Create GitHub Release
+
+1. Create a GitHub Release using `gh release create`:
+   ```bash
+   gh release create {TAG_NAME} \
+     --target {DEV_BRANCH} \
+     --title "{RELEASE_TITLE}" \
+     --notes "The base release for release-drafter to avoid \`Error: GraphQL Rate Limit Exceeded\`
+   https://github.com/release-drafter/release-drafter/issues/1018" \
+     --latest=false \
+     --prerelease=false
+   ```
+   - `--latest=false`: Do NOT set as latest release
+   - `--prerelease=false`: Do NOT set as pre-release
+
+### Step 4: Verify targetCommitish
+
+1. Run the following command and confirm that `targetCommitish` equals `DEV_BRANCH`:
+   ```bash
+   gh release view {TAG_NAME} --json targetCommitish
+   ```
+2. If `targetCommitish` does not match, display an error and stop
+
+### Step 5: Create and Push the Release Branch
+
+1. From the same commit (still on `DEV_BRANCH`), create and push `RELEASE_BRANCH`:
+   ```bash
+   git checkout -b {RELEASE_BRANCH}
+   git push origin {RELEASE_BRANCH}
+   ```
+
+### Step 6: Summary
+
+Display a summary of all created resources:
+
+```
+Created:
+  - Branch: {DEV_BRANCH} (pushed to origin)
+  - Branch: {RELEASE_BRANCH} (pushed to origin)
+  - GitHub Release: {RELEASE_TITLE} (tag: {TAG_NAME}, target: {DEV_BRANCH})
+```
+
+## Error Handling
+
+- If `DEV_BRANCH` already exists on the remote, warn the user and ask how to proceed
+- If `RELEASE_BRANCH` already exists on the remote, warn the user and ask how to proceed
+- If the tag `TAG_NAME` already exists, warn the user and ask how to proceed
+- If `gh` CLI is not authenticated, instruct the user to run `gh auth login`

+ 307 - 0
.claude/commands/kiro/spec-cleanup.md

@@ -0,0 +1,307 @@
+---
+description: Organize and clean up specification documents after implementation completion
+allowed-tools: Bash, Glob, Grep, Read, Write, Edit, MultiEdit, Update
+argument-hint: <feature-name>
+---
+
+# Specification Cleanup
+
+<background_information>
+- **Mission**: Organize specification documents after implementation completion, removing implementation details while preserving essential context for future refactoring
+- **Success Criteria**:
+  - Implementation details (testing procedures, deployment checklists) removed
+  - Design decisions and constraints preserved in research.md and design.md
+  - Requirements simplified (Acceptance Criteria condensed to summaries)
+  - Unimplemented features removed or documented
+  - Documents remain valuable for future refactoring work
+</background_information>
+
+<instructions>
+## Core Task
+Clean up and organize specification documents for feature **$1** after implementation is complete.
+
+## Organizing Principle
+
+**"Can we read essential context from these spec documents when refactoring this feature months later?"**
+
+- **Keep**: "Why" (design decisions, architectural constraints, limitations, trade-offs)
+- **Remove**: "How" (testing procedures, deployment steps, detailed implementation examples)
+
+## Execution Steps
+
+### Step 1: Load Context
+
+**Discover all spec files**:
+- Use Glob to find all files in `.kiro/specs/$1/` directory
+- Categorize files:
+  - **Core files** (must preserve): `spec.json`, `requirements.md`, `design.md`, `tasks.md`, `research.md`
+  - **Other files** (evaluate case-by-case): validation reports, notes, prototypes, migration guides, etc.
+
+**Read all discovered files**:
+- Read all core files first
+- Read other files to understand their content and value
+
+**Determine target language**:
+- Read `spec.json` and extract the `language` field (e.g., `"ja"`, `"en"`)
+- This is the language ALL spec document content must be written in
+- Note: code comments within code blocks are exempt (must stay in English per project rules)
+
+**Verify implementation status**:
+- Check that tasks are marked complete `[x]` in tasks.md
+- If implementation incomplete, warn user and ask to confirm cleanup
+
+### Step 2: Analyze Current State
+
+**Identify cleanup opportunities**:
+
+1. **Other files** (non-core files like validation-report.md, notes.md, etc.):
+   - Read each file to understand its content and purpose
+   - Identify valuable information that should be preserved:
+     * Implementation discoveries and lessons learned
+     * Critical constraints or design decisions
+     * Historical context for future refactoring
+   - Determine salvage strategy:
+     * Migrate valuable content to research.md or design.md
+     * Keep file if it contains essential reference information
+     * Delete if content is redundant or no longer relevant
+   - **Case-by-case evaluation required** - never assume files should be deleted
+
+2. **research.md**:
+   - Should contain production discoveries and implementation lessons learned
+   - Check if implementation revealed new constraints or patterns to document
+   - Identify content from other files that should be migrated here
+
+3. **requirements.md**:
+   - Identify verbose Acceptance Criteria that can be condensed to summaries
+   - Find unimplemented requirements (compare with tasks.md)
+   - Detect duplicate or redundant content
+
+4. **design.md**:
+   - Identify implementation-specific sections that can be removed:
+     * Detailed Testing Strategy (test procedures)
+     * Security Considerations (if covered in implementation)
+     * Error Handling code examples (if implemented)
+     * Migration Strategy (after migration complete)
+     * Deployment Checklist (after deployment)
+   - Identify sections to preserve:
+     * Architecture diagrams (essential for understanding)
+     * Component interfaces (API contracts)
+     * Design decisions and rationale
+     * Critical implementation constraints
+     * Known limitations
+   - Check if content from other files should be migrated here
+
+5. **Language audit** (compare actual language vs. `spec.json.language`):
+   - For each markdown file, scan prose content (headings, paragraphs, list items) and detect the written language
+   - Flag any file or section whose language does **not** match the target language
+   - Exemptions — do NOT flag:
+     * Content inside fenced code blocks (` ``` `) — code comments must stay in English
+     * Inline code spans (`` `...` ``)
+     * Proper nouns, technical terms, and identifiers that are always written in English
+   - Collect flagged items into a **translation plan**: file name, approximate line range, detected language, and a brief excerpt
+
+### Step 3: Interactive Confirmation
+
+**Present cleanup plan to user**:
+
+For each file and section identified in Step 2, ask:
+- "Should I delete/simplify/keep/salvage this section?"
+- Provide recommendations based on organizing principle
+- Show brief preview of content to aid decision
+
+**Example questions for other files**:
+- "validation-report.md found. Contains {brief summary}. Options:"
+  - "A: Migrate valuable content to research.md, then delete"
+  - "B: Keep as historical reference"
+  - "C: Delete (content no longer needed)"
+- "notes.md found. Contains {brief summary}. Salvage to research.md before deleting? [Y/n]"
+
+**Example questions for core files**:
+- "research.md: Add 'Session N: Production Discoveries' section to document implementation lessons? [Y/n]"
+- "requirements.md: Simplify Acceptance Criteria from detailed bullet points to summary paragraphs? [Y/n]"
+- "requirements.md: Remove unimplemented requirements (e.g., Req 4.4 field masking not implemented)? [Y/n]"
+- "design.md: Delete 'Testing Strategy' section (lines X-Y)? [Y/n]"
+- "design.md: Delete 'Security Considerations' section (lines X-Y)? [Y/n]"
+- "design.md: Keep Architecture diagrams (essential for refactoring)? [Y/n]"
+
+**Translation confirmation** (if language mismatches were found in Step 2):
+- Show summary: "Found content in language(s) other than `{target_language}` in the following files:"
+  - List each flagged file with line range and a short excerpt
+- Ask: "Translate mismatched content to `{target_language}`? [Y/n]"
+  - If Y: translate all flagged sections in Step 4
+  - If n: skip translation (leave files as-is)
+- Note: code blocks are never translated
+
+**Batch similar decisions**:
+- Group related sections (e.g., all "delete implementation details" decisions)
+- Allow user to approve categories rather than individual items
+- Present file-by-file salvage decisions for other files
+
+### Step 4: Execute Cleanup
+
+**For each approved action**:
+
+1. **Salvage and cleanup other files** (if approved):
+   - For each non-core file (validation-report.md, notes.md, etc.):
+     * Extract valuable information (implementation lessons, constraints, decisions)
+     * Migrate content to appropriate core file:
+       - Technical discoveries → research.md
+       - Design constraints → design.md
+       - Requirement clarifications → requirements.md
+     * Delete file after salvage (if approved)
+   - Document salvaged content with source reference (e.g., "From validation-report.md:")
+
+2. **Update research.md** (if new discoveries or salvaged content):
+   - Add new section "Session N: Production Implementation Discoveries" (if needed)
+   - Document critical technical constraints discovered during implementation
+   - Include code examples for critical patterns (e.g., falsy checks, credential preservation)
+   - Integrate salvaged content from other files
+   - Cross-reference requirements.md and design.md where relevant
+
+3. **Simplify requirements.md** (if approved):
+   - Transform detailed Acceptance Criteria into summary paragraphs
+   - Remove unimplemented requirements entirely
+   - Preserve requirement objectives and summaries
+   - Example transformation:
+     ```
+     Before: "1. System shall X... 2. System shall Y... [7 criteria]"
+     After: "**Summary**: System provides X and Y. Configuration includes..."
+     ```
+
+4. **Clean up design.md** (if approved):
+   - Delete approved sections (Testing Strategy, Security Considerations, etc.)
+   - Add "Critical Implementation Constraints" section if implementation revealed new constraints
+   - Integrate salvaged content from other files (if relevant)
+   - Preserve architecture diagrams and component interfaces
+   - Keep design decisions and rationale sections
+
+5. **Translate language-mismatched content** (if approved):
+   - For each flagged file and section, translate prose content to the target language
+   - **Never translate**: content inside fenced code blocks or inline code spans
+   - Preserve all Markdown formatting (headings, bold, lists, links, etc.)
+   - After translation, verify the overall document reads naturally in the target language
+   - Document translated files in the cleanup summary
+
+6. **Update spec.json metadata**:
+   - Set `phase: "implementation-complete"` (if not already set)
+   - Add `cleanup_completed: true` flag
+   - Update `updated_at` timestamp
+
+### Step 5: Generate Cleanup Summary
+
+**Provide summary report**:
+- List of files modified/deleted
+- Sections removed and lines saved
+- Critical information preserved
+- Recommendations for future refactoring
+
+**Format**:
+```markdown
+## Cleanup Summary for {feature-name}
+
+### Files Modified
+- ✅ validation-report.md: Salvaged to research.md, then deleted (730 lines removed)
+- ✅ notes.md: Salvaged to design.md, then deleted (120 lines removed)
+- ✅ research.md: Added Session 2 discoveries + salvaged content (180 lines added)
+- ✅ requirements.md: Simplified 6 requirements (350 lines → 180 lines)
+- ✅ design.md: Removed 4 sections, added constraints + salvaged content (250 lines removed, 100 added)
+- ✅ requirements.md: Translated mismatched sections to {target_language}
+
+### Information Salvaged
+- Implementation discoveries from validation-report.md → research.md
+- Design notes from notes.md → design.md
+- Historical context preserved with source attribution
+
+### Information Preserved
+- Architecture diagrams and component interfaces
+- Design decisions and rationale
+- Critical implementation constraints
+- Known limitations and trade-offs
+
+### Next Steps
+- Spec documents ready for future refactoring reference
+- Consider creating knowledge base entry if pattern is reusable
+```
+
+## Critical Constraints
+
+- **User approval required**: Never delete content without explicit confirmation
+- **Language consistency**: All prose content must be written in the language specified in `spec.json.language`; translate any mismatched sections (code blocks exempt)
+- **Preserve history**: Don't delete discovery rationale or design decisions
+- **Balance brevity with completeness**: Remove redundancy but keep essential context
+- **Interactive workflow**: Pause for user input rather than making assumptions
+
+## Tool Guidance
+
+- **Glob**: Discover all files in `.kiro/specs/{feature}/` directory
+- **Read**: Load all discovered files for analysis
+- **Grep**: Search for patterns (e.g., unimplemented requirements, completed tasks)
+- **Edit/Write**: Update files based on approved changes, salvage content
+- **Bash**: Delete files after salvage (if approved)
+- **MultiEdit**: For batch edits across multiple sections
+
+## Output Description
+
+Provide cleanup plan and execution report in the language specified in spec.json.
+
+**Report Structure**:
+1. **Current State Analysis**: What needs cleanup and why
+2. **Cleanup Plan**: Proposed changes with recommendations
+3. **Confirmation Prompts**: Interactive questions for user approval
+4. **Execution Summary**: What was changed and why
+5. **Preserved Context**: What critical information remains for future refactoring
+
+**Format**: Clear, scannable format with sections and bullet points
+
+## Safety & Fallback
+
+### Error Scenarios
+
+**Implementation Incomplete**:
+- **Condition**: Less than 90% of tasks marked `[x]` in tasks.md
+- **Action**: Warn user: "Implementation appears incomplete (X/Y tasks done). Continue cleanup? [y/N]"
+- **Recommendation**: Wait until implementation complete before cleanup
+
+**Spec Not Found**:
+- **Message**: "No spec found for `$1`. Check available specs in `.kiro/specs/`"
+- **Action**: List available spec directories
+
+**Missing Critical Files**:
+- **Condition**: requirements.md or design.md missing
+- **Action**: Skip cleanup for missing files, proceed with available files
+- **Warning**: "requirements.md missing - cannot simplify requirements"
+
+### Dry Run Mode (Future Enhancement)
+
+**If `-n` or `--dry-run` flag provided**:
+- Show cleanup plan without executing changes
+- Allow user to review before committing to cleanup
+
+### Backup Recommendation
+
+**Before cleanup**:
+- Recommend user create git commit or backup
+- Warning: "This will modify spec files. Commit current state first? [Y/n]"
+
+### Undo Support
+
+**If cleanup goes wrong**:
+- Use git to restore previous state: `git checkout HEAD -- .kiro/specs/{feature}/`
+- Remind user to commit before cleanup for easy rollback
+
+## Example Usage
+
+```bash
+# Basic cleanup after implementation
+/kiro:spec-cleanup oauth2-email-support
+
+# With conversation context about implementation discoveries
+# Command will prompt for Session N discoveries to document
+/kiro:spec-cleanup user-authentication
+```
+
+## Related Commands
+
+- `/kiro:spec-impl {feature}` - Implement tasks (run before cleanup)
+- `/kiro:validate-impl {feature}` - Validate implementation (run before cleanup)
+- `/kiro:spec-status {feature}` - Check implementation status

+ 40 - 4
.claude/commands/learn.md

@@ -25,7 +25,18 @@ Focus on four key areas:
 
 ## Skill File Structure
 
-Extracted patterns are saved in `.claude/skills/learned/{topic-name}/SKILL.md` with:
+Extracted patterns are saved in **appropriate skill directories** based on the scope of the pattern:
+
+**Workspace-specific patterns**:
+- `apps/{workspace}/.claude/skills/learned/{topic-name}/SKILL.md`
+- `packages/{package}/.claude/skills/learned/{topic-name}/SKILL.md`
+- Examples: patterns specific to a single app or package
+
+**Global patterns** (monorepo-wide):
+- `.claude/skills/learned/{topic-name}/SKILL.md`
+- Examples: patterns applicable across all workspaces
+
+### File Template
 
 ```yaml
 ---
@@ -49,12 +60,19 @@ description: Brief description (auto-invoked when working on related code)
 ## GROWI-Specific Examples
 
 Topics commonly learned in GROWI development:
-- `virtualized-tree-patterns` — @headless-tree + @tanstack/react-virtual optimizations
+
+**Apps/app-specific** (`apps/app/.claude/skills/learned/`):
+- `page-save-origin-semantics` — Origin-based conflict detection for collaborative editing
 - `socket-jotai-integration` — Real-time state synchronization patterns
 - `api-v3-error-handling` — RESTful API error response patterns
-- `jotai-atom-composition` — Derived atoms and state composition
 - `mongodb-query-optimization` — Mongoose indexing and aggregation patterns
 
+**Global monorepo patterns** (`.claude/skills/learned/`):
+- `virtualized-tree-patterns` — @headless-tree + @tanstack/react-virtual optimizations (if used across apps)
+- `jotai-atom-composition` — Derived atoms and state composition (if shared pattern)
+- `turborepo-cache-invalidation` — Build cache debugging techniques
+- `pnpm-workspace-dependencies` — Workspace dependency resolution issues
+
 ## Quality Guidelines
 
 **Extract:**
@@ -74,7 +92,25 @@ Topics commonly learned in GROWI development:
 1. User triggers `/learn` after solving a complex problem
 2. Review the session to identify valuable patterns
 3. Draft skill file(s) with clear structure
-4. Save to `.claude/skills/learned/{topic-name}/SKILL.md`
+4. **Autonomously determine the appropriate directory**:
+   - Analyze the pattern's scope (which files/modules were involved)
+   - If pattern is specific to a workspace in `apps/*` or `packages/*`:
+     - Save to `{workspace}/.claude/skills/learned/{topic-name}/SKILL.md`
+   - If pattern is applicable across multiple workspaces:
+     - Save to `.claude/skills/learned/{topic-name}/SKILL.md`
 5. Skills automatically apply in future sessions when working on related code
 
+### Directory Selection Logic
+
+**Workspace-specific** (save to `{workspace}/.claude/skills/learned/`):
+- Pattern involves workspace-specific concepts, models, or APIs
+- References files primarily in one `apps/*` or `packages/*` directory
+- Example: Page save logic in `apps/app` → `apps/app/.claude/skills/learned/`
+
+**Global** (save to `.claude/skills/learned/`):
+- Pattern applies across multiple workspaces
+- Involves monorepo-wide tools (Turborepo, pnpm, Biome, Vitest)
+- Shared coding patterns or architectural principles
+- Example: Turborepo caching pitfall → `.claude/skills/learned/`
+
 Learned skills are automatically invoked based on their description when working on related code.

+ 107 - 6
.claude/commands/tdd.md

@@ -7,6 +7,71 @@ description: Enforce test-driven development workflow. Scaffold interfaces, gene
 
 This command enforces test-driven development methodology for GROWI.
 
+---
+
+## ⚠️ CRITICAL: MANDATORY TDD PROTOCOL
+
+**THIS IS NOT OPTIONAL. THIS IS A TEAM REQUIREMENT.**
+
+### Non-Negotiable Rules
+
+1. **ALL phases of TDD MUST be followed: RED → GREEN → REFACTOR**
+2. **Tests MUST be written BEFORE any implementation code (RED phase)**
+3. **Implementation MUST be minimal and only to pass tests (GREEN phase)**
+4. **Tests MUST remain passing during refactoring (REFACTOR phase)**
+5. **NEVER skip any phase of the TDD cycle**
+6. **If you believe you cannot follow TDD, STOP and ask the user IMMEDIATELY**
+
+### Violation Protocol
+
+If you find yourself about to deviate from the TDD cycle:
+
+1. **STOP IMMEDIATELY** - Do not proceed with any phase out of order
+2. **ASK THE USER** - Explain why you think following TDD is difficult
+3. **WAIT FOR APPROVAL** - Do not proceed without explicit user consent
+4. **DOCUMENT THE EXCEPTION** - If approved, document why TDD was skipped
+
+**Example of required user confirmation:**
+
+```
+"This task requires writing integration tests with complex setup
+(database mocks, authentication, etc.).
+
+I have two options:
+
+Option A: Follow strict TDD (recommended)
+- RED: Write failing integration test first
+- GREEN: Implement minimal code to pass
+- REFACTOR: Improve code while keeping tests green
+- May take 15-30 minutes for test setup
+- Ensures proper test coverage
+
+Option B: Skip TDD for this specific change
+- Implement directly
+- Verify with existing test suite only
+- Risk: No specific test for this change
+
+Which approach would you prefer?"
+```
+
+**Never assume. Always ask.**
+
+---
+
+## Pre-Implementation Checklist
+
+Before writing ANY code, confirm:
+
+- [ ] I have read and understood the task requirements
+- [ ] I am starting with Step 1: Write a FAILING test (RED phase)
+- [ ] I have NOT written any implementation code yet
+- [ ] I will follow all phases: RED → GREEN → REFACTOR
+- [ ] If I cannot follow the complete TDD cycle, I have ASKED THE USER for guidance
+
+**If any checkbox is unchecked, STOP and ask the user.**
+
+---
+
 ## What This Command Does
 
 1. **Scaffold Interfaces** - Define types/interfaces first
@@ -61,6 +126,8 @@ export function validatePagePath(path: string): ValidationResult {
 
 ## Step 2: Write Failing Test (RED)
 
+**⚠️ CHECKPOINT: Confirm you have NOT written any implementation code yet.**
+
 ```typescript
 // src/utils/page-path-validator.spec.ts
 describe('validatePagePath', () => {
@@ -84,6 +151,8 @@ describe('validatePagePath', () => {
 
 ## Step 3: Run Tests - Verify FAIL
 
+**⚠️ MANDATORY: Tests MUST fail before proceeding to implementation.**
+
 ```bash
 turbo run test --filter @growi/app -- src/utils/page-path-validator.spec.ts
 
@@ -92,10 +161,14 @@ FAIL src/utils/page-path-validator.spec.ts
     Error: Not implemented
 ```
 
-✅ Tests fail as expected. Ready to implement.
+**✅ CHECKPOINT PASSED: Tests fail as expected. Ready to implement.**
+
+**❌ If tests pass or don't run: STOP. Fix the test first.**
 
 ## Step 4: Implement Minimal Code (GREEN)
 
+**⚠️ CHECKPOINT: Only write the MINIMUM code needed to pass the tests.**
+
 ```typescript
 export function validatePagePath(path: string): ValidationResult {
   if (!path) {
@@ -110,20 +183,35 @@ export function validatePagePath(path: string): ValidationResult {
 
 ## Step 5: Run Tests - Verify PASS
 
+**⚠️ MANDATORY: ALL tests MUST pass before proceeding to refactoring.**
+
 ```bash
 turbo run test --filter @growi/app -- src/utils/page-path-validator.spec.ts
 
 PASS  ✓ All tests passing!
 ```
 
+**✅ CHECKPOINT PASSED: Ready to refactor if needed.**
+
+**❌ If tests fail: Fix implementation, do NOT move to refactoring.**
+
 ## Step 6: Check Coverage
 
+**⚠️ MANDATORY: Verify test coverage meets requirements (80% minimum).**
+
 ```bash
 cd {package_dir} && pnpm vitest run --coverage src/utils/page-path-validator.spec.ts
 
 Coverage: 100% ✅ (Target: 80%)
 ```
 
+**✅ TDD CYCLE COMPLETE: All phases completed successfully.**
+
+- ✅ RED: Failing tests written
+- ✅ GREEN: Implementation passes tests
+- ✅ REFACTOR: Code improved (if needed)
+- ✅ COVERAGE: 80%+ achieved
+
 ## TDD Best Practices
 
 **DO:**
@@ -172,13 +260,26 @@ Coverage: 100% ✅ (Target: 80%)
 
 ## Important Notes
 
-**MANDATORY**: Tests must be written BEFORE implementation. The TDD cycle is:
+**MANDATORY - NO EXCEPTIONS**: The complete TDD cycle MUST be followed:
+
+1. **RED** - Write failing test FIRST
+2. **GREEN** - Implement minimal code to pass the test
+3. **REFACTOR** - Improve code while keeping tests green
+
+**Absolute Requirements:**
+- ❌ NEVER skip the RED phase
+- ❌ NEVER skip the GREEN phase
+- ❌ NEVER skip the REFACTOR phase
+- ❌ NEVER write implementation code before tests
+- ❌ NEVER proceed without explicit user approval if you cannot follow TDD
 
-1. **RED** - Write failing test
-2. **GREEN** - Implement to pass
-3. **REFACTOR** - Improve code
+**If you violate these rules:**
+1. STOP immediately
+2. Discard any implementation code written before tests
+3. Inform the user of the violation
+4. Start over with RED phase
 
-Never skip the RED phase. Never write code before tests.
+**This is a team development standard. Violations are not acceptable.**
 
 ## Related Skills
 

+ 22 - 0
.claude/hooks/session-start.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+
+# Only run in remote (Claude Code on the web) environments
+if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
+  exit 0
+fi
+
+cd "$CLAUDE_PROJECT_DIR"
+
+# Install all workspace dependencies.
+# turbo (root devDependency) and all workspace packages will be installed.
+pnpm install
+
+# Install turbo globally (mirrors devcontainer postCreateCommand.sh) so it is
+# available as a bare `turbo` command in subsequent Claude tool calls.
+# Falls back to adding node_modules/.bin to PATH if the pnpm global store is
+# not yet configured in this environment.
+if ! command -v turbo &> /dev/null; then
+  pnpm install turbo --global 2>/dev/null \
+    || echo "export PATH=\"$CLAUDE_PROJECT_DIR/node_modules/.bin:\$PATH\"" >> "$CLAUDE_ENV_FILE"
+fi

+ 21 - 0
.claude/rules/coding-style.md

@@ -201,6 +201,27 @@ Implemented react-window for virtualizing page tree
 to improve performance with 10k+ pages.
 ```
 
+## Cross-Platform Compatibility
+
+GROWI must work on Windows, macOS, and Linux. Never use platform-specific shell commands in npm scripts.
+
+```json
+// ❌ WRONG: Unix-only commands in npm scripts
+"clean": "rm -rf dist",
+"copy": "cp src/foo.ts dist/foo.ts",
+"move": "mv src dist"
+
+// ✅ CORRECT: Cross-platform tools
+"clean": "rimraf dist",
+"copy": "node -e \"require('fs').cpSync('src/foo.ts','dist/foo.ts')\"",
+"move": "node -e \"require('fs').renameSync('src','dist')\""
+```
+
+**Rules**:
+- Use `rimraf` instead of `rm -rf`
+- Use Node.js one-liners or cross-platform tools (`cpy-cli`, `cpx2`) instead of `cp`, `mv`, `echo`, `ls`
+- Never assume a POSIX shell in npm scripts
+
 ## Code Quality Checklist
 
 Before marking work complete:

+ 38 - 0
.claude/rules/testing.md

@@ -0,0 +1,38 @@
+# Testing Rules
+
+## Package Manager (CRITICAL)
+
+**NEVER use `npx` to run tests. ALWAYS use `pnpm`.**
+
+```bash
+# ❌ WRONG
+npx vitest run yjs.integ
+
+# ✅ CORRECT
+pnpm vitest run yjs.integ
+```
+
+## Test Execution Commands
+
+### Individual Test File (from package directory)
+
+```bash
+# Use partial file name - Vitest auto-matches
+pnpm vitest run yjs.integ
+pnpm vitest run helper.spec
+pnpm vitest run Button.spec
+
+# Flaky test detection
+pnpm vitest run yjs.integ --repeat=10
+```
+
+- Use **partial file name** (no `src/` prefix or full path needed)
+- No `--project` flag needed (Vitest auto-detects from file extension)
+
+### All Tests for a Package (from monorepo root)
+
+```bash
+turbo run test --filter @growi/app
+```
+
+For testing patterns (mocking, assertions, structure), see the `.claude/skills/learned/essential-test-patterns` skill.

+ 49 - 2
.claude/settings.json

@@ -1,5 +1,47 @@
 {
+  "permissions": {
+    "allow": [
+      "Bash(node --version)",
+      "Bash(npm --version)",
+      "Bash(npm view *)",
+      "Bash(pnpm --version)",
+      "Bash(turbo --version)",
+      "Bash(turbo run build)",
+      "Bash(turbo run lint)",
+      "Bash(pnpm run lint:*)",
+      "Bash(pnpm vitest run *)",
+      "Bash(pnpm biome check *)",
+      "Bash(pnpm ls *)",
+      "Bash(pnpm why *)",
+      "Bash(cat *)",
+      "Bash(echo *)",
+      "Bash(find *)",
+      "Bash(grep *)",
+      "Bash(git diff *)",
+      "Bash(gh issue view *)",
+      "Bash(gh pr view *)",
+      "Bash(gh pr diff *)",
+      "Bash(ls *)",
+      "WebFetch(domain:github.com)",
+      "mcp__context7__*",
+      "mcp__plugin_context7_*",
+      "mcp__github__*",
+      "WebSearch",
+      "WebFetch"
+    ]
+  },
+  "enableAllProjectMcpServers": true,
   "hooks": {
+    "SessionStart": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
+          }
+        ]
+      }
+    ],
     "PostToolUse": [
       {
         "matcher": "Write|Edit",
@@ -7,11 +49,16 @@
           {
             "type": "command",
             "command": "if [[ \"$FILE\" == */apps/* ]] || [[ \"$FILE\" == */packages/* ]]; then REPO_ROOT=$(echo \"$FILE\" | sed 's|/\\(apps\\|packages\\)/.*|/|'); cd \"$REPO_ROOT\" && pnpm biome check --write \"$FILE\" 2>/dev/null || true; fi",
-            "timeout": 30,
-            "description": "Auto-format edited files in apps/* and packages/* with Biome"
+            "timeout": 30
           }
         ]
       }
     ]
+  },
+  "enabledPlugins": {
+    "context7@claude-plugins-official": true,
+    "github@claude-plugins-official": true,
+    "typescript-lsp@claude-plugins-official": true,
+    "playwright@claude-plugins-official": true
   }
 }

+ 0 - 27
.claude/skills/learned/.gitkeep

@@ -1,27 +0,0 @@
-# Learned Skills Directory
-
-This directory contains Skills learned from development sessions using the `/learn` command.
-
-Each learned skill is automatically applied when working on related code based on its description.
-
-## Structure
-
-```
-learned/
-├── {topic-name-1}/
-│   └── SKILL.md
-├── {topic-name-2}/
-│   └── SKILL.md
-└── {topic-name-3}/
-    └── SKILL.md
-```
-
-## How Skills Are Created
-
-Use the `/learn` command after completing a feature or solving a complex problem:
-
-```
-/learn
-```
-
-Claude will extract reusable patterns and save them as Skills in this directory.

+ 122 - 0
.claude/skills/learned/essential-test-design/SKILL.md

@@ -0,0 +1,122 @@
+---
+name: essential-test-design
+description: Write tests that verify observable behavior (contract), not implementation details. Auto-invoked when writing or reviewing tests.
+---
+
+## Problem
+
+Tests that are tightly coupled to implementation details cause two failures:
+
+1. **False positives** — Tests pass even when behavior is broken (e.g., delay shortened but test still passes because it only checks `setTimeout` was called)
+2. **False negatives** — Tests fail even when behavior is correct (e.g., implementation switches from `setTimeout` to a `delay()` utility, spy breaks)
+
+Both undermine the purpose of testing: detecting regressions in behavior.
+
+## Principle: Test the Contract, Not the Mechanism
+
+A test is "essential" when it:
+- **Fails if the behavior degrades** (catches real bugs)
+- **Passes if the behavior is preserved** (survives refactoring)
+- **Does not depend on how the behavior is implemented** (implementation-agnostic)
+
+Ask: "What does the caller of this function experience?" — test that.
+
+## Anti-Patterns and Corrections
+
+### Anti-Pattern 1: Implementation Spy
+
+```typescript
+// BAD: Tests implementation, not behavior
+// Breaks if implementation changes from setTimeout to any other delay mechanism
+const spy = vi.spyOn(global, 'setTimeout');
+await exponentialBackoff(1);
+expect(spy).toHaveBeenCalledWith(expect.any(Function), 1000);
+```
+
+### Anti-Pattern 2: Arrange That Serves the Assert
+
+```typescript
+// BAD: The "arrange" is set up only to make the "assert" trivially pass
+// This is a self-fulfilling prophecy, not a meaningful test
+vi.advanceTimersByTime(1000);
+await promise;
+// No assertion — "it didn't throw" is not a valuable test
+```
+
+### Correct: Behavior Boundary Test
+
+```typescript
+// GOOD: Tests the observable contract
+// "Does not resolve before the expected delay, resolves at the expected delay"
+let resolved = false;
+mailService.exponentialBackoff(1).then(() => { resolved = true });
+
+await vi.advanceTimersByTimeAsync(999);
+expect(resolved).toBe(false);  // Catches: delay too short
+
+await vi.advanceTimersByTimeAsync(1);
+expect(resolved).toBe(true);   // Catches: delay too long or hangs
+```
+
+## Decision Framework
+
+When writing a test, ask these questions in order:
+
+1. **What is the contract?** — What does the caller expect to experience?
+   - e.g., "Wait for N ms before resolving"
+2. **What breakage should this test catch?** — Define the regression scenario
+   - e.g., "Someone changes the delay from 1000ms to 500ms"
+3. **Would this test still pass if I refactored the internals?** — If no, you're testing implementation
+   - e.g., Switching from `setTimeout` to `Bun.sleep()` shouldn't break the test
+4. **Would this test fail if the behavior degraded?** — If no, the test has no value
+   - e.g., If delay is halved, `expect(resolved).toBe(false)` at 999ms would catch it
+
+## Common Scenarios
+
+### Async Delay / Throttle / Debounce
+
+Use fake timers + boundary assertions (as shown above).
+
+### Data Transformation
+
+Assert on output shape/values, not on which internal helper was called.
+
+```typescript
+// BAD
+const spy = vi.spyOn(utils, 'formatDate');
+transform(input);
+expect(spy).toHaveBeenCalled();
+
+// GOOD
+const result = transform(input);
+expect(result.date).toBe('2026-01-01');
+```
+
+### Side Effects (API calls, DB writes)
+
+Mocking the boundary (API/DB) is acceptable — that IS the observable behavior.
+
+```typescript
+// OK: The contract IS "sends an email via mailer"
+expect(mockMailer.sendMail).toHaveBeenCalledWith(
+  expect.objectContaining({ to: 'user@example.com' })
+);
+```
+
+### Retry Logic
+
+Test the number of attempts and the final outcome, not the internal flow.
+
+```typescript
+// GOOD: Contract = "retries N times, then fails with specific error"
+mockMailer.sendMail.mockRejectedValue(new Error('fail'));
+await expect(sendWithRetry(config, 3)).rejects.toThrow('failed after 3 attempts');
+expect(mockMailer.sendMail).toHaveBeenCalledTimes(3);
+```
+
+## When to Apply
+
+- Writing new test cases for any function or method
+- Reviewing existing tests for flakiness or brittleness
+- Refactoring tests after fixing flaky CI failures
+- Code review of test pull requests

+ 68 - 10
.claude/skills/testing-patterns-with-vitest/SKILL.md → .claude/skills/learned/essential-test-patterns/SKILL.md

@@ -1,7 +1,6 @@
 ---
-name: testing-patterns-with-vitest
-description: GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended. Auto-invoked for all GROWI development work.
-user-invocable: false
+name: essential-test-patterns
+description: GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended.
 ---
 
 # GROWI Testing Patterns
@@ -373,6 +372,71 @@ vi.mock('~/utils/myUtils', () => ({
 }));
 ```
 
+### Mocking CommonJS Modules with mock-require
+
+**IMPORTANT**: When `vi.mock()` fails with ESModule/CommonJS compatibility issues, use `mock-require` instead:
+
+```typescript
+import mockRequire from 'mock-require';
+
+describe('Service with CommonJS dependencies', () => {
+  beforeEach(() => {
+    // Mock CommonJS module before importing the code under test
+    mockRequire('legacy-module', {
+      someFunction: vi.fn().mockReturnValue('mocked'),
+      someProperty: 'mocked-value',
+    });
+  });
+
+  afterEach(() => {
+    // Clean up mocks to avoid leakage between tests
+    mockRequire.stopAll();
+  });
+
+  it('should use mocked module', async () => {
+    // Import AFTER mocking (dynamic import if needed)
+    const { MyService } = await import('~/services/MyService');
+
+    const result = MyService.doSomething();
+    expect(result).toBe('mocked');
+  });
+});
+```
+
+**When to use `mock-require`**:
+- Legacy CommonJS modules that don't work with `vi.mock()`
+- Mixed ESM/CJS environments causing module resolution issues
+- Third-party libraries with complex module systems
+- When `vi.mock()` fails with "Cannot redefine property" or "Module is not defined"
+
+**Key points**:
+- ✅ Mock **before** importing the code under test
+- ✅ Use `mockRequire.stopAll()` in `afterEach()` to prevent test leakage
+- ✅ Use dynamic imports (`await import()`) when needed
+- ✅ Works with both CommonJS and ESModule targets
+
+### Choosing the Right Mocking Strategy
+
+```typescript
+// ✅ Prefer vi.mock() for ESModules (simplest)
+vi.mock('~/modern-module', () => ({
+  myFunction: vi.fn(),
+}));
+
+// ✅ Use mock-require for CommonJS or mixed environments
+import mockRequire from 'mock-require';
+mockRequire('legacy-module', { myFunction: vi.fn() });
+
+// ✅ Use vitest-mock-extended for type-safe object mocks
+import { mockDeep } from 'vitest-mock-extended';
+const mockService = mockDeep<MyService>();
+```
+
+**Decision tree**:
+1. Can use `vi.mock()`? → Use it (simplest)
+2. CommonJS or module error? → Use `mock-require`
+3. Need type-safe object mock? → Use `vitest-mock-extended`
+
 ## Integration Tests (with Database)
 
 Integration tests (*.integ.ts) can access in-memory databases:
@@ -415,13 +479,7 @@ Before committing tests, ensure:
 
 ## Running Tests
 
-```bash
-# Run all tests for a package
-turbo run test --filter @growi/app
-
-# Run specific test file
-cd {package_dir} && pnpm vitest run src/components/Button/Button.spec.tsx
-```
+See the `testing` rule (`.claude/rules/testing.md`) for test execution commands.
 
 ## Summary: GROWI Testing Philosophy
 

+ 50 - 4
.claude/skills/monorepo-overview/SKILL.md

@@ -64,6 +64,34 @@ turbo run test --filter @growi/app
 turbo run lint --filter @growi/core
 ```
 
+### Build Order Management
+
+Build dependencies in this monorepo are **not** declared with `dependsOn: ["^build"]` (the automatic workspace-dependency mode). Instead, they are declared **explicitly** — either in the root `turbo.json` for legacy entries, or in per-package `turbo.json` files for newer packages.
+
+**When to update**: whenever a package gains a new workspace dependency on another buildable package (one that produces a `dist/`), declare the build-order dependency explicitly. Without it, Turborepo may build in the wrong order, causing missing `dist/` files or type errors.
+
+**Pattern — per-package `turbo.json`** (preferred for new dependencies):
+
+```json
+// packages/my-package/turbo.json
+{
+  "extends": ["//"],
+  "tasks": {
+    "build": { "dependsOn": ["@growi/some-dep#build"] },
+    "dev":   { "dependsOn": ["@growi/some-dep#dev"] }
+  }
+}
+```
+
+- `"extends": ["//"]` inherits all root task definitions; only add the extra `dependsOn`
+- Keep root `turbo.json` clean — package-level overrides live with the package that owns the dependency
+- For packages with multiple tasks (watch, lint, test), mirror the dependency in each relevant task
+
+**Existing examples**:
+- `packages/slack/turbo.json` — `build`/`dev` depend on `@growi/logger`
+- `packages/remark-attachment-refs/turbo.json` — all tasks depend on `@growi/core`, `@growi/logger`, `@growi/remark-growi-directive`, `@growi/ui`
+- Root `turbo.json` — `@growi/ui#build` depends on `@growi/core#build` (pre-dates the per-package pattern)
+
 ## Architectural Principles
 
 ### 1. Feature-Based Architecture (Recommended)
@@ -99,11 +127,28 @@ This enables better code splitting and prevents server-only code from being bund
 
 Common code should be extracted to `packages/`:
 
-- **core**: Utilities, constants, type definitions
+- **core**: Domain hub (see below)
 - **ui**: Reusable React components
 - **editor**: Markdown editor
 - **pluginkit**: Plugin system framework
 
+#### @growi/core — Domain & Utilities Hub
+
+`@growi/core` is the foundational shared package depended on by all other packages (10 consumers). Its responsibilities:
+
+- **Domain type definitions** — Single source of truth for cross-package interfaces (`IPage`, `IUser`, `IRevision`, `Ref<T>`, `HasObjectId`, etc.)
+- **Cross-cutting utilities** — Pure functions for page path validation, ObjectId checks, serialization (e.g., `serializeUserSecurely()`)
+- **System constants** — File types, plugin configs, scope enums
+- **Global type augmentations** — Runtime/polyfill type declarations visible to all consumers (e.g., `RegExp.escape()` via `declare global` in `index.ts`)
+
+Key patterns:
+
+1. **Shared types and global augmentations go in `@growi/core`** — Not duplicated per-package. `declare global` in `index.ts` propagates to all consumers through the module graph.
+2. **Subpath exports for granular imports** — `@growi/core/dist/utils/page-path-utils` instead of barrel imports from root.
+3. **Minimal runtime dependencies** — Only `bson-objectid`; ~70% types. Safe to import from both server and client contexts.
+4. **Server-specific interfaces are namespaced** — Under `interfaces/server/`.
+5. **Dual format (ESM + CJS)** — Built via Vite with `preserveModules: true` and `vite-plugin-dts` (`copyDtsFiles: true`).
+
 ## Version Management with Changeset
 
 GROWI uses **Changesets** for version management and release notes:
@@ -172,10 +217,11 @@ turbo run bootstrap
 # Start all dev servers (apps/app + dependencies)
 turbo run dev
 
-# Run tests for specific package
-turbo run test --filter @growi/app
+# Run a specific test file (from package directory)
+pnpm vitest run yjs.integ
 
-# Lint specific package
+# Run ALL tests / lint for a package
+turbo run test --filter @growi/app
 turbo run lint --filter @growi/core
 ```
 

+ 11 - 3
.claude/skills/tech-stack/SKILL.md

@@ -38,7 +38,7 @@ user-invocable: false
 ## Build & Development Tools
 
 ### Package Management
-- **pnpm** 10.4.1 - Package manager (faster, more efficient than npm/yarn)
+- **pnpm** Package manager (faster, more efficient than npm/yarn)
 
 ### Monorepo Orchestration
 - **Turborepo** ^2.1.3 - Build system with caching and parallelization
@@ -101,7 +101,12 @@ turbo run bootstrap
 ### Testing & Quality
 
 ```bash
-# Run tests for specific package
+# Run a specific test file (from package directory, e.g. apps/app)
+pnpm vitest run yjs.integ          # Partial file name match
+pnpm vitest run helper.spec        # Works for any test file
+pnpm vitest run yjs.integ --repeat=10  # Repeat for flaky test detection
+
+# Run ALL tests for a package (uses Turborepo caching)
 turbo run test --filter @growi/app
 
 # Run linters for specific package
@@ -182,9 +187,12 @@ Package-specific tsconfig.json example:
 
 ### Command Usage
 
-1. **Always use Turborepo for cross-package tasks**:
+1. **Use Turborepo for full-package tasks** (all tests, lint, build):
    - ✅ `turbo run test --filter @growi/app`
    - ❌ `cd apps/app && pnpm test` (bypasses Turborepo caching)
+2. **Use vitest directly for individual test files** (from package directory):
+   - ✅ `pnpm vitest run yjs.integ` (simple, fast)
+   - ❌ `turbo run test --filter @growi/app -- yjs.integ` (unnecessary overhead)
 
 2. **Use pnpm for package management**:
    - ✅ `pnpm install`

+ 5 - 2
.devcontainer/app/devcontainer.json

@@ -8,8 +8,9 @@
 
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
-      "version": "20.18.3"
-    }
+      "version": "24.14.0"
+    },
+    "ghcr.io/devcontainers/features/github-cli:1": {}
   },
 
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
@@ -30,6 +31,8 @@
         "editorconfig.editorconfig",
         "shinnn.stylelint",
         "stylelint.vscode-stylelint",
+        // markdown
+        "bierner.markdown-mermaid",
         // TypeScript (Native Preview)
         "typescriptteam.native-preview",
         // Test

+ 4 - 0
.devcontainer/pdf-converter/devcontainer.json

@@ -4,6 +4,10 @@
   "service": "pdf-converter",
   "workspaceFolder": "/workspace/growi",
 
+  "features": {
+    "ghcr.io/devcontainers/features/github-cli:1": {}
+  },
+
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // "forwardPorts": [],
 

+ 0 - 2
.github/dependabot.yml

@@ -24,8 +24,6 @@ updates:
       prefix: ci
       include: scope
     ignore:
-      - dependency-name: escape-string-regexp
-      - dependency-name: string-width
       - dependency-name: "@handsontable/react"
       - dependency-name: handsontable
       - dependency-name: typeorm

+ 5 - 5
.github/mergify.yml

@@ -6,17 +6,17 @@ queue_rules:
       - check-success ~= ci-app-launch-dev
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
     merge_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - check-success ~= ci-app-launch-dev
-      - check-success = test-prod-node20 / build-prod
-      - check-success ~= test-prod-node20 / launch-prod
-      - check-success ~= test-prod-node20 / run-playwright
+      - check-success = test-prod-node24 / build-prod
+      - check-success ~= test-prod-node24 / launch-prod
+      - check-success ~= test-prod-node24 / run-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node20 /
+      - -check-failure ~= test-prod-node24 /
 
 pull_request_rules:
   - name: Automatic queue to merge

+ 15 - 34
.github/workflows/ci-app-prod.yml

@@ -9,7 +9,6 @@ on:
       - .github/mergify.yml
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
-      - .github/workflows/reusable-app-reg-suit.yml
       - tsconfig.base.json
       - turbo.json
       - pnpm-lock.yaml
@@ -23,7 +22,6 @@ on:
       - .github/mergify.yml
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
-      - .github/workflows/reusable-app-reg-suit.yml
       - tsconfig.base.json
       - pnpm-lock.yaml
       - turbo.json
@@ -39,22 +37,21 @@ concurrency:
 
 jobs:
 
-  test-prod-node18:
-    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
-    if: |
-      ( github.event_name == 'push'
-        || github.base_ref == 'master'
-        || github.base_ref == 'dev/7.*.x'
-        || startsWith( github.base_ref, 'release/' )
-        || startsWith( github.head_ref, 'mergify/merge-queue/' ))
-    with:
-      node-version: 18.x
-      skip-e2e-test: true
-    secrets:
-      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-
+  # test-prod-node22:
+  #   uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
+  #   if: |
+  #     ( github.event_name == 'push'
+  #       || github.base_ref == 'master'
+  #       || github.base_ref == 'dev/7.*.x'
+  #       || startsWith( github.base_ref, 'release/' )
+  #       || startsWith( github.head_ref, 'mergify/merge-queue/' ))
+  #   with:
+  #     node-version: 22.x
+  #     skip-e2e-test: true
+  #   secrets:
+  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-  test-prod-node20:
+  test-prod-node24:
     uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
     if: |
       ( github.event_name == 'push'
@@ -63,23 +60,7 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
-      node-version: 20.x
+      node-version: 24.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-  # run-reg-suit-node20:
-  #   needs: [test-prod-node20]
-
-  #   uses: growilabs/growi/.github/workflows/reusable-app-reg-suit.yml@master
-
-  #   if: always()
-
-  #   with:
-  #     node-version: 20.x
-  #     skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
-  #   secrets:
-  #     REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
-  #     AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-  #     AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 3 - 3
.github/workflows/ci-app.yml

@@ -44,7 +44,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
       - uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
 
     services:
@@ -157,7 +157,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
 
     services:

+ 4 - 4
.github/workflows/ci-pdf-converter.yml

@@ -29,7 +29,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -65,7 +65,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -104,7 +104,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -142,7 +142,7 @@ jobs:
     - name: Assembling all dependencies
       run: |
         rm -rf out
-        pnpm deploy out --prod --filter @growi/pdf-converter
+        pnpm deploy out --prod --legacy --filter @growi/pdf-converter
         rm -rf apps/pdf-converter/node_modules && mv out/node_modules apps/pdf-converter/node_modules
 
     - name: pnpm run start:prod:ci

+ 4 - 4
.github/workflows/ci-slackbot-proxy.yml

@@ -30,7 +30,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -85,7 +85,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     services:
       mysql:
@@ -163,7 +163,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     services:
       mysql:
@@ -211,7 +211,7 @@ jobs:
     - name: Assembling all dependencies
       run: |
         rm -rf out
-        pnpm deploy out --prod --filter @growi/slackbot-proxy
+        pnpm deploy out --prod --legacy --filter @growi/slackbot-proxy
         rm -rf apps/slackbot-proxy/node_modules && mv out/node_modules apps/slackbot-proxy/node_modules
 
     - name: pnpm run start:prod:ci

+ 2 - 2
.github/workflows/release-rc.yml

@@ -37,7 +37,7 @@ jobs:
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
   build-image-rc:
-    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@rc/v7.5.x-node24
     with:
       image-name: growilabs/growi
       tag-temporary: latest-rc
@@ -47,7 +47,7 @@ jobs:
   publish-image-rc:
     needs: [determine-tags, build-image-rc]
 
-    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@rc/v7.5.x-node24
     with:
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io

+ 7 - 12
.github/workflows/release-subpackages.yml

@@ -14,6 +14,11 @@ on:
     branches:
       - master
 
+permissions:
+  id-token: write
+  contents: write
+  pull-requests: write
+
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
   cancel-in-progress: true
@@ -32,7 +37,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
 
     - name: Install dependencies
@@ -40,14 +45,6 @@ jobs:
         pnpm add turbo --global
         pnpm install --frozen-lockfile
 
-    - name: Setup .npmrc
-      run: |
-        cat << EOF > "$HOME/.npmrc"
-          //registry.npmjs.org/:_authToken=$NPM_TOKEN
-        EOF
-      env:
-        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
-
     - name: Retrieve changesets information
       id: changesets-status
       run: |
@@ -61,7 +58,6 @@ jobs:
         pnpm run release-subpackages:snapshot
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 
 
   release-subpackages:
@@ -75,7 +71,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
 
     - name: Install dependencies
@@ -92,4 +88,3 @@ jobs:
         publish: pnpm run release-subpackages
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -48,7 +48,7 @@ jobs:
         projectName: growi-official-image-builder
       env:
         CODEBUILD__sourceVersion: ${{ inputs.source-version }}
-        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:4.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }}
+        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:5.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:3.0' }}
         CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
         CODEBUILD__environmentVariablesOverride: '[
           { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }

+ 25 - 30
.github/workflows/reusable-app-prod.yml

@@ -16,7 +16,7 @@ on:
       node-version:
         required: true
         type: string
-        default: 22.x
+        default: 24.x
       skip-e2e-test:
         type: boolean
         default: false
@@ -57,17 +57,18 @@ jobs:
       env:
         ANALYZE: 1
 
-    - name: Assembling all dependencies
-      run: |
-        rm -rf out
-        pnpm deploy out --prod --filter @growi/app
-        rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
+    - name: Assemble production artifacts
+      run: bash apps/app/bin/assemble-prod.sh
+
+    - name: Check for broken symlinks in .next/node_modules
+      run: bash apps/app/bin/check-next-symlinks.sh
 
     - name: Archive production files
       id: archive-prod-files
       run: |
         tar -zcf production.tar.gz --exclude ./apps/app/.next/cache \
           package.json \
+          node_modules \
           apps/app/.next \
           apps/app/config \
           apps/app/dist \
@@ -76,6 +77,7 @@ jobs:
           apps/app/tmp \
           apps/app/.env.production* \
           apps/app/node_modules \
+          apps/app/next.config.js \
           apps/app/package.json
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
@@ -124,30 +126,22 @@ jobs:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v4
-
-    - uses: pnpm/action-setup@v4
-
     - uses: actions/setup-node@v4
       with:
         node-version: ${{ inputs.node-version }}
-        cache: 'pnpm'
-
-    # avoid setup-node cache failure; see: https://github.com/actions/setup-node/issues/1137
-    - name: Verify PNPM Cache Directory
-      run: |
-        PNPM_STORE_PATH="$( pnpm store path --silent )"
-        [ -d "$PNPM_STORE_PATH" ] || mkdir -vp "$PNPM_STORE_PATH"
 
     - name: Download production files artifact
       uses: actions/download-artifact@v4
       with:
         name: Production Files (node${{ inputs.node-version }})
 
-    - name: Extract procution files
+    - name: Extract production files
       run: |
         tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
 
+    # Run after extraction so pnpm/action-setup@v4 can read packageManager from package.json
+    - uses: pnpm/action-setup@v4
+
     - name: pnpm run server:ci
       working-directory: ./apps/app
       run: |
@@ -179,7 +173,7 @@ jobs:
     container:
       # Match the Playwright version
       # https://github.com/microsoft/playwright/issues/20010
-      image: mcr.microsoft.com/playwright:v1.49.1-jammy
+      image: mcr.microsoft.com/playwright:v1.58.2-jammy
 
     strategy:
       fail-fast: false
@@ -223,14 +217,14 @@ jobs:
       with:
         name: Production Files (node${{ inputs.node-version }})
 
-    - name: Extract procution files
+    - name: Extract production files to isolated directory
       run: |
-        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+        mkdir -p /tmp/growi-prod
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }} -C /tmp/growi-prod
 
     - name: Copy dotenv file for ci
-      working-directory: ./apps/app
       run: |
-        cat config/ci/.env.local.for-ci >> .env.production.local
+        cat apps/app/config/ci/.env.local.for-ci >> /tmp/growi-prod/apps/app/.env.production.local
 
     - name: Playwright Run (--project=chromium/installer)
       if: ${{ matrix.browser == 'chromium' }}
@@ -240,13 +234,13 @@ jobs:
       env:
         DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
-        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
 
     - name: Copy dotenv file for automatic installation
-      working-directory: ./apps/app
       run: |
-        cat config/ci/.env.local.for-auto-install >> .env.production.local
+        cat apps/app/config/ci/.env.local.for-auto-install >> /tmp/growi-prod/apps/app/.env.production.local
 
     - name: Playwright Run
       working-directory: ./apps/app
@@ -255,13 +249,13 @@ jobs:
       env:
         DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
         MONGO_URI: mongodb://mongodb:27017/growi-playwright
-        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
 
     - name: Copy dotenv file for automatic installation with allowing guest mode
-      working-directory: ./apps/app
       run: |
-        cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+        cat apps/app/config/ci/.env.local.for-auto-install-with-allowing-guest >> /tmp/growi-prod/apps/app/.env.production.local
 
     - name: Playwright Run (--project=${browser}/guest-mode)
       working-directory: ./apps/app
@@ -270,8 +264,9 @@ jobs:
       env:
         DEBUG: pw:api
         HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
-        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
 
     - name: Generate shard ID
       id: shard-id

+ 0 - 88
.github/workflows/reusable-app-reg-suit.yml

@@ -1,88 +0,0 @@
-name: Reusable VRT reporting workflow for production
-
-on:
-  workflow_call:
-    inputs:
-      node-version:
-        required: true
-        type: string
-      checkout-ref:
-        type: string
-        default: ${{ github.head_ref }}
-      skip-reg-suit:
-        type: boolean
-      cypress-report-artifact-name-pattern:
-        required: true
-        type: string
-    secrets:
-      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID:
-        required: true
-      AWS_ACCESS_KEY_ID:
-        required: true
-      AWS_SECRET_ACCESS_KEY:
-        required: true
-      SLACK_WEBHOOK_URL:
-        required: true
-    outputs:
-      EXPECTED_IMAGES_EXIST:
-        value: ${{ jobs.run-reg-suit.outputs.EXPECTED_IMAGES_EXIST }}
-
-
-jobs:
-
-  run-reg-suit:
-    # use secrets for "VRT" environment
-    # https://github.com/growilabs/growi/settings/environments/376165508/edit
-    environment: VRT
-
-    if: ${{ !inputs.skip-reg-suit }}
-
-    env:
-      REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
-      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-    runs-on: ubuntu-latest
-
-    outputs:
-      EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
-
-    steps:
-    - uses: actions/checkout@v4
-      with:
-        ref: ${{ inputs.checkout-ref }}
-        fetch-depth: 0
-
-    - uses: pnpm/action-setup@v4
-
-    - uses: actions/setup-node@v4
-      with:
-        node-version: ${{ inputs.node-version }}
-        cache: 'pnpm'
-
-    - name: Install dependencies
-      run: |
-        pnpm install --frozen-lockfile
-
-    - name: Download screenshots taken by cypress
-      uses: actions/download-artifact@v4
-      with:
-        path: apps/app/test/cypress
-        pattern: ${{ inputs.cypress-report-artifact-name-pattern }}
-        merge-multiple: true
-
-    - name: Run reg-suit
-      working-directory: ./apps/app
-      run: |
-        pnpm run reg:run
-
-    - name: Slack Notification
-      uses: weseek/ghaction-slack-notification@master
-      if: failure()
-      with:
-        type: ${{ job.status }}
-        job_name: '*Node CI for growi - run-reg-suit (${{ inputs.node-version }})*'
-        channel: '#ci'
-        isCompactMode: true
-        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@
 
 # dependencies
 node_modules
+node_modules.*
 /.pnp
 .pnp.js
 .pnpm-store

+ 466 - 0
.kiro/specs/auto-scroll/design.md

@@ -0,0 +1,466 @@
+# Design Document: auto-scroll
+
+## Overview
+
+**Purpose**: This feature provides a reusable hash-based auto-scroll mechanism that handles lazy-rendered content across GROWI's Markdown views. It compensates for layout shifts caused by asynchronous component rendering (e.g., Drawio diagrams, Mermaid charts, PlantUML images) by detecting in-progress renders and re-scrolling to the target.
+
+**Users**: End users navigating to hash-linked sections benefit from reliable scroll positioning. Developers integrating the hook into new views (PageView, SearchResultContent, future views) benefit from a standardized, configurable API.
+
+**Impact**: Refactors the existing `useHashAutoScroll` hook from a PageView-specific implementation into a shared, configurable hook. Renames and updates the rendering status attribute protocol for clarity and declarative usage. Also integrates hash-based auto-scroll into `SearchResultContent`, where the content pane has an independent scroll container.
+
+### Goals
+- Provide a single reusable hook for hash-based auto-scroll across all content views
+- Support customizable target resolution and scroll behavior per caller
+- Establish a clear, declarative rendering-status attribute protocol for async-rendering components
+- Maintain robust resource cleanup with timeout-based safety bounds
+- Integrate `SearchResultContent` as a second consumer with container-relative scroll strategy
+
+### Non-Goals
+- Adding `data-growi-is-content-rendering` to attachment-refs (Ref/Refs/RefImg/RefsImg/Gallery), or RichAttachment — these also cause layout shifts but require more complex integration; deferred to follow-up
+- Replacing SearchResultContent's keyword-highlight scroll with hash-based scroll (search pages have no URL hash)
+- Supporting non-browser environments (SSR) — this is a client-only hook
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The current implementation lives in `apps/app/src/components/PageView/use-hash-auto-scroll.tsx`, tightly coupled to PageView via:
+- Hardcoded `document.getElementById(targetId)` for target resolution
+- Hardcoded `element.scrollIntoView()` for scroll execution
+- First parameter named `pageId` implying page-specific usage
+
+The rendering attribute `data-growi-rendering` is defined in `@growi/core` and consumed by:
+- `remark-drawio` (sets attribute on render start, removes on completion)
+- `use-hash-auto-scroll` (observes attribute presence via MutationObserver)
+
+### Architecture Pattern & Boundary Map
+
+> **Note**: This diagram reflects the final architecture after Task 8 module reorganization. See "Task 8 Design" section below for the migration details.
+
+```mermaid
+graph TB
+    subgraph growi_core[growi core]
+        CONST[Rendering Status Constants]
+    end
+
+    subgraph shared_util[src/client/util]
+        WATCH[watchRenderingAndReScroll]
+    end
+
+    subgraph page_view[src/components/PageView]
+        UHAS[useHashAutoScroll]
+        PV[PageView]
+    end
+
+    subgraph search[features/search/.../SearchPage]
+        UKR[useKeywordRescroll]
+        SRC[SearchResultContent]
+    end
+
+    subgraph renderers[Async Renderers]
+        DV[DrawioViewer]
+        MV[MermaidViewer]
+        PUV[PlantUmlViewer]
+        LSX[Lsx]
+    end
+
+    PV -->|calls| UHAS
+    UHAS -->|imports| WATCH
+    SRC -->|calls| UKR
+    UKR -->|imports| WATCH
+    WATCH -->|queries| CONST
+    DV -->|sets/toggles| CONST
+    MV -->|sets/toggles| CONST
+    PUV -->|sets/toggles| CONST
+    LSX -->|sets/toggles| CONST
+```
+
+**Architecture Integration**:
+- Selected pattern: Co-located hooks per consumer + shared utility function — idiomatic React, testable, minimal coupling
+- Domain boundaries: `watchRenderingAndReScroll` (shared pure function) in `src/client/util/`, consumer-specific hooks co-located with their components, constants in `@growi/core`, attribute lifecycle in each renderer package
+- Existing patterns preserved: MutationObserver + polling hybrid, timeout-based safety bounds
+- Steering compliance: Named exports, immutable patterns, co-located tests
+
+**Co-location rationale**: `watchRenderingAndReScroll` lives in `src/client/util/` (not `hooks/`) because it is a plain function, not a React hook — co-located with `smooth-scroll.ts` as both are DOM scroll utilities. `useHashAutoScroll` lives next to `PageView.tsx` because it is hash-navigation–specific (`window.location.hash`) and PageView is its only consumer. `useKeywordRescroll` lives next to `SearchResultContent.tsx` for the same reason. The old `src/client/hooks/use-content-auto-scroll/` shared directory was removed because the hook was never truly shared — only the underlying utility function was.
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | React 18 hooks (`useEffect`) | Hook lifecycle management | No new dependencies |
+| Browser API | MutationObserver, `setTimeout`, `requestAnimationFrame` | DOM observation, polling, and layout timing | Standard Web APIs |
+| Shared Constants | `@growi/core` | Rendering attribute definitions | Existing package |
+
+No new external dependencies are introduced.
+
+## System Flows
+
+### Auto-Scroll Lifecycle
+
+```mermaid
+sequenceDiagram
+    participant Caller as Content View (PageView)
+    participant Hook as useHashAutoScroll
+    participant DOM as DOM
+    participant Watch as watchRenderingAndReScroll
+
+    Caller->>Hook: useHashAutoScroll options
+    Hook->>Hook: Guard checks key, hash, container
+
+    alt Target exists in DOM
+        Hook->>DOM: resolveTarget
+        DOM-->>Hook: HTMLElement
+        Hook->>DOM: scrollTo target
+        Hook->>Watch: start rendering watch (always)
+    else Target not yet in DOM
+        Hook->>DOM: MutationObserver on container
+        DOM-->>Hook: target appears
+        Hook->>DOM: scrollTo target
+        Hook->>Watch: start rendering watch (always)
+    end
+
+    Note over Watch: MutationObserver detects rendering elements,<br/>including those that mount after the initial scroll
+
+    loop While rendering elements exist and within timeout
+        Watch->>DOM: query rendering-status attr
+        DOM-->>Watch: elements found
+        Watch-->>Watch: wait 5s
+        Watch->>DOM: scrollTo target
+    end
+
+    Note over Watch: Auto-cleanup after 10s timeout
+```
+
+Key decisions:
+- The two-phase approach (target observation → rendering watch) runs sequentially.
+- The rendering watch uses a non-resetting timer to prevent starvation from rapid DOM mutations.
+- **The rendering watch always starts after the initial scroll**, regardless of whether rendering elements exist at that moment. This is necessary because async renderers (Mermaid loaded via `dynamic()`, PlantUML images) may mount into the DOM *after* the hook's effect runs. The MutationObserver inside `watchRenderingAndReScroll` (`childList: true, subtree: true`) detects these late-mounting elements.
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1, 1.2 | Immediate scroll to hash target | useHashAutoScroll | UseHashAutoScrollOptions.resolveTarget | Auto-Scroll Lifecycle |
+| 1.3, 1.4, 1.5 | Guard conditions | useHashAutoScroll | UseHashAutoScrollOptions.key, contentContainerId | — |
+| 2.1, 2.2, 2.3 | Deferred scroll for lazy targets | useHashAutoScroll (target observer) | — | Auto-Scroll Lifecycle |
+| 3.1–3.6 | Re-scroll after rendering | watchRenderingAndReScroll | scrollToTarget callback | Auto-Scroll Lifecycle |
+| 4.1–4.7 | Rendering attribute protocol | Rendering Status Constants, DrawioViewer, MermaidViewer, PlantUmlViewer, Lsx | GROWI_IS_CONTENT_RENDERING_ATTR | — |
+| 4.8 | ResizeObserver re-render cycle | DrawioViewer | GROWI_IS_CONTENT_RENDERING_ATTR | — |
+| 5.1–5.5 | Page-type agnostic design | watchRenderingAndReScroll (shared), useHashAutoScroll (PageView), useKeywordRescroll (Search) | — | — |
+| 5.6, 5.7, 6.1–6.3 | Cleanup and safety | useHashAutoScroll, useKeywordRescroll, watchRenderingAndReScroll | cleanup functions | — |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|--------------|--------|--------------|------------------|-----------|
+| useHashAutoScroll | src/components/PageView | Hash-based auto-scroll hook for PageView with configurable target resolution and scroll behavior | 1, 2, 5, 6 | watchRenderingAndReScroll (P0), Rendering Status Constants (P1) | Service |
+| useKeywordRescroll | features/search/.../SearchPage | Keyword-highlight scroll hook with rendering watch integration for SearchResultContent | 5, 6 | watchRenderingAndReScroll (P0), scrollWithinContainer (P0) | Service |
+| watchRenderingAndReScroll | src/client/util | Shared utility: polls for rendering-status attributes and re-scrolls until complete or timeout | 3, 6 | Rendering Status Constants (P0) | Service |
+| Rendering Status Constants | @growi/core | Shared attribute name, value, and selector constants | 4 | None | State |
+| DrawioViewer (modification) | remark-drawio | Declarative rendering-status attribute toggle | 4.3, 4.4, 4.8 | Rendering Status Constants (P0) | State |
+| MermaidViewer (modification) | features/mermaid | Add rendering-status attribute lifecycle to async SVG render | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State |
+| PlantUmlViewer (new) | features/plantuml | Wrap PlantUML `<img>` to provide rendering-status attribute lifecycle | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State |
+| Lsx (modification) | remark-lsx | Add rendering-status attribute lifecycle to async page list fetch | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State |
+
+### Client Hooks
+
+#### useHashAutoScroll
+
+| Field | Detail |
+|-------|--------|
+| Intent | Hash-based auto-scroll hook for PageView that scrolls to a target element identified by URL hash, with support for lazy-rendered content and customizable scroll behavior |
+| Requirements | 1.1–1.5, 2.1–2.3, 5.1–5.7, 6.1–6.3 |
+
+**Responsibilities & Constraints**
+- Orchestrates the full hash-based auto-scroll lifecycle: guard → resolve target → scroll → watch rendering
+- Always delegates to `watchRenderingAndReScroll` after the initial scroll — does **not** skip the watch even when no rendering elements are present at scroll time, because async renderers may mount later
+- Co-located with `PageView.tsx` — this hook is hash-navigation–specific (`window.location.hash`)
+
+**Dependencies**
+- Outbound: `watchRenderingAndReScroll` from `~/client/util/watch-rendering-and-rescroll` (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+/** Configuration for the hash-based auto-scroll hook */
+interface UseHashAutoScrollOptions {
+  /**
+   * Unique key that triggers re-execution when changed.
+   * When null/undefined, all scroll processing is skipped.
+   */
+  key: string | undefined | null;
+
+  /** DOM id of the content container element to observe */
+  contentContainerId: string;
+
+  /**
+   * Optional function to resolve the scroll target element.
+   * Receives the decoded hash string (without '#').
+   * Defaults to: (hash) => document.getElementById(hash)
+   */
+  resolveTarget?: (decodedHash: string) => HTMLElement | null;
+
+  /**
+   * Optional function to scroll to the target element.
+   * Defaults to: (el) => el.scrollIntoView()
+   */
+  scrollTo?: (target: HTMLElement) => void;
+}
+
+/** Hook signature */
+function useHashAutoScroll(options: UseHashAutoScrollOptions): void;
+```
+
+- Preconditions: Called within a React component; browser environment with `window.location.hash` available
+- Postconditions: On unmount or key change, all observers and timers are cleaned up
+- Invariants: At most one target observer and one rendering watch active per hook instance
+
+**Implementation Notes**
+- File location: `apps/app/src/components/PageView/use-hash-auto-scroll.ts`
+- Test file: `apps/app/src/components/PageView/use-hash-auto-scroll.spec.tsx`
+- The `resolveTarget` and `scrollTo` callbacks should be wrapped in `useRef` to avoid re-triggering the effect when callback identity changes
+
+---
+
+#### useKeywordRescroll
+
+| Field | Detail |
+|-------|--------|
+| Intent | Keyword-highlight scroll hook for SearchResultContent that scrolls to the first `.highlighted-keyword` element and re-scrolls after async renderers settle |
+| Requirements | 5.1–5.7, 6.1–6.3 |
+
+**Responsibilities & Constraints**
+- MutationObserver on container for keyword highlight detection (debounced 500ms)
+- `watchRenderingAndReScroll` integration for async renderer layout shift compensation
+- Cleanup of both MO and rendering watch on key change or unmount
+- Co-located with `SearchResultContent.tsx`
+
+**Dependencies**
+- Outbound: `watchRenderingAndReScroll` from `~/client/util/watch-rendering-and-rescroll` (P0)
+- Outbound: `scrollWithinContainer` from `~/client/util/smooth-scroll` (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+interface UseKeywordRescrollOptions {
+  /** Ref to the scrollable container element */
+  scrollElementRef: RefObject<HTMLElement | null>;
+  /** Unique key that triggers re-execution (typically page._id) */
+  key: string;
+}
+
+function useKeywordRescroll(options: UseKeywordRescrollOptions): void;
+```
+
+- Preconditions: `scrollElementRef.current` is a mounted scroll container
+- Postconditions: On unmount or key change, MO disconnected, rendering watch cleaned up, debounce cancelled
+
+**Implementation Notes**
+- File location: `apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.ts`
+- Test file: `apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.spec.tsx`
+- Helper functions (`scrollToKeyword`, `scrollToTargetWithinContainer`) are defined in the hook file since only this hook uses them
+
+---
+
+#### watchRenderingAndReScroll
+
+| Field | Detail |
+|-------|--------|
+| Intent | Pure function (not a hook) that monitors rendering-status attributes and periodically re-scrolls until rendering completes or timeout. Shared utility consumed by both `useHashAutoScroll` and `useKeywordRescroll`. |
+| Requirements | 3.1–3.6, 6.1–6.3 |
+
+**Responsibilities & Constraints**
+- Sets up MutationObserver to detect rendering-status attribute changes **and** new rendering elements added to the DOM (childList + subtree)
+- Manages a non-resetting poll timer (5s interval)
+- Enforces a hard timeout (10s) to prevent unbounded observation
+- Returns a cleanup function
+
+**Dependencies**
+- External: `@growi/core` rendering status constants — attribute selector (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+/**
+ * Watches for elements with in-progress rendering status in the container.
+ * Periodically calls scrollToTarget while rendering elements remain.
+ * Returns a cleanup function that stops observation and clears timers.
+ */
+function watchRenderingAndReScroll(
+  contentContainer: HTMLElement,
+  scrollToTarget: () => boolean,
+): () => void;
+```
+
+- Preconditions: `contentContainer` is a mounted DOM element
+- Postconditions: Cleanup function disconnects observer, clears all timers
+- Invariants: At most one poll timer active at any time; stopped flag prevents post-cleanup execution
+
+**Implementation Notes**
+- File location: `apps/app/src/client/util/watch-rendering-and-rescroll.ts` (co-located with `smooth-scroll.ts`)
+- Test file: `apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx`
+- Add a `stopped` boolean flag checked inside timer callbacks to prevent race conditions between cleanup and queued timer execution
+- When `checkAndSchedule` detects that no rendering elements remain and a timer is currently active, cancel the active timer immediately — avoids a redundant re-scroll after rendering has already completed
+- The MutationObserver watches `childList`, `subtree`, and `attributes` (filtered to the rendering-status attribute) — the `childList` + `subtree` combination is what detects late-mounting async renderers
+- **Performance trade-off**: The function is always started regardless of whether rendering elements exist at call time. This means one MutationObserver + one 10s cleanup timeout run for every hash navigation, even on pages with no async renderers. The initial `checkAndSchedule()` call returns early if no rendering elements are present, so no poll timer is ever scheduled in that case — the only cost is the MO observation and the 10s cleanup timeout itself, which is acceptable.
+- **`querySelector` frequency**: The `checkAndSchedule` callback fires on every `childList` mutation (in addition to attribute changes). Each invocation runs `querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR)` on the container. This call is O(n) on the subtree but stops at the first match and is bounded by the 10s timeout, making it acceptable even for content-heavy pages.
+
+---
+
+### @growi/core Constants
+
+#### Rendering Status Constants
+
+| Field | Detail |
+|-------|--------|
+| Intent | Centralized constants for the rendering-status attribute name, values, and CSS selector |
+| Requirements | 4.1, 4.2, 4.6 |
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+/** Attribute name applied to elements during async content rendering */
+const GROWI_IS_CONTENT_RENDERING_ATTR = 'data-growi-is-content-rendering' as const;
+
+/**
+ * CSS selector matching elements currently rendering.
+ * Matches only the "true" state, not completed ("false").
+ */
+const GROWI_IS_CONTENT_RENDERING_SELECTOR =
+  `[${GROWI_IS_CONTENT_RENDERING_ATTR}="true"]` as const;
+```
+
+- File location: `packages/core/src/consts/renderer.ts` (replaces existing constants)
+- Old constants (`GROWI_RENDERING_ATTR`, `GROWI_RENDERING_ATTR_SELECTOR`) are removed and replaced — no backward compatibility shim needed since all consumers are updated in the same change
+
+---
+
+### remark-drawio Modifications
+
+#### DrawioViewer (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Adopt declarative attribute value toggling instead of imperative add/remove |
+| Requirements | 4.3, 4.4, 4.8 |
+
+**Implementation Notes**
+- Replace `removeAttribute(GROWI_RENDERING_ATTR)` calls with `setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false')`
+- Initial JSX: `{[GROWI_IS_CONTENT_RENDERING_ATTR]: 'true'}` (unchanged pattern, new constant name)
+- Update `SUPPORTED_ATTRIBUTES` in `remark-drawio.ts` to use new constant name
+- Update sanitize option to allow the new attribute name
+- **ResizeObserver re-render cycle** (req 4.8): In the ResizeObserver handler, call `drawioContainerRef.current?.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true')` before `renderDrawioWithDebounce()`. The existing inner MutationObserver (childList) completion path already sets the attribute back to `"false"` after each render.
+
+---
+
+### MermaidViewer Modification
+
+#### MermaidViewer (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Add rendering-status attribute lifecycle to async `mermaid.render()` SVG rendering |
+| Requirements | 4.3, 4.4, 4.7 |
+
+**Implementation Notes**
+- Set `data-growi-is-content-rendering="true"` on the container element at initial render (via JSX spread before `mermaid.render()` is called)
+- After `mermaid.render()` completes and SVG is injected via `innerHTML`, delay the `"false"` signal using **`requestAnimationFrame`** so that the browser can compute the SVG layout before the auto-scroll system re-scrolls. Setting `"false"` synchronously after `innerHTML` assignment would signal completion before the browser has determined the element's final dimensions.
+- Set attribute to `"false"` immediately (without rAF) in the error/catch path, since no layout shift is expected on error
+- Cancel the pending rAF on effect cleanup to prevent state updates on unmounted components
+- File: `apps/app/src/features/mermaid/components/MermaidViewer.tsx`
+- The mermaid remark plugin sanitize options must be updated to include the new attribute name
+
+---
+
+### PlantUmlViewer (new component)
+
+#### PlantUmlViewer
+
+| Field | Detail |
+|-------|--------|
+| Intent | Wrap PlantUML image rendering in a component that signals rendering status, enabling the auto-scroll system to compensate for the layout shift when the external image loads |
+| Requirements | 4.3, 4.4, 4.7 |
+
+**Background**: PlantUML diagrams are rendered as `<img>` tags pointing to an external PlantUML server. The image load is asynchronous and causes a layout shift. The previous implementation had no `data-growi-is-content-rendering` support, so layout shifts from PlantUML images were never compensated.
+
+**Implementation Notes**
+- New component at `apps/app/src/features/plantuml/components/PlantUmlViewer.tsx`
+- Wraps `<img>` in a `<div>` container with `data-growi-is-content-rendering="true"` initially
+- Sets attribute to `"false"` via `onLoad` and `onError` handlers on the `<img>` element
+- The plantuml remark plugin (`plantuml.ts`) is updated to output a custom `<plantuml src="...">` HAST element instead of a plain `<img>`. This allows the renderer to map the `plantuml` element to the `PlantUmlViewer` React component.
+- `sanitizeOption` is exported from the plantuml service and merged in `renderer.tsx` (same pattern as drawio and mermaid)
+- `PlantUmlViewer` is registered as `components.plantuml` in all view option generators (`generateViewOptions`, `generateSimpleViewOptions`, `generatePreviewOptions`)
+
+---
+
+### remark-lsx Modification
+
+#### Lsx (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Add rendering-status attribute lifecycle to async SWR page list fetching |
+| Requirements | 4.3, 4.4, 4.7 |
+
+**Implementation Notes**
+- Set `data-growi-is-content-rendering="true"` on the outermost container element while `isLoading === true` (SWR fetch in progress)
+- Set attribute to `"false"` when data arrives — whether success, error, or empty result
+- Use declarative attribute binding via the existing `isLoading` state (no imperative DOM manipulation needed)
+- File: `packages/remark-lsx/src/client/components/Lsx.tsx`
+- The lsx remark plugin sanitize options must be updated to include the new attribute name
+- `@growi/core` must be added as a dependency of `remark-lsx` (same pattern as `remark-drawio`)
+- **SWR cache hit behavior**: When SWR returns a cached result immediately (`isLoading=false` on first render), the attribute starts at `"false"` and no re-scroll is triggered. This is correct: a cached result means the list renders without a layout shift, so no compensation is needed. The re-scroll mechanism only activates when `isLoading` starts as `"true"` (no cache) and transitions to `"false"` after the fetch completes.
+
+---
+
+### SearchResultContent Integration
+
+#### SearchResultContent (modification)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Integrate rendering-watch into SearchResultContent's keyword scroll so that layout shifts from async renderers are compensated |
+| Requirements | 5.1, 5.4, 5.5, 6.1 |
+
+**Background**: `SearchResultContent` renders page content inside a div with `overflow-y-scroll` (`#search-result-content-body-container`). The keyword-highlight scroll mechanism was originally inlined as a `useEffect` with no dependency array and no cleanup.
+
+**Post-Implementation Correction**: The initial design (tasks 6.1–6.3) attempted to integrate `useContentAutoScroll` (hash-based) into SearchResultContent. This was architecturally incorrect — search pages use `/search?q=foo` with no URL hash, so the hash-driven hook would never activate. See `research.md` "Post-Implementation Finding" for details.
+
+**Final Architecture**: The keyword scroll effect was extracted into a dedicated `useKeywordRescroll` hook (co-located with SearchResultContent), which directly integrates `watchRenderingAndReScroll` for rendering compensation. No hash-based scroll is used in SearchResultContent.
+
+**Hook Call Site**
+
+```typescript
+useKeywordRescroll({ scrollElementRef, key: page._id });
+```
+
+- `scrollElementRef` is the existing React ref pointing to the scroll container
+- `key: page._id` triggers re-execution when the selected page changes
+- The hook internally handles MutationObserver setup, debounced keyword scroll, rendering watch, and full cleanup
+
+**File**: `apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx`
+
+---
+
+## Error Handling
+
+### Error Strategy
+
+This feature operates entirely in the browser DOM layer with no server interaction. Errors are limited to DOM state mismatches.
+
+### Error Categories and Responses
+
+**Target Not Found** (2.3): If the hash target never appears within 10s, the observer disconnects silently. No error is surfaced to the user — this matches browser-native behavior for invalid hash links.
+
+**Container Not Found** (1.5): If the container element ID does not resolve, the hook returns immediately with no side effects.
+
+**Rendering Watch Timeout** (3.6): After 10s, all observers and timers are cleaned up regardless of remaining rendering elements. This prevents resource leaks from components that fail to signal completion.
+

+ 91 - 0
.kiro/specs/auto-scroll/requirements.md

@@ -0,0 +1,91 @@
+# Requirements Document
+
+## Introduction
+
+This specification defines the behavior of the **hash-based auto-scroll** mechanism used across GROWI's content pages. When a user navigates to a URL containing a fragment hash (e.g., `#section-title`), the system scrolls to the corresponding element in the rendered content. Because GROWI pages contain lazily-rendered elements (Drawio diagrams, Mermaid charts, etc.) that cause layout shifts after initial paint, the system must detect in-progress renders and re-scroll to compensate.
+
+This hook is designed to be **page-type agnostic** — it must work in any view that renders Markdown content with a hash-addressable container (PageView, search result previews, etc.).
+
+## Review Feedback (from yuki-takei, PR #10853)
+
+The following reviewer feedback is incorporated into these requirements:
+
+1. **Rendering attribute value**: Use declarative `true`/`false` toggling instead of `setAttribute`/`removeAttribute` — the attribute should always be present with a boolean-like value, not added/removed.
+2. **Attribute naming**: The attribute name should more clearly convey "rendering in progress" status. The name will be finalized in the design phase but must be more descriptive than `data-growi-rendering`.
+3. **Hook generalization**: Move to `src/client/hooks/` for shared use; accept a target-resolving closure instead of hardcoded `getElementById`; support customizable scroll behavior (e.g., `scrollIntoView` for PageView vs. a different method for SearchResultContent); rename the hook accordingly.
+
+## Requirements
+
+### Requirement 1: Immediate Scroll to Hash Target
+
+**Objective:** As a user, I want to be scrolled to the section referenced by the URL hash when I open a page, so that I can directly access the content I was linked to.
+
+#### Acceptance Criteria
+
+1. When the page loads with a URL hash and the target element already exists in the DOM, the hook shall scroll the target element into view immediately.
+2. When the page loads with a URL hash containing encoded characters (e.g., `%E6%97%A5%E6%9C%AC%E8%AA%9E`), the hook shall decode the hash and locate the corresponding element by its `id` attribute.
+3. If the key parameter is null or undefined, the hook shall skip all scroll processing.
+4. If the URL hash is empty, the hook shall skip all scroll processing.
+5. If the content container element is not found in the DOM, the hook shall skip all scroll processing.
+
+### Requirement 2: Deferred Scroll for Lazy-Rendered Targets
+
+**Objective:** As a user, I want the page to scroll to my target section even when the content is rendered after initial page load, so that dynamically rendered headings are still reachable via URL hash.
+
+#### Acceptance Criteria
+
+1. When the page loads with a URL hash and the target element does not yet exist in the DOM, the hook shall observe the content container for DOM mutations until the target appears.
+2. When the target element appears in the DOM during observation, the hook shall immediately scroll it into view.
+3. If the target element does not appear within the watch timeout period (default: 10 seconds), the hook shall stop observing and give up without error.
+
+### Requirement 3: Re-Scroll After Rendering Completion
+
+**Objective:** As a user, I want the view to re-adjust after lazy-rendered content (e.g., Drawio diagrams) finishes rendering, so that layout shifts do not push my target section out of view.
+
+#### Acceptance Criteria
+
+1. When an initial scroll completes and elements whose rendering-status attribute indicates "in progress" exist in the content container, the hook shall schedule a re-scroll after a poll interval (default: 5 seconds).
+2. While elements with in-progress rendering status remain in the container after a re-scroll, the hook shall repeat the poll-and-re-scroll cycle.
+3. When no elements with in-progress rendering status remain after a re-scroll check, the hook shall stop re-scrolling.
+4. When new elements with in-progress rendering status appear in the container (detected via MutationObserver), the hook shall schedule a re-scroll if one is not already pending.
+5. The hook shall not reset a running poll timer when additional DOM mutations occur — only schedule a new timer when no timer is active.
+6. The rendering watch shall automatically terminate after the watch timeout period (default: 10 seconds) regardless of remaining rendering elements.
+
+### Requirement 4: Rendering Status Attribute Protocol
+
+**Objective:** As a developer, I want a standardized attribute for components to signal their rendering status declaratively, so that the auto-scroll system can detect layout-shifting content generically.
+
+#### Acceptance Criteria
+
+1. The attribute name and its CSS selector for the "in progress" state shall be defined as shared constants in `@growi/core`.
+2. The attribute name shall clearly convey that rendering is in progress (e.g., more descriptive than a generic `data-growi-rendering`). The final name will be determined in the design phase.
+3. When a component begins rendering content that will change its dimensions (e.g., Drawio diagram initialization), the component shall set the attribute value to indicate "in progress" (e.g., `"true"`).
+4. When the component finishes rendering or encounters an error, the component shall set the attribute value to indicate "completed" (e.g., `"false"`) rather than removing the attribute entirely — the attribute lifecycle shall be declarative (value toggle), not imperative (add/remove).
+5. The attribute shall be included in the component's HTML sanitization allowlist so that it survives remark/rehype processing.
+6. The CSS selector used by the auto-scroll system shall match only the "in progress" state (e.g., `[attr="true"]`), not the completed state.
+7. The following async-rendering components shall adopt the attribute protocol in this scope: DrawioViewer, MermaidViewer, PlantUmlViewer (new wrapper component), and lsx (Lsx). Other async renderers (attachment-refs, RichAttachment) are deferred to follow-up work.
+8. When a component triggers a secondary re-render that will cause a layout shift (e.g., via ResizeObserver detecting container size changes after initial render), the component shall reset the attribute value to `"true"` before the re-render begins and allow the existing completion path to set it back to `"false"` when done. This ensures the auto-scroll system tracks all layout-shifting render cycles, not only the initial one.
+
+### Requirement 5: Page-Type Agnostic Design
+
+**Objective:** As a developer, I want the auto-scroll hook to be reusable across different page types (wiki pages, search results, etc.), so that hash-based scrolling behaves consistently throughout the application.
+
+#### Acceptance Criteria
+
+1. The hook shall accept a generic key parameter (not limited to page IDs) and a content container element ID as its inputs.
+2. The hook shall accept an optional target-resolving function (closure) that returns the target `HTMLElement | null`. When not provided, the hook shall default to resolving the target via `document.getElementById` using the decoded hash.
+3. The hook shall accept an optional scroll function that defines how to scroll to the target element. When not provided, the hook shall default to `element.scrollIntoView()`. This allows callers (e.g., SearchResultContent) to supply a custom scroll strategy.
+4. The hook shall not import or depend on any page-specific state (Jotai atoms, SWR hooks, or page models).
+5. The shared rendering-watch utility (`watchRenderingAndReScroll`) shall be located in a shared directory (e.g., `src/client/util/`). Each consumer-specific hook shall be co-located with its consumer component and named to reflect its purpose (e.g., hash-based scroll for PageView, keyword-based re-scroll for SearchResultContent).
+6. When the key parameter changes, the hook shall clean up any active observers and timers from the previous run and re-execute the scroll logic.
+7. When the component using the hook unmounts, the hook shall clean up all MutationObservers, timers, and rendering watch resources.
+
+### Requirement 6: Resource Cleanup and Safety
+
+**Objective:** As a developer, I want the hook to be safe against memory leaks and runaway timers, so that it can be used in any component lifecycle without side effects.
+
+#### Acceptance Criteria
+
+1. When the hook's effect cleanup runs, the hook shall disconnect all MutationObservers, clear all pending timers, and invoke any rendering watch cleanup functions.
+2. The hook shall enforce a maximum watch duration (default: 10 seconds) for both target observation and rendering watch, preventing indefinite resource consumption.
+3. While multiple elements with the rendering-status attribute (in-progress state) exist simultaneously, the hook shall execute only one re-scroll (not one per element).

+ 246 - 0
.kiro/specs/auto-scroll/research.md

@@ -0,0 +1,246 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `auto-scroll`
+- **Discovery Scope**: Extension (refactoring existing hook for reusability)
+- **Key Findings**:
+  - `src/client/hooks/` does not exist; hooks are collocated with features — a new shared hooks directory is needed
+  - SearchResultContent has independent scroll-to-highlighted-keyword logic using MutationObserver; coordination needed
+  - MermaidViewer does not implement the rendering attribute protocol; DrawioViewer is the only adopter
+
+## Research Log
+
+### Hook Location and Existing Patterns
+- **Context**: Requirement 5.5 specifies placing the hook in `src/client/hooks/`
+- **Findings**:
+  - `apps/app/src/client/hooks/` does not exist
+  - Existing hooks are collocated: `features/page-tree/hooks/`, `features/openai/client/components/.../hooks/`
+  - No precedent for a top-level shared hooks directory in `src/client/`
+- **Implications**: Creating `src/client/hooks/` establishes a new pattern for cross-feature hooks
+
+### SearchResultContent Scroll Behavior
+- **Context**: Requirement 5 mandates reusability for search result pages
+- **Sources**: `apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx`
+- **Findings**:
+  - Container ID: `search-result-content-body-container`
+  - Container has `overflow-y-scroll` — is the scroll unit, not the viewport
+  - Uses MutationObserver to find `.highlighted-keyword` elements and scroll to the first one using `scrollWithinContainer`
+  - Debounced at 500ms; `SCROLL_OFFSET_TOP = 30`
+  - Does NOT use URL hash — scrolls to highlighted search terms
+  - `useEffect` has no dependency array (fires on every render); no cleanup (intentional per inline comment)
+- **Implications (updated)**:
+  - `scrollIntoView()` default is inappropriate; custom `scrollTo` using `scrollWithinContainer` is required
+  - When `window.location.hash` is non-empty, the keyword scroll overrides hash scroll after 500ms debounce — must be suppressed via early return guard
+  - The `resolveTarget` default (`document.getElementById`) works correctly; heading `id` attributes are set by the remark pipeline
+
+### DrawioViewer Rendering Attribute Pattern
+- **Context**: Requirement 4.4 mandates declarative true/false toggling
+- **Sources**: `packages/remark-drawio/src/components/DrawioViewer.tsx`
+- **Findings**:
+  - Initial render: `{[GROWI_RENDERING_ATTR]: 'true'}` in JSX spread (line 188)
+  - On error: `removeAttribute(GROWI_RENDERING_ATTR)` (line 131)
+  - On complete: `removeAttribute(GROWI_RENDERING_ATTR)` (line 148)
+  - This is imperative add/remove, not declarative value toggle
+- **Implications**: Needs refactoring to `setAttribute(attr, 'false')` on completion/error instead of `removeAttribute`
+
+### MermaidViewer Status
+- **Context**: Could benefit from rendering attribute protocol
+- **Sources**: `apps/app/src/features/mermaid/components/MermaidViewer.tsx`
+- **Findings**:
+  - Does NOT use `GROWI_RENDERING_ATTR`
+  - Uses `mermaid.render()` async with direct `innerHTML` assignment
+  - Mermaid sanitize options only allow `value` attribute
+- **Implications**: Adding Mermaid support is a separate task, not in scope for this spec, but the design should be compatible
+
+### Rendering Attribute Naming
+- **Context**: Reviewer feedback requests a more descriptive name
+- **Findings**:
+  - Current: `data-growi-rendering` — ambiguous (rendering what?)
+  - Candidates considered:
+    - `data-growi-is-rendering-in-progress` — explicit but verbose
+    - `data-growi-rendering-status` — implies multiple states
+    - `data-growi-content-rendering` — slightly more specific
+  - With declarative true/false, a boolean-style name like `data-growi-is-content-rendering` works well
+- **Implications**: Selected `data-growi-is-content-rendering` — clearly a boolean predicate, reads naturally as `is-content-rendering="true"/"false"`
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Custom hook with options object | Single hook with configurable resolveTarget and scrollTo callbacks | Clean API, single import, testable | Options object may grow over time | Selected approach |
+| Separate hooks per page type | usePageHashScroll, useSearchScroll | Type-specific optimization | Duplicated watch/cleanup logic | Rejected — violates DRY |
+| HOC wrapper | Higher-order component wrapping scroll behavior | Framework-agnostic | Harder to compose, less idiomatic React | Rejected — hooks are idiomatic |
+
+## Design Decisions
+
+### Decision: Hook API Shape
+- **Context**: Hook must support PageView (hash-based) and SearchResultContent (keyword-based) with different scroll strategies
+- **Alternatives Considered**:
+  1. Positional parameters — `useAutoScroll(key, containerId, resolveTarget?, scrollFn?)`
+  2. Options object — `useAutoScroll(options)`
+- **Selected Approach**: Options object with required `key` and `contentContainerId`, optional `resolveTarget` and `scrollTo`
+- **Rationale**: Options object is extensible without breaking existing call sites and self-documents parameter intent
+- **Trade-offs**: Slightly more verbose at call site; mitigated by clear defaults
+
+### Decision: Attribute Name
+- **Context**: Reviewer feedback: name should clearly convey "rendering in progress"
+- **Selected Approach**: `data-growi-is-content-rendering` with values `"true"` / `"false"`
+- **Rationale**: Boolean predicate naming (`is-*`) is natural for a two-state attribute; `content-rendering` disambiguates from other rendering concepts
+- **Follow-up**: Update `@growi/core` constant and all consumers
+
+### Decision: CSS Selector for In-Progress State
+- **Context**: Requirement 4.6 — selector must match only in-progress state
+- **Selected Approach**: `[data-growi-is-content-rendering="true"]` instead of bare attribute selector
+- **Rationale**: With declarative true/false toggling, bare `[attr]` matches both states; value selector is required
+
+## Risks & Mitigations
+- **Risk**: SearchResultContent's existing keyword-highlight scroll may conflict with hash-based scroll — **Mitigation**: Guard the keyword-scroll `useEffect` with `if (window.location.hash.length > 0) return;` so hash scroll takes priority when a hash is present; keyword scroll proceeds unchanged otherwise
+- **Risk**: `scrollIntoView()` default scrolls the viewport when SearchResultContent's container has `overflow-y-scroll` — **Mitigation**: Provide a custom `scrollTo` closure using `scrollWithinContainer` with offset from the container's bounding rect
+- **Risk**: Renaming the attribute requires coordinated changes across `@growi/core`, `remark-drawio`, and consuming apps — **Mitigation**: Constants are centralized; single constant rename propagates via imports
+- **Risk**: MutationObserver on `subtree: true` may be expensive on large pages — **Mitigation**: Retained 10s maximum watch timeout from current implementation
+
+## Post-Implementation Finding: SearchResultContent Integration Misalignment
+
+**Discovered after task 6 implementation** during code review conversation.
+
+### Problem
+
+The task 6 implementation added `useContentAutoScroll` to `SearchResultContent`, but this was architecturally incorrect. `useContentAutoScroll` is URL-hash–driven (`if (hash.length === 0) return`) and will never activate in the search results context — the search page URL (`/search?q=foo`) carries no fragment identifier.
+
+### Actual Requirement
+
+The real requirement for SearchResultContent is:
+1. **Keyword scroll** (already working): scroll to the first `.highlighted-keyword` element when content loads, via MutationObserver + 500ms debounce
+2. **Re-scroll after rendering** (missing): when drawio / mermaid diagrams render asynchronously after the initial keyword scroll, the layout shifts and the keyword moves out of view — `watchRenderingAndReScroll` should re-scroll to the keyword once rendering settles
+
+### Current Code State (as of this writing)
+
+`apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx` contains:
+- `useContentAutoScroll(...)` call — **should be removed**
+- keyword scroll `useEffect` with hash guard (`if (window.location.hash.length > 0) return`) — the guard may also be removable depending on how the hook is refactored
+- `scrollToTargetWithinContainer` local helper (shared distance calculation) — **keep**
+
+### Proposed Refactoring Direction
+
+Two-phase refactor, designed for the next session:
+
+**Phase 1 — Immediate fix (SearchResultContent)**
+
+Wire `watchRenderingAndReScroll` directly into the keyword scroll `useEffect`:
+
+```typescript
+useEffect(() => {
+  const scrollElement = scrollElementRef.current;
+  if (scrollElement == null) return;
+
+  const scrollToKeyword = (): boolean => {
+    const toElem = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
+    if (toElem == null) return false;
+    scrollToTargetWithinContainer(toElem, scrollElement);
+    return true;
+  };
+
+  // MutationObserver for incremental content loading (debounced)
+  const observer = new MutationObserver(() => {
+    scrollToFirstHighlightedKeywordDebounced(scrollElement);
+  });
+  observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
+
+  // Rendering watch: re-scroll after drawio/mermaid layout shifts
+  const cleanupWatch = watchRenderingAndReScroll(scrollElement, scrollToKeyword);
+  return cleanupWatch;
+}, [page._id]);
+```
+
+Remove the `useContentAutoScroll` import and call entirely.
+
+**Phase 2 — Architecture improvement (shared hook)**
+
+Reorganize the relationship between `useContentAutoScroll` and `watchRenderingAndReScroll`:
+
+- `watchRenderingAndReScroll` (pure function) is the core shared primitive — **promote it to a named export** so callers other than `useContentAutoScroll` can use it directly
+- Consider introducing a thin React wrapper hook `useRenderingRescroll(scrollToTarget, deps)` that manages the `useEffect` lifecycle for `watchRenderingAndReScroll`, making it composable
+- `useContentAutoScroll` becomes the **hash-navigation–specific** hook: hash guard → target resolution → initial scroll → delegates to `useRenderingRescroll`
+- `SearchResultContent` keyword scroll becomes: MO-debounce → initial scroll → delegates to `useRenderingRescroll`
+- PageView-specific logic (default `scrollIntoView`, `getElementById` resolver) stays in PageView or in `useContentAutoScroll`
+
+Resulting dependency graph:
+
+```
+useContentAutoScroll  ─┐
+                        ├── useRenderingRescroll ── watchRenderingAndReScroll
+SearchResultContent   ─┘
+```
+
+### Key Questions for Next Session Design
+
+1. Should `useRenderingRescroll` be a hook (managing `useEffect` internally) or should callers be responsible for calling it inside their own effect? A hook is more ergonomic; a plain function is more flexible.
+2. The current keyword-scroll `useEffect` has no dependency array (fires every render) and no cleanup — intentional per inline comment. Adding `[page._id]` deps and a cleanup changes this behavior. Is that safe?
+3. Should the hash guard on the keyword-scroll `useEffect` be removed once `useContentAutoScroll` is also removed from `SearchResultContent`?
+
+## Task 8 Analysis: useRenderingRescroll Hook Extraction
+
+### Investigation (2026-04-06)
+
+**Objective**: Determine whether extracting a shared `useRenderingRescroll` hook is architecturally beneficial after tasks 1–7 completion.
+
+**Method**: Code review of current implementations — `useContentAutoScroll` (108 lines), `watchRenderingAndReScroll` (85 lines), `SearchResultContent` keyword-scroll effect (lines 133–161).
+
+### Findings
+
+**1. Hook extraction is architecturally infeasible for `useContentAutoScroll`**
+
+`useContentAutoScroll` calls `watchRenderingAndReScroll` conditionally inside its `useEffect`:
+- On the immediate path: only after `scrollToTarget()` returns true (line 77)
+- On the deferred path: only after the MutationObserver detects the target element (line 91)
+
+React hooks cannot be called conditionally or inside callbacks. A `useRenderingRescroll` hook would need an "enabled" flag pattern, adding complexity without simplification.
+
+**2. Co-located cleanup in SearchResultContent prevents separation**
+
+The keyword-scroll `useEffect` in `SearchResultContent` (lines 135–160) combines:
+- MutationObserver for keyword highlight detection
+- `watchRenderingAndReScroll` for async renderer compensation
+- Single cleanup return that handles both
+
+Extracting the watch into a separate hook would split cleanup across two effects, making the lifecycle harder to reason about.
+
+**3. All three design questions from the original research are resolved**
+
+| Question | Resolution | How |
+|----------|------------|-----|
+| Hook vs. function | Plain function | Conditional call inside effect prevents hook usage |
+| `[page._id]` deps + cleanup safe? | Yes, safe | Implemented in task 7.1, working correctly |
+| Hash guard removal | Already done | Removed in task 7.1 alongside `useContentAutoScroll` removal |
+
+**4. Current architecture is already optimal**
+
+`watchRenderingAndReScroll` as a plain function returning a cleanup closure is the correct abstraction level:
+- Composable into any `useEffect` (conditional or unconditional)
+- No React runtime coupling (testable without `renderHook`)
+- Clean dependency graph with two independent consumers
+
+### Initial Recommendation (superseded)
+
+Initially recommended closing Task 8 without code changes. However, after discussion the scope was revised from "hook extraction" to "module reorganization" — see below.
+
+### Revised Direction: Module Reorganization (2026-04-06)
+
+**Context**: The user observed that while a shared `useRenderingRescroll` hook adds no value (confirmed by analysis above), the current file layout is inconsistent:
+
+1. `useContentAutoScroll` lives in `src/client/hooks/` (shared) but is PageView-specific (hash-dependent)
+2. `watchRenderingAndReScroll` lives next to that hook as if internal, but is the actual shared primitive
+3. SearchResultContent's scroll logic is inlined rather than extracted
+
+**Revised approach**:
+- Move `watchRenderingAndReScroll` to `src/client/util/` — co-located with `smooth-scroll.ts` (both are DOM scroll utilities)
+- Rename `useContentAutoScroll` → `useHashAutoScroll` and move next to `PageView.tsx`
+- Extract keyword-scroll effect from `SearchResultContent` into co-located `useKeywordRescroll` hook
+- Delete `src/client/hooks/use-content-auto-scroll/` directory
+
+**Rationale**: Module co-location over shared directory. Each hook lives next to its only consumer. Only the truly shared primitive (`watchRenderingAndReScroll`) stays in a shared directory — and it moves from `hooks/` to `util/` since it's a plain function, not a hook.
+
+## References
+- [MutationObserver API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) — core browser API used for DOM observation
+- [Element.scrollIntoView()](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) — default scroll behavior
+- PR #10853 reviewer feedback from yuki-takei — driving force for this refactoring

+ 24 - 0
.kiro/specs/auto-scroll/spec.json

@@ -0,0 +1,24 @@
+{
+  "feature_name": "auto-scroll",
+  "created_at": "2026-04-02T00:00:00.000Z",
+  "updated_at": "2026-04-07T12:00:00.000Z",
+  "cleanup_completed": true,
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true,
+      "notes": "All tasks (1–8) complete. Design updated to reflect final architecture after module reorganization."
+    }
+  },
+  "ready_for_implementation": true
+}

+ 154 - 0
.kiro/specs/auto-scroll/tasks.md

@@ -0,0 +1,154 @@
+# Implementation Plan
+
+- [x] 1. Update rendering status constants in @growi/core
+  - Rename the attribute constant from the current name to `data-growi-is-content-rendering` to clearly convey boolean rendering-in-progress semantics
+  - Update the CSS selector constant to match only the in-progress state (`="true"`) rather than bare attribute presence
+  - Remove the old constants — no backward-compatibility aliases since all consumers are updated in the same change
+  - _Requirements: 4.1, 4.2, 4.6_
+
+- [x] 2. Update remark-drawio for declarative rendering attribute protocol
+- [x] 2.1 (P) Adopt declarative value toggling in DrawioViewer component
+  - Change rendering-complete and error paths to set the attribute value to `"false"` instead of removing the attribute entirely
+  - Update the initial JSX spread to use the renamed constant while keeping `"true"` as the initial value
+  - Verify that the wrapper component (DrawioViewerWithEditButton) continues to function without changes
+  - In the ResizeObserver handler, set `attr="true"` before `renderDrawioWithDebounce()` to signal re-render cycles to the auto-scroll system (req 4.8)
+  - _Requirements: 4.3, 4.4, 4.8_
+- [x] 2.2 (P) Update remark-drawio plugin sanitization and node rewriting
+  - Replace the old constant in the supported-attributes array with the new constant name
+  - Update node rewriting to set the new attribute name with `"true"` value on drawio nodes
+  - Confirm the sanitize export still passes the new attribute through HTML sanitization
+  - _Requirements: 4.5_
+
+- [x] 3. Add rendering attribute to MermaidViewer and Lsx
+- [x] 3.1 (P) Add rendering-status attribute lifecycle to MermaidViewer
+  - Set the rendering-status attribute to `"true"` on the container element at initial render before the async SVG render starts
+  - Set the attribute to `"false"` after `mermaid.render()` completes and the SVG is injected into the DOM
+  - Set the attribute to `"false"` in the error/catch path as well
+  - Update the mermaid remark plugin sanitize options to include the new attribute name in the allowlist
+  - _Requirements: 4.3, 4.4, 4.5, 4.7_
+- [x] 3.2 (P) Add rendering-status attribute lifecycle to Lsx component
+  - Set the rendering-status attribute to `"true"` on the outermost container while the SWR page list fetch is loading
+  - Set the attribute to `"false"` when data arrives — success, error, or empty result — using declarative binding from the existing `isLoading` state
+  - Update the lsx remark plugin sanitize options to include the new attribute name in the allowlist
+  - Add `@growi/core` as a dependency of `remark-lsx` (same pattern as `remark-drawio`)
+  - _Requirements: 4.3, 4.4, 4.5, 4.7_
+
+- [x] 4. Implement shared auto-scroll hook
+- [x] 4.1 Implement rendering watch function with safety improvements
+  - Create the `watchRenderingAndReScroll` function in the new shared hooks directory using the updated rendering-status selector
+  - Add a `stopped` boolean flag checked inside timer callbacks to prevent execution after cleanup (race condition fix from PR review)
+  - Maintain the existing non-resetting timer pattern: skip scheduling when a timer is already active
+  - When `checkAndSchedule` detects no rendering elements remain while a timer is still active, cancel the active timer immediately to avoid a redundant re-scroll after rendering has completed
+  - Enforce the 10-second hard timeout that cleans up observer and all timers regardless of remaining rendering elements
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 6.1, 6.2, 6.3_
+  - _Contracts: watchRenderingAndReScroll service interface_
+- [x] 4.2 Implement useContentAutoScroll hook with options object API
+  - Create the hook accepting an options object with `key`, `contentContainerId`, optional `resolveTarget`, and optional `scrollTo`
+  - Implement guard logic: skip processing when key is null/undefined, hash is empty, or container element not found
+  - Implement immediate scroll path: resolve target via provided closure (default: `getElementById`), scroll via provided function (default: `scrollIntoView`), then check for rendering elements before delegating to rendering watch — skip watch entirely if no rendering elements exist
+  - Implement deferred scroll path: MutationObserver on container until target appears, then scroll and conditionally delegate to rendering watch (same check), with 10-second timeout
+  - Store `resolveTarget` and `scrollTo` callbacks in refs to avoid re-triggering the effect on callback identity changes
+  - Wire cleanup to disconnect all observers, clear all timers, and invoke rendering watch cleanup
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 5.1, 5.2, 5.3, 5.4, 5.6, 5.7, 6.1, 6.2_
+  - _Contracts: UseContentAutoScrollOptions, useContentAutoScroll service interface_
+- [x] 4.3 (P) Write tests for watchRenderingAndReScroll
+  - Test that no timer is scheduled when no rendering elements exist
+  - Test that a re-scroll fires after the 5-second poll interval when rendering elements are present
+  - Test that the timer is not reset by intermediate DOM mutations
+  - Test that late-appearing rendering elements are detected by the observer and trigger a timer
+  - Test that only one re-scroll executes per cycle even with multiple rendering elements
+  - Test that the 10-second watch timeout cleans up all resources
+  - Test that the stopped flag prevents timer callbacks from executing after cleanup
+  - Test that an active timer is cancelled when rendering elements are removed before the timer fires
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 6.1, 6.2, 6.3_
+- [x] 4.4 (P) Write tests for useContentAutoScroll
+  - Test guard conditions: no-op when key is null, hash is empty, or container not found
+  - Test immediate scroll when target already exists in DOM
+  - Test deferred scroll when target appears after initial render via MutationObserver
+  - Test that encoded hash values are decoded correctly before target resolution
+  - Test that a custom `resolveTarget` closure is called instead of the default
+  - Test that a custom `scrollTo` function is called instead of the default
+  - Test cleanup on key change: observers and timers from previous run are released
+  - Test cleanup on unmount: all resources are released
+  - Test rendering watch integration: re-scroll fires when rendering elements exist after initial scroll
+  - Test that rendering watch is skipped when no rendering elements exist after initial scroll
+  - Test 10-second timeout for target observation when target never appears
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 5.1, 5.2, 5.3, 5.6, 5.7, 6.1, 6.2_
+
+- [x] 5. Integrate hook into PageView and remove old implementation
+  - Replace the import of the old hook with the new shared hook in PageView
+  - Update the call site to use the options object API with `key: currentPageId` and `contentContainerId` — no custom `resolveTarget` or `scrollTo` needed (defaults match PageView's behavior)
+  - Delete the old hook file and its test file from the PageView directory
+  - Verify that PageView auto-scroll behavior is preserved with manual testing or existing test coverage
+  - _Requirements: 5.1, 5.4, 5.5_
+
+- [x] 6. Integrate useContentAutoScroll into SearchResultContent
+- [x] 6.1 (P) Add hash-based auto-scroll with container-relative scroll strategy
+  - Call `useContentAutoScroll` with `key: page._id` and `contentContainerId: 'search-result-content-body-container'`
+  - Provide a custom `scrollTo` closure that calculates the target element's offset relative to the container's bounding rect and calls `scrollWithinContainer` with the same `SCROLL_OFFSET_TOP` constant already used for keyword scroll
+  - Capture the container via the existing `scrollElementRef` in the closure to avoid a redundant `getElementById` lookup
+  - Do not provide a custom `resolveTarget` — heading elements have `id` attributes set by the remark pipeline, so the default `getElementById` resolver works correctly
+  - _Requirements: 5.1, 5.2, 5.3, 5.5_
+
+- [x] 6.2 (P) Suppress keyword-highlight scroll when a URL hash is present
+  - Add an early return guard at the top of the existing keyword-scroll `useEffect`: if `window.location.hash` is non-empty, return immediately so hash-based scroll is not overridden by the debounced keyword scroll
+  - Preserve the existing keyword-scroll behavior fully when no hash is present — the MutationObserver, debounce interval, and `scrollWithinContainer` call remain unchanged
+  - _Requirements: 5.1, 5.5_
+
+- [x] 6.3 Write tests for SearchResultContent auto-scroll integration
+  - Test that `useContentAutoScroll` is called with the correct `key` and `contentContainerId` when the component mounts
+  - Test that the custom `scrollTo` scrolls within the container (not the viewport) by verifying `scrollWithinContainer` is called with the correct distance
+  - Test that the keyword-scroll `useEffect` skips observation when `window.location.hash` is non-empty
+  - Test that the keyword-scroll `useEffect` sets up the MutationObserver normally when no hash is present
+  - _Requirements: 5.1, 5.2, 5.3, 5.5_
+
+---
+
+## Phase 2: Module Reorganization
+
+> **Context**: Tasks 1–7 delivered all functional requirements. Task 8 reorganizes modules for co-location: each hook moves next to its consumer, and the shared rendering watch utility moves to `src/client/util/`. No behavior changes — pure structural improvement.
+
+- [x] 7. Fix SearchResultContent: replace `useContentAutoScroll` with `watchRenderingAndReScroll`
+- [x] 7.1 Wire `watchRenderingAndReScroll` into keyword-scroll effect
+  - Remove `useContentAutoScroll` import and call from `SearchResultContent.tsx`
+  - Import `watchRenderingAndReScroll` (already exported from `watch-rendering-and-rescroll.ts`)
+  - Inside the keyword-scroll `useEffect`, after setting up the MutationObserver, call `watchRenderingAndReScroll(scrollElement, scrollToKeyword)` where `scrollToKeyword` calls `scrollToTargetWithinContainer` on the first `.highlighted-keyword` element
+  - Add `[page._id]` to the dependency array (currently has no deps) and return the watch cleanup function
+  - Remove the hash guard (`if (window.location.hash.length > 0) return`) — no longer needed once `useContentAutoScroll` is removed
+  - _See research.md for proposed code sketch_
+
+- [x] 7.2 Update SearchResultContent tests
+  - Remove tests that assert `useContentAutoScroll` is called
+  - Add tests that `watchRenderingAndReScroll` re-scrolls to `.highlighted-keyword` after a rendering element settles
+  - Update MutationObserver suppression test: remove the hash-guard test (guard will be gone)
+
+- [x] 8. Reorganize auto-scroll modules by co-locating hooks with their consumers
+- [x] 8.1 Move the rendering watch utility to the shared utility directory
+  - Move the rendering watch function and its test file from the shared hooks directory to the client utility directory, alongside the existing smooth-scroll utility
+  - Update the import path in the hash-based auto-scroll hook to reference the new location
+  - Update the import path in SearchResultContent to reference the new location
+  - Run existing tests to verify no regressions from the path change
+  - _Requirements: 5.4, 5.5_
+- [x] 8.2 Rename and move the hash-based auto-scroll hook next to PageView
+  - Rename the hook and its options type to reflect its hash-navigation–specific purpose (not a generic "content auto-scroll")
+  - Move the hook file and its test file to the PageView component directory
+  - Update PageView's import to use the co-located hook with the new name
+  - Update the hook's internal import of the rendering watch utility to use the path established in 8.1
+  - Run existing tests to verify the rename and move introduce no regressions
+  - _Requirements: 5.4, 5.5_
+- [x] 8.3 Extract the keyword-scroll effect from SearchResultContent into a co-located hook
+  - Create a new hook that encapsulates the MutationObserver-based keyword detection, debounced scroll, and rendering watch integration currently inlined in the component
+  - Accept a ref to the scrollable container and a trigger key as inputs
+  - Move the scroll helper functions (container-relative scroll calculation, first-highlighted-keyword lookup) into the hook file if they are used only by this logic
+  - Replace the inline useEffect in SearchResultContent with a single call to the new hook
+  - _Requirements: 5.4, 5.5, 6.1_
+- [x] 8.4 (P) Write tests for the extracted keyword-rescroll hook
+  - Migrate rendering watch assertions from SearchResultContent tests into the new hook's test file
+  - Add tests for keyword scroll behavior: MutationObserver setup, debounced scroll to the first highlighted keyword, cleanup on key change and unmount
+  - Simplify SearchResultContent tests to verify the hook is called with the correct container ref and key, without re-testing internal scroll behavior
+  - _Requirements: 6.1, 6.2_
+- [x] 8.5 (P) Remove the old shared hooks directory and verify no stale imports
+  - Delete the now-empty auto-scroll hooks directory
+  - Search the codebase for any remaining references to the old directory path and fix them
+  - Run the full test suite and type check to confirm the reorganization is complete
+  - _Requirements: 5.5_

+ 268 - 0
.kiro/specs/collaborative-editor/design.md

@@ -0,0 +1,268 @@
+# Design Document: collaborative-editor
+
+## Overview
+
+**Purpose**: Real-time collaborative editing in GROWI, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution via Yjs CRDT.
+
+**Users**: All GROWI users who use real-time collaborative page editing. System operators manage the WebSocket and persistence infrastructure.
+
+**Impact**: Yjs document synchronization over native WebSocket (`y-websocket`), with Socket.IO continuing to serve non-Yjs real-time events (page room broadcasts, notifications).
+
+### Goals
+- Guarantee a single server-side Y.Doc per page — no split-brain desynchronization
+- Provide real-time bidirectional sync for all connected editors
+- Authenticate and authorize WebSocket connections using existing session infrastructure
+- Persist draft state to MongoDB for durability across reconnections and restarts
+- Bridge awareness/presence events to non-editor UI via Socket.IO rooms
+
+### Non-Goals
+- Changing the Yjs document model, CodeMirror integration, or page save/revision logic
+- Migrating Socket.IO-based UI events to WebSocket
+- Changing the `yjs-writings` MongoDB collection schema or data format
+
+## Architecture
+
+### Architecture Diagram
+
+```mermaid
+graph TB
+    subgraph Client
+        CM[CodeMirror Editor]
+        WP[WebsocketProvider]
+        GS[Global Socket.IO Client]
+    end
+
+    subgraph Server
+        subgraph HTTP Server
+            Express[Express App]
+            SIO[Socket.IO Server]
+            WSS[WebSocket Server - ws]
+        end
+
+        subgraph YjsService
+            UpgradeHandler[Upgrade Handler - Auth]
+            ConnHandler[Connection Handler]
+            DocManager[Document Manager - getYDoc]
+            AwarenessBridge[Awareness Bridge]
+        end
+
+        MDB[(MongoDB - yjs-writings)]
+        SessionStore[(Session Store)]
+    end
+
+    CM --> WP
+    WP -->|ws path yjs pageId| WSS
+    GS -->|socket.io| SIO
+
+    WSS -->|upgrade auth| UpgradeHandler
+    UpgradeHandler -->|parse cookie| SessionStore
+    WSS -->|connection| ConnHandler
+    ConnHandler --> DocManager
+    DocManager --> MDB
+
+    AwarenessBridge -->|io.in room .emit| SIO
+
+    DocManager -->|awareness events| AwarenessBridge
+```
+
+**Key architectural properties**:
+- **Dual transport**: WebSocket for Yjs sync (`/yjs/{pageId}`), Socket.IO for UI events (`/socket.io/`)
+- **Singleton YjsService**: Encapsulates all Yjs document management
+- **Atomic document creation**: `map.setIfUndefined` from lib0 — synchronous get-or-create, no race condition window
+- **Session-based auth**: Cookie parsed from HTTP upgrade request, same session store as Express
+
+### Technology Stack
+
+| Layer | Choice / Version | Role |
+|-------|------------------|------|
+| Client Provider | `y-websocket@^2.x` (WebsocketProvider) | Yjs document sync over WebSocket |
+| Server WebSocket | `ws@^8.x` (WebSocket.Server) | Native WebSocket server, `noServer: true` mode |
+| Server Yjs Utils | `y-websocket@^2.x` (`bin/utils`) | `setupWSConnection`, `getYDoc`, `WSSharedDoc` |
+| Persistence | `y-mongodb-provider` (extended) | Yjs document persistence to `yjs-writings` collection |
+| Event Bridge | Socket.IO `io` instance | Awareness state broadcasting to page rooms |
+| Auth | express-session + passport | WebSocket upgrade authentication via cookie |
+
+## System Flows
+
+### Client Connection Flow
+
+```mermaid
+sequenceDiagram
+    participant C as Client Browser
+    participant WSS as WebSocket Server
+    participant UH as Upgrade Handler
+    participant SS as Session Store
+    participant DM as Document Manager
+    participant MDB as MongoDB
+
+    C->>WSS: HTTP Upgrade GET /yjs/pageId
+    WSS->>UH: upgrade event
+    UH->>SS: Parse cookie, load session
+    SS-->>UH: Session with user
+    UH->>UH: Check page access
+    alt Unauthorized
+        UH-->>C: 401/403, destroy socket
+    else Authorized
+        UH->>WSS: handleUpgrade
+        WSS->>DM: setupWSConnection
+        DM->>DM: getYDoc - atomic get or create
+        alt New document
+            DM->>MDB: bindState - load persisted state
+            MDB-->>DM: Y.Doc state
+        end
+        DM-->>C: Sync Step 1 - state vector
+        C-->>DM: Sync Step 2 - diff
+        DM-->>C: Awareness states
+    end
+```
+
+Authentication happens before `handleUpgrade` — unauthorized connections never reach the Yjs layer. Document creation uses `getYDoc`'s atomic `map.setIfUndefined` pattern.
+
+### Document Lifecycle
+
+```mermaid
+stateDiagram-v2
+    [*] --> Created: First client connects
+    Created --> Active: bindState completes
+    Active --> Active: Clients connect/disconnect
+    Active --> Flushing: Last client disconnects
+    Flushing --> [*]: writeState completes, doc destroyed
+    Flushing --> Active: New client connects before destroy
+```
+
+## Components and Interfaces
+
+| Component | Layer | Intent | Key Dependencies |
+|-----------|-------|--------|-----------------|
+| YjsService | Server / Service | Orchestrates Yjs document lifecycle, exposes public API | ws, y-websocket/bin/utils, MongodbPersistence |
+| UpgradeHandler | Server / Auth | Authenticates and authorizes WebSocket upgrade requests | express-session, passport, Page model |
+| guardSocket | Server / Util | Prevents socket closure by other upgrade handlers during async auth | — |
+| PersistenceAdapter | Server / Data | Bridges MongodbPersistence to y-websocket persistence interface | MongodbPersistence, syncYDoc, Socket.IO io |
+| AwarenessBridge | Server / Events | Bridges y-websocket awareness events to Socket.IO rooms | Socket.IO io |
+| use-collaborative-editor-mode | Client / Hook | Manages WebsocketProvider lifecycle and awareness | y-websocket, yjs |
+
+### YjsService
+
+**Intent**: Manages Yjs document lifecycle, WebSocket server setup, and public API for page save/status integration.
+
+**Responsibilities**:
+- Owns the `ws.WebSocketServer` instance and the y-websocket `docs` Map
+- Initializes persistence via y-websocket's `setPersistence`
+- Registers the HTTP `upgrade` handler (delegating auth to UpgradeHandler)
+- Exposes the same public interface as `IYjsService` for downstream consumers
+
+**Service Interface**:
+
+```typescript
+interface IYjsService {
+  getYDocStatus(pageId: string): Promise<YDocStatus>;
+  syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody>;
+  getCurrentYdoc(pageId: string): Y.Doc | undefined;
+}
+```
+
+- Constructor accepts `httpServer: http.Server` and `io: Server`
+- Uses `WebSocket.Server({ noServer: true })` + y-websocket utils
+- Uses `httpServer.on('upgrade', ...)` with path check for `/yjs/`
+- **CRITICAL**: Socket.IO server must set `destroyUpgrade: false` to prevent engine.io from destroying non-Socket.IO upgrade requests
+
+### UpgradeHandler
+
+**Intent**: Authenticates WebSocket upgrade requests using session cookies and verifies page access.
+
+**Interface**:
+
+```typescript
+type UpgradeResult =
+  | { authorized: true; request: AuthenticatedRequest; pageId: string }
+  | { authorized: false; statusCode: number };
+```
+
+- Runs express-session and passport middleware via `runMiddleware` helper against raw `IncomingMessage`
+- `writeErrorResponse` writes HTTP status line only — socket cleanup deferred to caller (works with `guardSocket`)
+- Guest access: if `user` is undefined but page allows guest access, authorization proceeds
+
+### guardSocket
+
+**Intent**: Prevents other synchronous upgrade handlers from closing the socket during async auth.
+
+**Why this exists**: Node.js EventEmitter fires all `upgrade` listeners synchronously. When the Yjs async handler yields at its first `await`, Next.js's `NextCustomServer.upgradeHandler` runs and calls `socket.end()` for unrecognized paths. This destroys the socket before Yjs auth completes.
+
+**How it works**: Temporarily replaces `socket.end()` and `socket.destroy()` with no-ops before the first `await`. After auth completes, `restore()` reinstates the original methods.
+
+```typescript
+const guard = guardSocket(socket);
+const result = await handleUpgrade(request, socket, head);
+guard.restore();
+```
+
+### PersistenceAdapter
+
+**Intent**: Adapts MongodbPersistence to y-websocket's persistence interface (`bindState`, `writeState`).
+
+**Interface**:
+
+```typescript
+interface YWebsocketPersistence {
+  bindState: (docName: string, ydoc: Y.Doc) => void;
+  writeState: (docName: string, ydoc: Y.Doc) => Promise<void>;
+  provider: MongodbPersistence;
+}
+```
+
+**Key behavior**:
+- `bindState`: Loads persisted state → determines YDocStatus → calls `syncYDoc` → registers awareness event bridge
+- `writeState`: Flushes document state to MongoDB on last-client disconnect
+- Ordering within `bindState` is guaranteed (persistence load → sync → awareness registration)
+
+### AwarenessBridge
+
+**Intent**: Bridges y-websocket per-document awareness events to Socket.IO room broadcasts.
+
+**Published events** (to Socket.IO rooms):
+- `YjsAwarenessStateSizeUpdated` with `awarenessStateSize: number`
+- `YjsHasYdocsNewerThanLatestRevisionUpdated` with `hasNewerYdocs: boolean`
+
+**Subscribed events** (from y-websocket):
+- `WSSharedDoc.awareness.on('update', ...)` — per-document awareness changes
+
+### use-collaborative-editor-mode (Client Hook)
+
+**Intent**: Manages WebsocketProvider lifecycle, awareness state, and CodeMirror extensions.
+
+**Key details**:
+- WebSocket URL: `${wsProtocol}//${window.location.host}/yjs`, room name: `pageId`
+- Options: `connect: true`, `resyncInterval: 3000`
+- Awareness API: `provider.awareness.setLocalStateField`, `.on('update', ...)`
+- All side effects (provider creation, awareness setup) must be outside React state updaters to avoid render-phase violations
+
+## Data Models
+
+No custom data models. Uses the existing `yjs-writings` MongoDB collection via `MongodbPersistence` (extended `y-mongodb-provider`). Collection schema, indexes, and persistence interface (`bindState` / `writeState`) are unchanged.
+
+## Error Handling
+
+| Error Type | Scenario | Response |
+|------------|----------|----------|
+| Auth Failure | Invalid/expired session cookie | 401 on upgrade, socket destroyed |
+| Access Denied | User lacks page access | 403 on upgrade, socket destroyed |
+| Persistence Error | MongoDB read failure in bindState | Log error, serve empty doc (clients sync from each other) |
+| WebSocket Close | Client network failure | Automatic reconnect with exponential backoff (WebsocketProvider built-in) |
+| Document Not Found | getCurrentYdoc for non-active doc | Return undefined |
+
+## Requirements Traceability
+
+| Requirement | Summary | Components |
+|-------------|---------|------------|
+| 1.1, 1.2 | Single Y.Doc per page | DocumentManager (getYDoc atomic pattern) |
+| 1.3, 1.4, 1.5 | Sync integrity on reconnect | DocumentManager, WebsocketProvider |
+| 2.1, 2.2 | y-websocket transport | YjsService, use-collaborative-editor-mode |
+| 2.3 | Coexist with Socket.IO | UpgradeHandler, guardSocket |
+| 2.4 | resyncInterval | WebsocketProvider |
+| 3.1-3.4 | Auth on upgrade | UpgradeHandler |
+| 4.1-4.5 | MongoDB persistence | PersistenceAdapter |
+| 5.1-5.4 | Awareness and presence | AwarenessBridge, use-collaborative-editor-mode |
+| 6.1-6.4 | YDoc status and sync | YjsService |

+ 79 - 0
.kiro/specs/collaborative-editor/requirements.md

@@ -0,0 +1,79 @@
+# Requirements Document
+
+## Introduction
+
+GROWI provides real-time collaborative editing powered by Yjs, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution. The collaborative editing system uses `y-websocket` as the Yjs transport layer over native WebSocket, with MongoDB persistence for draft state and Socket.IO bridging for awareness/presence events to non-editor UI components.
+
+**Scope**: Server-side Yjs document management, client-side Yjs provider, WebSocket authentication, MongoDB persistence integration, and awareness/presence tracking.
+
+**Out of Scope**: The Yjs document model itself, CodeMirror editor integration details, page save/revision logic, or the global Socket.IO infrastructure used for non-Yjs events.
+
+## Requirements
+
+### Requirement 1: Document Synchronization Integrity
+
+**Objective:** As a wiki user editing collaboratively, I want all clients editing the same page to always share a single server-side Y.Doc instance, so that edits are never lost due to document desynchronization.
+
+#### Acceptance Criteria
+
+1. When multiple clients connect to the same page simultaneously, the Yjs Service shall ensure that exactly one Y.Doc instance exists on the server for that page.
+2. When a client connects while another client's document initialization is in progress, the Yjs Service shall return the same Y.Doc instance to both clients without creating a duplicate.
+3. When a client reconnects after a brief network disconnection, the Yjs Service shall synchronize the client with the existing server-side Y.Doc containing all other clients' changes.
+4. While multiple clients are editing the same page, the Yjs Service shall propagate each client's changes to all other connected clients in real time.
+5. If a client's WebSocket connection drops and reconnects, the Yjs Service shall not destroy the server-side Y.Doc while other clients remain connected.
+
+### Requirement 2: WebSocket Transport Layer
+
+**Objective:** As a system operator, I want the collaborative editing transport to use y-websocket over native WebSocket, so that the system benefits from active maintenance and atomic document initialization.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall use `y-websocket` server utilities as the server-side Yjs transport.
+2. The Editor Client shall use `y-websocket`'s `WebsocketProvider` as the client-side Yjs provider.
+3. The WebSocket server shall coexist with the existing Socket.IO server on the same HTTP server instance without port conflicts.
+4. The Yjs Service shall support `resyncInterval` (periodic state re-synchronization) to recover from any missed updates.
+
+### Requirement 3: Authentication and Authorization
+
+**Objective:** As a system administrator, I want WebSocket connections for collaborative editing to be authenticated and authorized, so that only permitted users can access page content via the Yjs channel.
+
+#### Acceptance Criteria
+
+1. When a WebSocket upgrade request is received for collaborative editing, the Yjs Service shall authenticate the user using the existing session/passport mechanism.
+2. When an authenticated user attempts to connect to a page's Yjs document, the Yjs Service shall verify that the user has read access to that page before allowing the connection.
+3. If an unauthenticated or unauthorized WebSocket upgrade request is received, the Yjs Service shall reject the connection with an appropriate HTTP error status.
+4. Where guest access is enabled for a page, the Yjs Service shall allow guest users to connect to that page's collaborative editing session.
+
+### Requirement 4: MongoDB Persistence
+
+**Objective:** As a system operator, I want the Yjs persistence layer to use MongoDB storage, so that draft state is preserved across server restarts and client reconnections.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall use the `yjs-writings` MongoDB collection for document persistence.
+2. The Yjs Service shall use the `MongodbPersistence` implementation (extended `y-mongodb-provider`).
+3. When a Y.Doc is loaded from persistence, the Yjs Service shall apply the persisted state before sending sync messages to connecting clients.
+4. When a Y.Doc receives updates, the Yjs Service shall persist each update to MongoDB with an `updatedAt` timestamp.
+5. When all clients disconnect from a document, the Yjs Service shall flush the document state to MongoDB before destroying the in-memory instance.
+
+### Requirement 5: Awareness and Presence Tracking
+
+**Objective:** As a wiki user, I want to see which other users are currently editing the same page, so that I can coordinate edits and avoid conflicts.
+
+#### Acceptance Criteria
+
+1. While a user is editing a page, the Editor Client shall broadcast the user's presence information (name, username, avatar, cursor color) via the Yjs awareness protocol.
+2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`).
+3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room.
+4. The Editor Client shall display the list of active editors based on awareness state updates from the Yjs provider.
+
+### Requirement 6: YDoc Status and Sync Integration
+
+**Objective:** As a system component, I want the YDoc status detection and force-sync mechanisms to function correctly, so that draft detection, save operations, and revision synchronization work as expected.
+
+#### Acceptance Criteria
+
+1. The Yjs Service shall expose `getYDocStatus(pageId)` returning the correct status (ISOLATED, NEW, DRAFT, SYNCED, OUTDATED).
+2. The Yjs Service shall expose `getCurrentYdoc(pageId)` returning the in-memory Y.Doc instance if one exists.
+3. When a Y.Doc is loaded from persistence (within `bindState`), the Yjs Service shall call `syncYDoc` to synchronize the document with the latest revision based on YDoc status.
+4. The Yjs Service shall expose `syncWithTheLatestRevisionForce(pageId)` for API-triggered force synchronization.

+ 69 - 0
.kiro/specs/collaborative-editor/research.md

@@ -0,0 +1,69 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `collaborative-editor`
+- **Key Findings**:
+  - y-websocket uses atomic `map.setIfUndefined` for document creation — eliminates TOCTOU race conditions
+  - `y-websocket@2.x` bundles both client and server utils with `yjs@^13` compatibility
+  - `ws` package already installed in GROWI; Express HTTP server supports adding WebSocket upgrade alongside Socket.IO
+
+## Design Decisions
+
+### Decision: Use y-websocket@2.x for both client and server
+
+- **Context**: Need yjs v13 compatibility on both client and server sides
+- **Alternatives Considered**:
+  1. y-websocket@3.x client + custom server — more work, v3 SyncStatus not needed
+  2. y-websocket@3.x + @y/websocket-server — requires yjs v14 migration (out of scope)
+  3. y-websocket@2.x for everything — simplest path, proven code
+- **Selected**: Option 3 — `y-websocket@2.x`
+- **Rationale**: Minimizes custom code, proven server utils, compatible with yjs v13, clear upgrade path to v3 + @y/websocket-server when yjs v14 migration happens
+- **Trade-offs**: Miss v3 SyncStatus feature, but `sync` event + `resyncInterval` meets all requirements
+- **Follow-up**: Plan separate yjs v14 migration, then upgrade to y-websocket v3 + @y/websocket-server
+
+### Decision: WebSocket path prefix `/yjs/`
+
+- **Context**: Need URL pattern that doesn't conflict with Socket.IO
+- **Selected**: `/yjs/{pageId}`
+- **Rationale**: Simple, semantic, no conflict with Socket.IO's `/socket.io/` path or Express routes
+
+### Decision: Session-based authentication on WebSocket upgrade
+
+- **Context**: Must authenticate WebSocket connections without Socket.IO middleware
+- **Selected**: Parse session cookie from HTTP upgrade request, deserialize user from session store
+- **Rationale**: Reuses existing session infrastructure — same cookie, same store, same passport serialization
+- **Trade-offs**: Couples to express-session internals, but GROWI already has this coupling throughout
+
+### Decision: Keep Socket.IO for awareness event fan-out
+
+- **Context**: GROWI uses Socket.IO rooms (`page:{pageId}`) to broadcast awareness updates to non-editor components
+- **Selected**: Continue using Socket.IO `io.in(roomName).emit()` for awareness events, bridging from y-websocket awareness
+- **Rationale**: Non-editor UI components already listen on Socket.IO rooms; changing this is out of scope
+
+## Critical Implementation Constraints
+
+### engine.io `destroyUpgrade` setting
+
+Socket.IO's engine.io v6 defaults `destroyUpgrade: true` in its `attach()` method. This causes engine.io to destroy all non-Socket.IO upgrade requests after a 1-second timeout. The Socket.IO server **must** be configured with `destroyUpgrade: false` to allow `/yjs/` WebSocket handshakes to succeed.
+
+### Next.js upgradeHandler race condition (guardSocket pattern)
+
+Next.js's `NextCustomServer.upgradeHandler` registers an `upgrade` listener on the HTTP server. When the Yjs async handler yields at its first `await`, Next.js's synchronous handler runs and calls `socket.end()` for unrecognized paths. The `guardSocket` pattern temporarily replaces `socket.end()`/`socket.destroy()` with no-ops before the first `await`, restoring them after auth completes.
+
+- `prependListener` cannot solve this — it only changes listener order, cannot prevent subsequent listeners from executing
+- Removing Next.js's listener is fragile and breaks HMR
+- Synchronous auth is impossible (requires async MongoDB/session store queries)
+
+### React render-phase violation in use-collaborative-editor-mode
+
+Provider creation and awareness event handlers must be placed **outside** `setProvider(() => { ... })` functional state updaters. If inside, `awareness.setLocalStateField()` triggers synchronous awareness events that update other components during render. All side effects go in the `useEffect` body; `setProvider(_provider)` is called with a plain value.
+
+### y-websocket bindState ordering
+
+y-websocket does NOT await `bindState` before sending sync messages. However, within `bindState` itself, the ordering is guaranteed: persistence load → YDocStatus check → syncYDoc → awareness registration. This consolidation is intentional.
+
+## References
+- [y-websocket GitHub](https://github.com/yjs/y-websocket)
+- [y-websocket-server GitHub](https://github.com/yjs/y-websocket-server) (yjs v14, future migration target)
+- [ws npm](https://www.npmjs.com/package/ws)
+- [y-mongodb-provider](https://github.com/MaxNoetzold/y-mongodb-provider)

+ 22 - 0
.kiro/specs/collaborative-editor/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "collaborative-editor",
+  "created_at": "2026-03-19T00:00:00.000Z",
+  "updated_at": "2026-03-24T00:00:00.000Z",
+  "language": "en",
+  "phase": "active",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 3 - 0
.kiro/specs/collaborative-editor/tasks.md

@@ -0,0 +1,3 @@
+# Implementation Plan
+
+No pending tasks. Use `/kiro:spec-tasks collaborative-editor` to generate tasks for new work.

+ 153 - 0
.kiro/specs/hotkeys/design.md

@@ -0,0 +1,153 @@
+# Technical Design
+
+## Architecture Overview
+
+The GROWI hotkey system manages keyboard shortcuts globally. It uses `tinykeys` (~400 byte) as the key binding engine and a **subscriber component pattern** to execute actions when hotkeys fire.
+
+### Component Diagram
+
+```
+BasicLayout / AdminLayout
+  └─ HotkeysManager (loaded via next/dynamic, ssr: false)
+       ├─ tinykeys(window, bindings) — registers all key bindings
+       └─ renders subscriber components on demand:
+            ├─ EditPage
+            ├─ CreatePage
+            ├─ FocusToGlobalSearch
+            ├─ ShowShortcutsModal
+            ├─ ShowStaffCredit
+            └─ SwitchToMirrorMode
+```
+
+### Key Files
+
+| File | Role |
+|------|------|
+| `src/client/components/Hotkeys/HotkeysManager.tsx` | Core orchestrator — binds all keys via tinykeys, renders subscribers |
+| `src/client/components/Hotkeys/Subscribers/*.tsx` | Individual action handlers rendered when their hotkey fires |
+| `src/components/Layout/BasicLayout.tsx` | Mounts HotkeysManager via `next/dynamic({ ssr: false })` |
+| `src/components/Layout/AdminLayout.tsx` | Mounts HotkeysManager via `next/dynamic({ ssr: false })` |
+
+## Design Decisions
+
+### D1: tinykeys as Binding Engine
+
+**Decision**: Use `tinykeys` (v3) instead of `react-hotkeys` (v2).
+
+**Rationale**:
+- `react-hotkeys` contributes 91 modules to async chunks; `tinykeys` is 1 module (~400 bytes)
+- tinykeys natively supports single keys, modifier combos (`Control+/`), and multi-key sequences (`ArrowUp ArrowUp ...`)
+- No need for custom state machine (`HotkeyStroke`) or detection wrapper (`HotkeysDetector`)
+
+**Trade-off**: tinykeys has no React integration — key binding is done imperatively in a `useEffect` hook rather than declaratively via JSX props. This is acceptable given the simplicity of the binding map.
+
+### D2: Subscriber-Owned Binding Definitions
+
+**Decision**: Each subscriber component exports its own `hotkeyBindings` metadata alongside its React component. `HotkeysManager` imports these definitions and auto-builds the tinykeys binding map — it never hardcodes specific keys or subscriber references.
+
+**Rationale**:
+- True "1 module = 1 hotkey" encapsulation: each subscriber owns its key binding, handler category, and action logic
+- Adding a new hotkey requires creating only one file (the new subscriber); `HotkeysManager` needs no modification
+- Fully satisfies Req 7 AC 2 ("define hotkey without modifying core detection logic")
+- Self-documenting: looking at a subscriber file tells you everything about that hotkey
+
+**Type contract**:
+```typescript
+// Shared type definition in HotkeysManager.tsx or a shared types file
+type HotkeyCategory = 'single' | 'modifier';
+
+type HotkeyBindingDef = {
+  keys: string | string[];   // tinykeys key expression(s)
+  category: HotkeyCategory;  // determines handler wrapper (single = input guard, modifier = no guard)
+};
+
+type HotkeySubscriber = {
+  component: React.ComponentType<{ onDeleteRender: () => void }>;
+  bindings: HotkeyBindingDef;
+};
+```
+
+**Subscriber example**:
+```typescript
+// CreatePage.tsx
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: 'c',
+  category: 'single',
+};
+
+export const CreatePage = ({ onDeleteRender }: Props): null => { /* ... */ };
+```
+
+```typescript
+// ShowShortcutsModal.tsx
+export const hotkeyBindings: HotkeyBindingDef = {
+  keys: ['Control+/', 'Meta+/'],
+  category: 'modifier',
+};
+```
+
+**HotkeysManager usage**:
+```typescript
+// HotkeysManager.tsx
+import * as createPage from './Subscribers/CreatePage';
+import * as editPage from './Subscribers/EditPage';
+// ... other subscribers
+
+const subscribers: HotkeySubscriber[] = [
+  { component: createPage.CreatePage, bindings: createPage.hotkeyBindings },
+  { component: editPage.EditPage, bindings: editPage.hotkeyBindings },
+  // ...
+];
+
+// In useEffect: iterate subscribers to build tinykeys binding map
+```
+
+**Trade-off**: Slightly more structure than a plain object literal, but the pattern is minimal and each subscriber file is fully self-contained.
+
+### D3: Subscriber Render-on-Fire Pattern
+
+**Decision**: Subscriber components are rendered into the React tree only when their hotkey fires, and self-remove after executing their action.
+
+**Rationale**:
+- Preserves the existing GROWI pattern where hotkey actions need access to React hooks (Jotai atoms, SWR, i18n, routing)
+- Components call `onDeleteRender()` after completing their effect to clean up
+- Uses a monotonically incrementing key ref to avoid React key collisions
+
+### D4: Two Handler Categories
+
+**Decision**: `singleKeyHandler` and `modifierKeyHandler` are separated.
+
+**Rationale**:
+- Single-key shortcuts (`e`, `c`, `/`) must be suppressed when the user is typing in input/textarea/contenteditable elements
+- Modifier-key shortcuts (`Control+/`, `Meta+/`) and multi-key sequences should fire regardless of focus, as they are unlikely to conflict with text entry
+- `isEditableTarget()` check is applied only to single-key handlers
+
+### D5: Client-Only Loading
+
+**Decision**: HotkeysManager is loaded via `next/dynamic({ ssr: false })`.
+
+**Rationale**:
+- Keyboard events are client-only; no SSR rendering is needed
+- Dynamic import keeps hotkey modules out of initial server-rendered chunks
+- Both BasicLayout and AdminLayout follow this pattern
+
+## Implementation Deviations from Requirements
+
+| Requirement | Deviation | Justification |
+|-------------|-----------|---------------|
+| Req 8 AC 2: "export typed interfaces for hotkey definitions" | `HotkeyBindingDef` and `HotkeySubscriber` types are exported for subscriber use but not published as a package API | These types are internal to the Hotkeys module; no external consumers need them |
+
+> **Note (task 5)**: Req 8 AC 1 is now fully satisfied — all 6 subscriber components converted from `.jsx` to `.tsx` with TypeScript `Props` types and named exports.
+> **Note (D2 revision)**: Req 7 AC 2 is now fully satisfied — subscriber-owned binding definitions mean adding a hotkey requires only creating a new subscriber file.
+
+## Key Binding Format (tinykeys)
+
+| Category | Format | Example |
+|----------|--------|---------|
+| Single key | `"key"` | `e`, `c`, `"/"` |
+| Modifier combo | `"Modifier+key"` | `"Control+/"`, `"Meta+/"` |
+| Multi-key sequence | `"key1 key2 key3 ..."` (space-separated) | `"ArrowUp ArrowUp ArrowDown ArrowDown ..."` |
+| Platform modifier | `"$mod+key"` | `"$mod+/"` (Control on Windows/Linux, Meta on macOS) |
+
+> Note: The current implementation uses explicit `Control+/` and `Meta+/` rather than `$mod+/` to match the original behavior.
+

+ 101 - 0
.kiro/specs/hotkeys/requirements.md

@@ -0,0 +1,101 @@
+# Requirements Document
+
+## Introduction
+
+GROWI currently uses `react-hotkeys` (v2.0.0, 91 modules in async chunk) to manage keyboard shortcuts via a custom subscriber pattern. The library is identified as an optimization target due to its module footprint. This specification covers the migration from `react-hotkeys` to `tinykeys`, a lightweight (~400B) keyboard shortcut library, while preserving all existing hotkey functionality and the subscriber-based architecture.
+
+### Current Architecture Overview
+
+- **HotkeysDetector**: Wraps `react-hotkeys`'s `GlobalHotKeys` to capture key events and convert them to custom key expressions
+- **HotkeyStroke**: State machine model for multi-key sequence detection (e.g., Konami codes)
+- **HotkeysManager**: Orchestrator that maps strokes to subscriber components and manages their lifecycle
+- **Subscribers**: 6 components (CreatePage, EditPage, FocusToGlobalSearch, ShowShortcutsModal, ShowStaffCredit, SwitchToMirrorMode) that self-define hotkeys via static `getHotkeyStrokes()`
+
+### Registered Hotkeys
+
+| Shortcut | Action |
+|----------|--------|
+| `c` | Open page creation modal |
+| `e` | Start page editing |
+| `/` | Focus global search |
+| `Ctrl+/` or `Meta+/` | Open shortcuts help modal |
+| `↑↑↓↓←→←→BA` | Show staff credits (Konami code) |
+| `XXBBAAYYA↓←` | Switch to mirror mode (Konami code) |
+
+## Requirements
+
+### Requirement 1: Replace react-hotkeys Dependency with tinykeys
+
+**Objective:** As a developer, I want to replace `react-hotkeys` with `tinykeys`, so that the application's async chunk module count is reduced and the hotkey system uses a modern, lightweight library.
+
+#### Acceptance Criteria
+
+1. The GROWI application shall use `tinykeys` as the keyboard shortcut library instead of `react-hotkeys`.
+2. When the migration is complete, the `react-hotkeys` package shall be removed from `package.json` dependencies.
+3. The GROWI application shall not increase the total async chunk module count compared to the current `react-hotkeys` implementation.
+
+### Requirement 2: Preserve Single-Key Shortcut Functionality
+
+**Objective:** As a user, I want single-key shortcuts to continue working after the migration, so that my workflow is not disrupted.
+
+#### Acceptance Criteria
+
+1. When the user presses the `c` key (outside an input/textarea/editable element), the Hotkeys system shall open the page creation modal.
+2. When the user presses the `e` key (outside an input/textarea/editable element), the Hotkeys system shall start page editing if the page is editable and no modal is open.
+3. When the user presses the `/` key (outside an input/textarea/editable element), the Hotkeys system shall open the global search modal.
+
+### Requirement 3: Preserve Modifier-Key Shortcut Functionality
+
+**Objective:** As a user, I want modifier-key shortcuts to continue working after the migration, so that keyboard shortcut help remains accessible.
+
+#### Acceptance Criteria
+
+1. When the user presses `Ctrl+/` (or `Meta+/` on macOS), the Hotkeys system shall open the shortcuts help modal.
+
+### Requirement 4: Preserve Multi-Key Sequence (Konami Code) Functionality
+
+**Objective:** As a user, I want multi-key sequences (Konami codes) to continue working after the migration, so that easter egg features remain accessible.
+
+#### Acceptance Criteria
+
+1. When the user enters the key sequence `↑↑↓↓←→←→BA`, the Hotkeys system shall show the staff credits modal.
+2. When the user enters the key sequence `XXBBAAYYA↓←`, the Hotkeys system shall apply the mirror mode CSS class to the document body.
+3. While a multi-key sequence is in progress, the Hotkeys system shall track partial matches and reset if an incorrect key is pressed.
+
+### Requirement 5: Input Element Focus Guard
+
+**Objective:** As a user, I want single-key shortcuts to not fire when I am typing in an input field, so that keyboard shortcuts do not interfere with text entry.
+
+#### Acceptance Criteria
+
+1. While an `<input>`, `<textarea>`, or `contenteditable` element is focused, the Hotkeys system shall suppress single-key shortcuts (e.g., `c`, `e`, `/`).
+2. While an `<input>`, `<textarea>`, or `contenteditable` element is focused, the Hotkeys system shall still allow modifier-key shortcuts (e.g., `Ctrl+/`).
+
+### Requirement 6: Lifecycle Management and Cleanup
+
+**Objective:** As a developer, I want hotkey bindings to be properly registered and cleaned up on component mount/unmount, so that there are no memory leaks or stale handlers.
+
+#### Acceptance Criteria
+
+1. When a layout component (BasicLayout or AdminLayout) mounts, the Hotkeys system shall register all hotkey bindings.
+2. When a layout component unmounts, the Hotkeys system shall unsubscribe all hotkey bindings.
+3. The Hotkeys system shall provide a cleanup mechanism compatible with React's `useEffect` return pattern.
+
+### Requirement 7: Maintain Subscriber Component Architecture
+
+**Objective:** As a developer, I want the subscriber-based architecture to be preserved or appropriately modernized, so that adding or modifying hotkeys remains straightforward.
+
+#### Acceptance Criteria
+
+1. The Hotkeys system shall support a pattern where each hotkey action is defined as an independent unit (component or handler) with its own key binding definition.
+2. When a new hotkey action is added, the developer shall be able to define it without modifying the core hotkey detection logic.
+3. The Hotkeys system shall support dynamic rendering of subscriber components when their associated hotkey fires.
+
+### Requirement 8: TypeScript Migration
+
+**Objective:** As a developer, I want the migrated hotkey system to use TypeScript, so that the code benefits from type safety and better IDE support.
+
+#### Acceptance Criteria
+
+1. The Hotkeys system shall be implemented in TypeScript (`.ts`/`.tsx` files) rather than JavaScript (`.js`/`.jsx`).
+2. The Hotkeys system shall export typed interfaces for hotkey definitions and handler signatures.

+ 23 - 0
.kiro/specs/hotkeys/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "hotkeys",
+  "created_at": "2026-02-20T00:00:00.000Z",
+  "updated_at": "2026-02-24T12:00:00.000Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleanup_completed": true
+}

+ 29 - 0
.kiro/specs/hotkeys/tasks.md

@@ -0,0 +1,29 @@
+# Implementation Tasks
+
+## Summary
+
+All tasks completed. Migrated from `react-hotkeys` to `tinykeys` with subscriber-owned binding definitions and full TypeScript conversion.
+
+| Task | Description | Requirements |
+|------|-------------|--------------|
+| 1 | Write HotkeysManager tests (TDD) | 2, 3, 5 |
+| 2 | Rewrite HotkeysManager with tinykeys | 1, 2, 3, 4, 5, 6, 8 |
+| 3 | Remove legacy hotkey infrastructure | 1, 7 |
+| 4 | Verify quality and module reduction (-92 modules) | 1 |
+| 5 | Convert 4 JSX subscribers to TypeScript, fix bugs, unify patterns | 7, 8 |
+| 6.1 | Define shared types, add binding exports to all subscribers | 7, 8 |
+| 6.2 | Refactor HotkeysManager to build binding map from subscriber exports | 6, 7 |
+| 7 | Verify refactoring preserves all existing behavior | 1, 2, 3, 4, 5 |
+
+## Requirements Coverage
+
+| Requirement | Tasks |
+|-------------|-------|
+| 1. Replace react-hotkeys with tinykeys | 2, 3, 4, 7 |
+| 2. Preserve single-key shortcuts | 1, 2, 7 |
+| 3. Preserve modifier-key shortcuts | 1, 2, 7 |
+| 4. Preserve multi-key sequences | 2, 7 |
+| 5. Input element focus guard | 1, 2, 7 |
+| 6. Lifecycle management and cleanup | 2, 6.2 |
+| 7. Subscriber component architecture | 3, 5, 6.1, 6.2 |
+| 8. TypeScript migration | 2, 5, 6.1 |

+ 670 - 0
.kiro/specs/migrate-logger-to-pino/design.md

@@ -0,0 +1,670 @@
+# Design Document: migrate-logger-to-pino
+
+## Overview
+
+**Purpose**: This feature migrates GROWI's logging infrastructure from bunyan (with the custom `universal-bunyan` wrapper) to pino, delivering faster structured logging with a smaller dependency footprint.
+
+**Users**: All GROWI developers (logger consumers), operators (log level configuration), and the CI/CD pipeline (dependency management).
+
+**Impact**: Replaces 7 logging-related packages (`bunyan`, `universal-bunyan`, `bunyan-format`, `express-bunyan-logger`, `morgan`, `browser-bunyan`, `@browser-bunyan/console-formatted-stream`) with 3 (`pino`, `pino-pretty`, `pino-http`) plus a new shared package `@growi/logger`. Consumer applications import only `@growi/logger`; `pino-http` is encapsulated within the package.
+
+### Goals
+- Replace bunyan with pino across all apps and packages without functional degradation
+- Preserve namespace-based log level control (config files + env var overrides)
+- Eliminate morgan by consolidating HTTP logging into pino-http
+- Maintain OpenTelemetry diagnostic logger integration
+- Provide a shared `@growi/logger` package as the single logging entry point
+
+### Non-Goals
+- Changing log output semantics (field names, message format) beyond what pino naturally produces
+- Adding new logging capabilities (structured context propagation, remote log shipping)
+- Migrating to pino v10 (deferred until OTel instrumentation supports it)
+- Changing the namespace naming convention (e.g., `growi:service:page`)
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The current logging stack has these layers:
+
+1. **universal-bunyan** — custom wrapper providing: namespace-based level control via config + env vars, platform detection (Node.js/browser), stream selection (bunyan-format for Node.js, ConsoleFormattedStream for browser), logger caching
+2. **Per-app loggerFactory** — thin wrapper that loads dev/prod config and delegates to universal-bunyan
+3. **bunyan / browser-bunyan** — underlying logger implementations
+4. **express-bunyan-logger / morgan** — HTTP request logging middleware
+
+Key patterns to preserve:
+- `loggerFactory(name: string): Logger` as the sole logger creation API
+- Hierarchical colon-delimited namespaces with glob pattern matching
+- Environment variables (`DEBUG`, `TRACE`, etc.) overriding config file levels
+- Dev: human-readable output; Prod: JSON output (toggleable via `FORMAT_NODE_LOG`)
+- Browser: console output with error-level default in production
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph ConsumerApps[Consumer Applications]
+        App[apps/app]
+        Slackbot[apps/slackbot-proxy]
+    end
+
+    subgraph ConsumerPkgs[Consumer Packages]
+        Slack[packages/slack]
+        Remark[packages/remark-attachment-refs]
+    end
+
+    subgraph GrowiLogger[@growi/logger]
+        Factory[LoggerFactory]
+        LevelResolver[LevelResolver]
+        EnvParser[EnvVarParser]
+        TransportSetup[TransportFactory]
+        HttpLogger[HttpLoggerFactory]
+    end
+
+    subgraph External[External Packages]
+        Pino[pino v9.x]
+        PinoPretty[pino-pretty]
+        PinoHttp[pino-http]
+        Minimatch[minimatch]
+    end
+
+    App --> Factory
+    App --> HttpLogger
+    Slackbot --> Factory
+    Slackbot --> HttpLogger
+    Slack --> Factory
+    Remark --> Factory
+
+    Factory --> LevelResolver
+    Factory --> TransportSetup
+    LevelResolver --> EnvParser
+    LevelResolver --> Minimatch
+
+    Factory --> Pino
+    TransportSetup --> PinoPretty
+
+    HttpLogger --> Factory
+    HttpLogger --> PinoHttp
+```
+
+**Architecture Integration**:
+- Selected pattern: Wrapper package (`@growi/logger`) encapsulating pino configuration — mirrors universal-bunyan's role
+- Domain boundary: `@growi/logger` owns all logger creation, level resolution, and transport setup; consumer apps only call `loggerFactory(name)`
+- Existing patterns preserved: factory function signature, namespace conventions, config file structure
+- New components: `LevelResolver` (namespace-to-level matching), `TransportFactory` (dev/prod stream setup), `EnvVarParser` (env variable parsing)
+- Steering compliance: shared package in `packages/` follows monorepo conventions
+- **Dev-only isolation**: modules that are only used in development (`bunyan-format`, `morgan-like-format-options`) reside under `src/dev/` to make the boundary explicit; all are loaded via dynamic import, never statically bundled in production
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Logging Core | pino v9.x | Structured JSON logger for Node.js and browser | Pinned to v9.x for OTel compatibility; see research.md |
+| Dev Formatting | pino-pretty v13.x | Human-readable log output in development | Used as transport (worker thread) |
+| HTTP Logging | pino-http v11.x | Express middleware for request/response logging | Dependency of @growi/logger; not directly imported by consumer apps |
+| Glob Matching | minimatch (existing) | Namespace pattern matching for level config | Already a transitive dependency via universal-bunyan |
+| Shared Package | @growi/logger | Logger factory with namespace/config/env support and HTTP middleware | New package in packages/logger/ |
+
+## System Flows
+
+### Logger Creation Flow
+
+```mermaid
+sequenceDiagram
+    participant App as Application Startup
+    participant Factory as LoggerFactory
+    participant Transport as pino.transport (Worker)
+    participant Root as Root pino Logger
+
+    App->>Factory: initializeLoggerFactory(options)
+    Factory->>Transport: pino.transport(config) — spawns ONE Worker thread
+    Transport-->>Factory: transport stream
+    Factory->>Root: pino({ level: 'trace' }, transport)
+    Root-->>Factory: rootLogger stored in module scope
+```
+
+```mermaid
+sequenceDiagram
+    participant Consumer as Consumer Module
+    participant Factory as LoggerFactory
+    participant Cache as Logger Cache
+    participant Resolver as LevelResolver
+    participant Root as Root pino Logger
+
+    Consumer->>Factory: loggerFactory(namespace)
+    Factory->>Cache: lookup(namespace)
+    alt Cache hit
+        Cache-->>Factory: cached child logger
+    else Cache miss
+        Factory->>Resolver: resolveLevel(namespace, config, envOverrides)
+        Resolver-->>Factory: resolved level
+        Factory->>Root: rootLogger.child({ name: namespace })
+        Root-->>Factory: child logger (shares Worker thread)
+        Factory->>Factory: childLogger.level = resolved level
+        Factory->>Cache: store(namespace, childLogger)
+    end
+    Factory-->>Consumer: Logger
+```
+
+### Level Resolution Flow
+
+```mermaid
+flowchart TD
+    Start[resolveLevel namespace] --> EnvCheck{Env var match?}
+    EnvCheck -->|Yes| EnvLevel[Use env var level]
+    EnvCheck -->|No| ConfigCheck{Config pattern match?}
+    ConfigCheck -->|Yes| ConfigLevel[Use config level]
+    ConfigCheck -->|No| DefaultLevel[Use config default level]
+
+    EnvLevel --> Done[Return level]
+    ConfigLevel --> Done
+    DefaultLevel --> Done
+```
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1–1.4 | Logger factory with namespace support | LoggerFactory, LoggerCache | `loggerFactory()` | Logger Creation |
+| 2.1–2.4 | Config-file level control | LevelResolver, ConfigLoader | `LoggerConfig` type | Level Resolution |
+| 3.1–3.5 | Env var level override | EnvVarParser, LevelResolver | `parseEnvLevels()` | Level Resolution |
+| 4.1–4.4 | Platform-aware logger | LoggerFactory, TransportFactory | `createTransport()` | Logger Creation |
+| 5.1–5.4 | Dev/prod output formatting | TransportFactory | `TransportOptions` | Logger Creation |
+| 6.1–6.4 | HTTP request logging | HttpLoggerMiddleware | `createHttpLogger()` | — |
+| 7.1–7.3 | OpenTelemetry integration | DiagLoggerPinoAdapter | `DiagLogger` interface | — |
+| 8.1–8.5 | Multi-app consistency | @growi/logger package | Package exports | — |
+| 9.1–9.3 | Dependency cleanup | — (removal task) | — | — |
+| 10.1–10.3 | Backward-compatible API | LoggerFactory | `Logger` type export | — |
+| 11.1–11.4 | Pino performance preservation | LoggerFactory | `initializeLoggerFactory`, shared root logger | Logger Creation |
+| 12.1–12.6 | Bunyan-like output format | BunyanFormatTransport, TransportFactory | Custom transport target | Logger Creation |
+| 13.1–13.5 | HTTP logger encapsulation | HttpLoggerFactory | `createHttpLoggerMiddleware()` | — |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|-------------|--------|--------------|-----------------|-----------|
+| LoggerFactory | @growi/logger / Core | Create and cache namespace-bound pino loggers | 1, 4, 8, 10, 11 | pino (P0), LevelResolver (P0), TransportFactory (P0) | Service |
+| LevelResolver | @growi/logger / Core | Resolve log level for a namespace from config + env | 2, 3 | minimatch (P0), EnvVarParser (P0) | Service |
+| EnvVarParser | @growi/logger / Core | Parse env vars into namespace-level map | 3 | — | Service |
+| TransportFactory | @growi/logger / Core | Create pino transport/options for Node.js and browser | 4, 5, 12 | pino-pretty (P1) | Service |
+| BunyanFormatTransport | @growi/logger / Transport | Custom pino transport producing bunyan-format "short" output | 12 | pino-pretty (P1) | Transport |
+| HttpLoggerFactory | @growi/logger / Core | Factory for pino-http Express middleware | 6, 13 | pino-http (P0), LoggerFactory (P0) | Service |
+| DiagLoggerPinoAdapter | apps/app / OpenTelemetry | Wrap pino logger as OTel DiagLogger | 7 | pino (P0) | Service |
+| ConfigLoader | Per-app | Load dev/prod config files | 2 | — | — |
+
+### @growi/logger Package
+
+#### LoggerFactory
+
+| Field | Detail |
+|-------|--------|
+| Intent | Central entry point for creating namespace-bound pino loggers with level resolution and caching |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 4.1, 8.5, 10.1, 10.3 |
+
+**Responsibilities & Constraints**
+- Create pino logger instances with resolved level and transport configuration
+- Cache logger instances per namespace to ensure singleton behavior
+- Detect platform (Node.js vs browser) and apply appropriate configuration
+- Expose `loggerFactory(name: string): pino.Logger` as the public API
+
+**Dependencies**
+- Outbound: LevelResolver — resolve level for namespace (P0)
+- Outbound: TransportFactory — create transport options (P0)
+- External: pino v9.x — logger creation (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+import type { Logger } from 'pino';
+
+interface LoggerConfig {
+  [namespacePattern: string]: string; // pattern → level ('info', 'debug', etc.)
+}
+
+interface LoggerFactoryOptions {
+  config: LoggerConfig;
+}
+
+/**
+ * Initialize the logger factory module with configuration.
+ * Must be called once at application startup before any loggerFactory() calls.
+ */
+function initializeLoggerFactory(options: LoggerFactoryOptions): void;
+
+/**
+ * Create or retrieve a cached pino logger for the given namespace.
+ */
+function loggerFactory(name: string): Logger;
+```
+
+- Preconditions: `initializeLoggerFactory()` called before first `loggerFactory()` call
+- Postconditions: Returns a pino.Logger bound to the namespace with resolved level
+- Invariants: Same namespace always returns the same logger instance
+
+**Implementation Notes**
+- The `initializeLoggerFactory` is called once per app at startup, receiving the merged dev/prod config
+- Browser detection: `typeof window !== 'undefined' && typeof window.document !== 'undefined'`
+- In browser mode, skip transport setup and use pino's built-in `browser` option
+- The factory is a module-level singleton (module scope cache + config)
+- **Performance critical**: `pino.transport()` spawns a Worker thread. It MUST be called **once** inside `initializeLoggerFactory`, not inside `loggerFactory`. Each `loggerFactory(name)` call creates a child logger via `rootLogger.child({ name })` which shares the single Worker thread. Calling `pino.transport()` per namespace would spawn N Worker threads for N namespaces, negating pino's core performance advantage.
+
+#### LevelResolver
+
+| Field | Detail |
+|-------|--------|
+| Intent | Determine the effective log level for a given namespace by matching against config patterns and env var overrides |
+| Requirements | 2.1, 2.3, 2.4, 3.1, 3.2, 3.3, 3.4, 3.5 |
+
+**Responsibilities & Constraints**
+- Match namespace against glob patterns in config (using minimatch)
+- Match namespace against env var-derived patterns (env vars take precedence)
+- Return the most specific matching level, or the `default` level as fallback
+- Parse is done once at module initialization; resolution is per-namespace at logger creation time
+
+**Dependencies**
+- Outbound: EnvVarParser — get env-derived level map (P0)
+- External: minimatch — glob pattern matching (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+interface LevelResolver {
+  /**
+   * Resolve the log level for a namespace.
+   * Priority: env var match > config pattern match > config default.
+   */
+  resolveLevel(
+    namespace: string,
+    config: LoggerConfig,
+    envOverrides: LoggerConfig,
+  ): string;
+}
+```
+
+- Preconditions: `config` contains a `default` key
+- Postconditions: Returns a valid pino log level string
+- Invariants: Env overrides always take precedence over config
+
+#### EnvVarParser
+
+| Field | Detail |
+|-------|--------|
+| Intent | Parse environment variables (DEBUG, TRACE, INFO, WARN, ERROR, FATAL) into a namespace-to-level map |
+| Requirements | 3.1, 3.4, 3.5 |
+
+**Responsibilities & Constraints**
+- Read `process.env.DEBUG`, `process.env.TRACE`, etc.
+- Split comma-separated values into individual namespace patterns
+- Return a flat `LoggerConfig` map: `{ 'growi:*': 'debug', 'growi:service:page': 'trace' }`
+- Parsed once at module load time (not per-logger)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+/**
+ * Parse log-level environment variables into a namespace-to-level map.
+ * Reads: DEBUG, TRACE, INFO, WARN, ERROR, FATAL from process.env.
+ */
+function parseEnvLevels(): LoggerConfig;
+```
+
+- Preconditions: Environment is available (`process.env`)
+- Postconditions: Returns a map where each key is a namespace pattern and value is a level string
+- Invariants: Only the six known env vars are read; unknown vars are ignored
+
+#### TransportFactory
+
+| Field | Detail |
+|-------|--------|
+| Intent | Create pino transport configuration appropriate for the current environment |
+| Requirements | 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.3, 5.4, 12.1, 12.6, 12.7, 12.8 |
+
+**Responsibilities & Constraints**
+- Node.js development: return BunyanFormatTransport config (`singleLine: false`) — **dev only, not imported in production**
+- Node.js production + `FORMAT_NODE_LOG`: return standard `pino-pretty` transport with `singleLine: true` (not bunyan-format)
+- Node.js production default: return raw JSON (stdout) — no transport
+- Browser: return pino `browser` option config (console output, production error-level default)
+- Include `name` field in all output via pino's `name` option
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+import type { LoggerOptions } from 'pino';
+
+interface TransportConfig {
+  /** Pino options for Node.js environment */
+  nodeOptions: Partial<LoggerOptions>;
+  /** Pino options for browser environment */
+  browserOptions: Partial<LoggerOptions>;
+}
+
+/**
+ * Create transport configuration based on environment.
+ * @param isProduction - Whether NODE_ENV is 'production'
+ */
+function createTransportConfig(isProduction: boolean): TransportConfig;
+```
+
+- Preconditions: Called during logger factory initialization
+- Postconditions: Returns valid pino options for the detected environment
+- Invariants: Browser options never include Node.js transports
+
+**Implementation Notes**
+- Dev transport: `{ target: '<resolved-path>/dev/bunyan-format.js' }` — target path resolved via `path.join(path.dirname(fileURLToPath(import.meta.url)), 'dev', 'bunyan-format.js')`; no `options` passed (singleLine defaults to false inside the module)
+- Prod with FORMAT_NODE_LOG: `{ target: 'pino-pretty', options: { translateTime: 'SYS:standard', ignore: 'pid,hostname', singleLine: true } }` — standard pino-pretty, no custom prettifiers
+- Prod without FORMAT_NODE_LOG (or false): raw JSON to stdout (no transport)
+- Browser production: `{ browser: { asObject: false }, level: 'error' }`
+- Browser development: `{ browser: { asObject: false } }` (inherits resolved level)
+- **Important**: The bunyan-format transport path is only resolved/referenced in the dev branch, ensuring the module is never imported in production
+
+#### BunyanFormatTransport
+
+| Field | Detail |
+|-------|--------|
+| Intent | Custom pino transport that produces bunyan-format "short" mode output (development only) |
+| Requirements | 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7 |
+
+**Responsibilities & Constraints**
+- Loaded by `pino.transport()` in a Worker thread — must be a module file, not inline functions
+- Uses pino-pretty internally with `customPrettifiers` to match bunyan-format "short" layout
+- **Development only**: This module is only referenced by TransportFactory in the dev branch; never imported in production
+
+**Dependencies**
+- External: pino-pretty v13.x (P1) — used internally for colorization and base formatting
+
+**Contracts**: Transport [x]
+
+##### Transport Module
+
+```typescript
+// packages/logger/src/dev/bunyan-format.ts
+// Default export: function(opts) → Writable stream (pino transport protocol)
+
+interface BunyanFormatOptions {
+  singleLine?: boolean;
+  colorize?: boolean;
+  destination?: NodeJS.WritableStream;
+}
+```
+
+**Implementation Notes**
+- Uses `messageFormat` in pino-pretty to produce the full line: timestamp + level + name + message
+- `ignore: 'pid,hostname,name,req,res,responseTime'` — suppresses pino-http's verbose req/res objects in dev; the morgan-like `customSuccessMessage` already provides method/URL/status/time on the same line
+- `customPrettifiers: { time: () => '', level: () => '' }` — suppresses pino-pretty's default time/level rendering (handled inside `messageFormat`)
+- Level right-alignment and colorization are implemented inside `messageFormat` using ANSI codes
+- `singleLine` defaults to `false` inside the module; no `options` need to be passed from TransportFactory
+- Since the transport is a separate module loaded by the Worker thread, function options work (no serialization issue)
+- Vite's `preserveModules` ensures `src/dev/bunyan-format.ts` → `dist/dev/bunyan-format.js`
+- `NO_COLOR` environment variable is respected to disable colorization
+
+##### Output Examples
+
+**Dev** (bunyan-format, singleLine: false):
+```
+10:06:30.419Z DEBUG growi:service:PassportService: LdapStrategy: serverUrl is invalid
+10:06:30.420Z  WARN growi:service:PassportService: SamlStrategy: cert is not set.
+    extra: {"field":"value"}
+```
+
+**Dev HTTP log** (bunyan-format + morgan-like format, req/res suppressed):
+```
+10:06:30.730Z  INFO express: GET /applicable-grant?pageId=abc 304 - 16ms
+```
+
+**Prod + FORMAT_NODE_LOG** (standard pino-pretty, singleLine: true):
+```
+[2026-03-30 12:00:00.000] INFO (growi:service:search): Elasticsearch is enabled
+```
+
+**Prod default**: raw JSON (no transport, unchanged)
+
+### HTTP Logging Layer
+
+#### HttpLoggerFactory
+
+| Field | Detail |
+|-------|--------|
+| Intent | Encapsulate pino-http middleware creation within @growi/logger so consumers don't depend on pino-http |
+| Requirements | 6.1, 6.2, 6.3, 6.4, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6 |
+
+**Responsibilities & Constraints**
+- Create pino-http middleware using a logger from LoggerFactory
+- In development mode: dynamically import and apply `morganLikeFormatOptions` (customSuccessMessage, customErrorMessage, customLogLevel)
+- In production mode: use pino-http's default message format (no morgan-like module imported)
+- Accept optional `autoLogging` configuration for route filtering
+- Return Express-compatible middleware
+- Encapsulate `pino-http` as an internal dependency of `@growi/logger`
+
+**Dependencies**
+- External: pino-http v11.x (P0)
+- Inbound: LoggerFactory — provides base logger (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+import type { RequestHandler } from 'express';
+
+interface HttpLoggerOptions {
+  /** Logger namespace, defaults to 'express' */
+  namespace?: string;
+  /** Auto-logging configuration (e.g., route ignore patterns) */
+  autoLogging?: {
+    ignore: (req: { url?: string }) => boolean;
+  };
+}
+
+/**
+ * Create Express middleware for HTTP request logging.
+ * In dev: uses pino-http with morgan-like formatting (dynamically imported).
+ * In prod: uses pino-http with default formatting.
+ */
+async function createHttpLoggerMiddleware(options?: HttpLoggerOptions): Promise<RequestHandler>;
+```
+
+- Preconditions: LoggerFactory initialized
+- Postconditions: Returns Express middleware that logs HTTP requests
+- Invariants: morganLikeFormatOptions applied only in dev; static file paths skipped when autoLogging.ignore provided
+
+**Implementation Notes**
+- The type assertion for Logger<string> → pino-http's Logger is handled internally, hidden from consumers
+- `pino-http` moves from apps' dependencies to `@growi/logger`'s dependencies
+- **Browser compatibility**: `pino-http` is imported lazily inside the function body (`const { default: pinoHttp } = await import('pino-http')`) rather than at the module top-level. This prevents bundlers (Turbopack/webpack) from pulling the Node.js-only `pino-http` into browser bundles when `@growi/logger` is imported by shared code
+- `morganLikeFormatOptions` is dynamically imported (`await import('./dev/morgan-like-format-options')`) only when `NODE_ENV !== 'production'`, ensuring the module is not loaded in production
+- The function is `async` to support the dynamic imports; consumers call: `express.use(await createHttpLoggerMiddleware({ autoLogging: { ignore: ... } }))`
+
+### OpenTelemetry Layer
+
+#### DiagLoggerPinoAdapter
+
+| Field | Detail |
+|-------|--------|
+| Intent | Adapt a pino logger to the OpenTelemetry DiagLogger interface |
+| Requirements | 7.1, 7.2, 7.3 |
+
+**Responsibilities & Constraints**
+- Implement the OTel `DiagLogger` interface (`error`, `warn`, `info`, `debug`, `verbose`)
+- Map `verbose()` to pino's `trace()` level
+- Parse JSON strings in message arguments (preserving current behavior)
+- Disable `@opentelemetry/instrumentation-pino` if enabled by default
+
+**Dependencies**
+- External: pino v9.x (P0)
+- External: @opentelemetry/api (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+import type { DiagLogger } from '@opentelemetry/api';
+
+/**
+ * Create a DiagLogger that delegates to a pino logger.
+ * Maps OTel verbose level to pino trace level.
+ */
+function createDiagLoggerAdapter(): DiagLogger;
+```
+
+- Preconditions: LoggerFactory initialized, pino logger available for OTel namespace
+- Postconditions: Returns a valid DiagLogger implementation
+- Invariants: All DiagLogger methods delegate to the corresponding pino level
+
+**Implementation Notes**
+- Minimal change from current `DiagLoggerBunyanAdapter` — rename class, update import from bunyan to pino
+- `parseMessage` helper can remain largely unchanged
+- In OTel SDK configuration, replace `'@opentelemetry/instrumentation-bunyan': { enabled: false }` with `'@opentelemetry/instrumentation-pino': { enabled: false }` if the instrumentation package is present
+
+## Data Models
+
+Not applicable. This feature modifies runtime logging behavior and does not introduce or change persisted data models.
+
+## Error Handling
+
+### Error Strategy
+Logging infrastructure must be resilient — a logger failure must never crash the application.
+
+### Error Categories and Responses
+- **Missing config file**: Fall back to `{ default: 'info' }` and emit a console warning
+- **Invalid log level in config/env**: Ignore the entry and log a warning to stderr
+- **Transport initialization failure** (pino-pretty not available): Fall back to raw JSON output
+- **Logger creation failure**: Return a no-op logger that silently discards messages
+
+### Monitoring
+- Logger initialization errors are written to `process.stderr` directly (cannot use the logger itself)
+- No additional monitoring infrastructure required — this is the monitoring infrastructure
+
+## Addendum: Formatting Improvements (Post-Migration)
+
+> Added 2026-03-30. The core migration is complete. This section covers log output readability improvements based on operator feedback.
+
+### Background
+
+- Morgan was used in dev because bunyan's express logging was too verbose
+- Morgan's one-liner format (`GET /path 200 12ms`) was valued for readability
+- `FORMAT_NODE_LOG=true` should produce concise one-liner logs suitable for quick-glance monitoring
+- Production default should remain structured JSON (already working via `.env.production`)
+
+### Gap Summary
+
+| Gap | Issue | Resolution |
+|-----|-------|------------|
+| A | `singleLine: false` in prod FORMAT_NODE_LOG path | Change to `singleLine: true` |
+| B | `FORMAT_NODE_LOG` defaults to formatted when unset | Defer to separate PR (`.env.production` handles this) |
+| C | pino-http uses default verbose messages | Add `customSuccessMessage` / `customErrorMessage` / `customLogLevel` |
+| D | Dev and prod pino-pretty configs identical | Differentiate via `singleLine` |
+
+### Change 1: TransportFactory — Differentiated `singleLine`
+
+**File**: `packages/logger/src/transport-factory.ts`
+
+Current production + FORMAT_NODE_LOG branch uses `singleLine: false`. Change to `singleLine: true`:
+
+```
+Dev:                    singleLine: false  (unchanged — full context)
+Prod + FORMAT_NODE_LOG: singleLine: true   (concise one-liners)
+Prod default:           raw JSON           (unchanged)
+```
+
+The dev branch remains multi-line so developers see full object context. The production formatted path becomes single-line for operator readability.
+
+### Change 2: HttpLoggerMiddleware — Custom Message Format
+
+**Files**: `apps/app/src/server/crowi/index.ts`, `apps/slackbot-proxy/src/Server.ts`
+
+Add pino-http message customization to produce morgan-like output:
+
+```typescript
+const customSuccessMessage: PinoHttpOptions['customSuccessMessage'] = (req, res, responseTime) => {
+  return `${req.method} ${req.url} ${res.statusCode} - ${Math.round(responseTime)}ms`;
+};
+
+const customErrorMessage: PinoHttpOptions['customErrorMessage'] = (req, res, error) => {
+  return `${req.method} ${req.url} ${res.statusCode} - ${error.message}`;
+};
+
+const customLogLevel: PinoHttpOptions['customLogLevel'] = (_req, res, error) => {
+  if (error != null || res.statusCode >= 500) return 'error';
+  if (res.statusCode >= 400) return 'warn';
+  return 'info';
+};
+```
+
+### Output Examples (Updated with dev-only bunyan-like format)
+
+**Dev** (bunyan-format transport + morgan-like HTTP messages):
+```
+10:06:30.419Z  INFO express: GET /page/path 200 - 12ms
+    req: {"method":"GET","url":"/page/path"}
+    res: {"statusCode":200}
+```
+
+**Prod + FORMAT_NODE_LOG=true** (standard pino-pretty, default pino-http messages):
+```
+[2026-03-30 12:00:00.000] INFO (express): request completed
+```
+
+**Prod default** (JSON, default pino-http messages):
+```json
+{"level":30,"time":1711792800000,"name":"express","msg":"request completed","req":{"method":"GET","url":"/page/path"},"res":{"statusCode":200},"responseTime":12}
+```
+
+### Testing
+
+- `transport-factory.spec.ts`: Verify transport target contains `bunyan-format` (not pino-pretty directly); dev transport passes no options (singleLine handled inside bunyan-format); prod + FORMAT_NODE_LOG returns pino-pretty with `singleLine: true`
+- `bunyan-format.spec.ts`: Verify transport module produces `HH:mm:ss.SSSZ LEVEL name: message` format; verify req/res are excluded from output
+- `http-logger.spec.ts`: Verify `createHttpLoggerMiddleware` returns middleware, applies morganLikeFormatOptions in dev, passes autoLogging options
+- `morgan-like-format-options.spec.ts`: Verify message formats using `strip()` to remove ANSI codes before assertion; verify customLogLevel returns correct levels for 2xx/4xx/5xx
+
+---
+
+## Addendum: HTTP Logger Encapsulation (Post-Migration)
+
+> Added 2026-04-02. Moves pino-http usage from consumer apps into @growi/logger.
+
+### Background
+
+- Consumer apps (`apps/app`, `apps/slackbot-proxy`) currently import `pino-http` directly
+- This leaks implementation details and requires each app to configure morgan-like format options
+- Encapsulating in `@growi/logger` provides a single configuration point and cleaner dependency graph
+
+### Changes
+
+1. **New file**: `packages/logger/src/http-logger.ts` — exports `createHttpLoggerMiddleware(options)`
+2. **Package.json**: Add `pino-http` to `@growi/logger` dependencies
+3. **apps/app**: Replace direct `pino-http` import with `createHttpLoggerMiddleware` from `@growi/logger`
+4. **apps/slackbot-proxy**: Same as apps/app
+5. **Cleanup**: Remove `pino-http` from apps' direct dependencies (keep in @growi/logger)
+
+---
+
+## Addendum: Dev-Only Module Isolation and Browser Compatibility (Post-Migration)
+
+> Added 2026-04-06. Restructures dev-only modules and fixes browser bundle compatibility.
+
+### Background
+
+- `bunyan-format` and `morgan-like-format-options` were mixed with production modules at the `src/` root level
+- `pino-http` imported at the module top-level caused browser bundle errors (Turbopack: `TypeError: __turbopack_context__.r(...).symbols is undefined`) when `@growi/logger` was imported by shared page code
+- HTTP request logs in dev were verbose (multi-line `req`/`res` JSON objects)
+- HTTP status codes in dev lacked visual differentiation
+
+### Changes
+
+1. **`src/dev/` directory**: All dev-only modules moved under `src/dev/`
+   - `src/transports/bunyan-format.ts` → `src/dev/bunyan-format.ts`
+   - `src/morgan-like-format-options.ts` → `src/dev/morgan-like-format-options.ts`
+   - `src/transports/` directory removed
+2. **`index.ts`**: Removed static `export { morganLikeFormatOptions }` — dev-only module must not appear in production-facing package exports
+3. **`http-logger.ts`**: `pino-http` import moved from module top-level into the async function body (`const { default: pinoHttp } = await import('pino-http')`) — prevents browser bundlers from including the Node.js-only package
+4. **`bunyan-format.ts`**: `ignore` extended to `'pid,hostname,name,req,res,responseTime'` — suppresses verbose pino-http req/res objects; morgan-like `customSuccessMessage` already provides all relevant HTTP metadata on one line
+5. **`morgan-like-format-options.ts`**: ANSI color codes added for status code (2xx=green, 3xx=cyan, 4xx=yellow, 5xx=red) and dim response time; `NO_COLOR` env var respected

+ 156 - 0
.kiro/specs/migrate-logger-to-pino/requirements.md

@@ -0,0 +1,156 @@
+# Requirements Document
+
+## Introduction
+
+GROWI currently uses bunyan as its logging library, wrapped by the custom `universal-bunyan` package (developed by WeSeek). The system provides namespace-based hierarchical logging with environment variable-driven log level control, platform detection (Node.js/Browser), and different output formatting for development and production environments. Morgan is used for HTTP request logging in development mode while `express-bunyan-logger` handles production HTTP logging.
+
+This specification covers the complete migration from bunyan to pino, replacing `universal-bunyan` with an equivalent pino-based solution, and eliminating morgan by consolidating HTTP request logging under pino. The migration must preserve all existing functionality without degradation.
+
+### Current Components to Replace
+- `bunyan` → `pino`
+- `universal-bunyan` (custom) → pino-based equivalent (official packages preferred, custom wrapper where needed)
+- `bunyan-format` → pino transport equivalent (e.g., `pino-pretty`)
+- `express-bunyan-logger` → `pino-http` or equivalent
+- `morgan` (dev only) → consolidated into pino-http
+- `browser-bunyan` / `@browser-bunyan/console-formatted-stream` → pino browser mode or equivalent
+- `@types/bunyan` → pino's built-in types
+
+## Requirements
+
+### Requirement 1: Logger Factory with Namespace Support
+
+**Objective:** As a developer, I want to create loggers with hierarchical namespace identifiers (e.g., `growi:service:page`), so that I can identify the source of log messages and control granularity per module.
+
+#### Acceptance Criteria
+1. The Logger Factory shall provide a `loggerFactory(name: string)` function that returns a logger instance bound to the given namespace.
+2. When `loggerFactory` is called multiple times with the same namespace, the Logger Factory shall return the same cached logger instance.
+3. The Logger Factory shall support colon-delimited hierarchical namespaces (e.g., `growi:crowi`, `growi:routes:login`).
+4. The Logger Factory shall maintain API compatibility so that callers use `logger.info()`, `logger.debug()`, `logger.warn()`, `logger.error()`, `logger.trace()`, and `logger.fatal()` without changes to call sites.
+
+### Requirement 2: Namespace-Based Log Level Configuration via Config Files
+
+**Objective:** As a developer, I want to define per-namespace log levels in configuration files (separate for dev and prod), so that I can fine-tune verbosity for specific modules without restarting with different env vars.
+
+#### Acceptance Criteria
+1. The Logger Factory shall load a configuration object mapping namespace patterns to log levels (e.g., `{ 'growi:service:*': 'debug', 'default': 'info' }`).
+2. The Logger Factory shall select the dev or prod configuration based on the `NODE_ENV` environment variable.
+3. The Logger Factory shall support glob pattern matching (e.g., `growi:service:*`) for namespace-to-level mapping using minimatch-compatible syntax.
+4. When no specific namespace match exists, the Logger Factory shall fall back to the `default` level defined in the configuration.
+
+### Requirement 3: Environment Variable-Based Log Level Override
+
+**Objective:** As an operator, I want to override log levels at runtime via environment variables, so that I can enable debug/trace logging for specific namespaces without modifying code or config files.
+
+#### Acceptance Criteria
+1. The Logger Factory shall read the environment variables `DEBUG`, `TRACE`, `INFO`, `WARN`, `ERROR`, and `FATAL` to parse namespace patterns.
+2. When an environment variable (e.g., `DEBUG=growi:routes:*,growi:service:page`) is set, the Logger Factory shall apply the corresponding log level to all matching namespaces.
+3. When both a config file entry and an environment variable match the same namespace, the environment variable shall take precedence.
+4. The Logger Factory shall support comma-separated namespace patterns within a single environment variable value.
+5. The Logger Factory shall support glob wildcard patterns (e.g., `growi:*`) in environment variable values.
+
+### Requirement 4: Platform-Aware Logger (Node.js and Browser)
+
+**Objective:** As a developer, I want the logger to work seamlessly in both Node.js (server) and browser (client) environments, so that I can use the same `loggerFactory` import in universal/shared code.
+
+#### Acceptance Criteria
+1. The Logger Factory shall detect the runtime environment (Node.js vs browser) and instantiate the appropriate logger implementation.
+2. While running in a browser environment, the Logger Factory shall output logs to the browser's developer console with readable formatting.
+3. While running in a browser production environment, the Logger Factory shall default to `error` level to minimize console noise.
+4. While running in a Node.js environment, the Logger Factory shall output structured logs suitable for machine parsing or human-readable formatting depending on configuration.
+
+### Requirement 5: Output Formatting (Development vs Production)
+
+**Objective:** As a developer/operator, I want distinct log output formats for development and production, so that dev logs are human-readable while production logs are structured and parseable.
+
+#### Acceptance Criteria
+1. While `NODE_ENV` is not `production`, the Logger Factory shall output human-readable formatted logs (equivalent to bunyan-format `short` mode) using pino-pretty or an equivalent transport.
+2. While `NODE_ENV` is `production`, the Logger Factory shall output structured JSON logs by default.
+3. Where the `FORMAT_NODE_LOG` environment variable is set, the Logger Factory shall respect it to toggle between formatted and raw JSON output in production (formatted by default when `FORMAT_NODE_LOG` is unset or truthy).
+4. The Logger Factory shall include the logger namespace in all log output so that the source module is identifiable.
+
+### Requirement 6: HTTP Request Logging
+
+**Objective:** As a developer/operator, I want HTTP request logging integrated with pino, so that request/response metadata is captured in a consistent format alongside application logs, eliminating the need for morgan.
+
+#### Acceptance Criteria
+1. The GROWI Server shall log HTTP requests using `pino-http` or an equivalent pino-based middleware, replacing both `morgan` (dev) and `express-bunyan-logger` (prod).
+2. While in development mode, the HTTP Logger shall skip logging for Next.js static file requests (paths starting with `/_next/static/`).
+3. The HTTP Logger shall use a logger instance obtained from the Logger Factory with the namespace `express` (or equivalent) for consistency with existing log namespaces.
+4. The HTTP Logger shall include standard HTTP metadata (method, URL, status code, response time) in log entries.
+
+### Requirement 7: OpenTelemetry Integration
+
+**Objective:** As a developer, I want the pino-based logger to integrate with OpenTelemetry diagnostics, so that observability tooling continues to function after migration.
+
+#### Acceptance Criteria
+1. The OpenTelemetry DiagLogger adapter shall be updated to wrap pino instead of bunyan.
+2. The OpenTelemetry DiagLogger adapter shall map OpenTelemetry verbose level to pino trace level.
+3. The OpenTelemetry SDK configuration shall disable pino instrumentation if an equivalent auto-instrumentation exists (analogous to the current bunyan instrumentation disable).
+
+### Requirement 8: Multi-App Consistency
+
+**Objective:** As a developer, I want all GROWI monorepo applications to use the same pino-based logging solution, so that logging behavior and configuration are consistent across the platform.
+
+#### Acceptance Criteria
+1. The `apps/app` application shall use the pino-based Logger Factory.
+2. The `apps/slackbot-proxy` application shall use the pino-based Logger Factory.
+3. The `packages/slack` package shall use the pino-based Logger Factory.
+4. The `packages/remark-attachment-refs` package shall use the pino-based Logger Factory.
+5. The Logger Factory shall be published as a shared package within the monorepo so that all consumers import from a single source.
+
+### Requirement 9: Dependency Cleanup
+
+**Objective:** As a maintainer, I want all bunyan-related and morgan dependencies removed after migration, so that the dependency tree is clean and there is no dead code.
+
+#### Acceptance Criteria
+1. When migration is complete, the monorepo shall have no references to `bunyan`, `universal-bunyan`, `bunyan-format`, `express-bunyan-logger`, `browser-bunyan`, `@browser-bunyan/console-formatted-stream`, or `@types/bunyan` in any `package.json`.
+2. When migration is complete, the monorepo shall have no references to `morgan` or `@types/morgan` in any `package.json`.
+3. When migration is complete, no source file shall contain imports or requires of the removed packages.
+
+### Requirement 11: Preserve Pino's Performance Characteristics
+
+**Objective:** As a developer, I want the logger implementation to honour pino's design philosophy of minimal overhead in the main thread, so that migrating from bunyan does not introduce performance regressions.
+
+#### Acceptance Criteria
+1. The Logger Factory shall create pino's worker-thread transport (`pino.transport()`) **at most once** per application lifetime (i.e., during `initializeLoggerFactory`), regardless of the number of unique namespaces.
+2. The Logger Factory shall create per-namespace loggers by calling `.child()` on a shared root pino instance, not by calling `pino()` and `pino.transport()` independently for each namespace.
+3. The Logger Factory shall not perform any blocking I/O or expensive computation on the hot path of each log method call (level-checking is performed by pino's internal mechanism and is acceptable).
+4. The number of active Worker threads used by the logger subsystem shall remain constant after the first call to `loggerFactory()`, regardless of how many distinct namespaces are subsequently requested.
+
+### Requirement 10: Backward-Compatible Log API
+
+**Objective:** As a developer, I want the new logger to expose the same method signatures as the current bunyan logger, so that existing log call sites require minimal or no changes.
+
+#### Acceptance Criteria
+1. The pino logger shall support `.info()`, `.debug()`, `.warn()`, `.error()`, `.trace()`, and `.fatal()` methods with the same argument patterns as bunyan (message string, optional object, optional error).
+2. If bunyan-specific APIs (e.g., `logger.child()`, serializers) are used at any call sites, the pino equivalent shall be provided or the call site shall be adapted.
+3. The Logger Factory shall export a TypeScript type for the logger instance that is compatible with the pino Logger type.
+
+### Requirement 12: Bunyan-Like Output Format (Development Only)
+
+**Objective:** As a developer, I want the log output in development mode to resemble bunyan-format's "short" mode, so that the visual experience remains familiar after migration.
+
+#### Acceptance Criteria
+1. While in development mode (`NODE_ENV !== 'production'`), the Logger Factory shall output each log line in the format: `HH:mm:ss.SSSZ LEVEL name: message` (e.g., `10:06:30.419Z DEBUG growi:service:page: some message`).
+2. The level label shall be right-aligned to 5 characters (e.g., `DEBUG`, ` INFO`, ` WARN`).
+3. The timestamp shall be UTC time-only in ISO 8601 format (`HH:mm:ss.SSSZ`), without date or surrounding brackets.
+4. The logger namespace (`name` field) shall appear directly after the level label, followed by a colon and the message, without parentheses.
+5. Log lines shall be colorized by level (cyan for DEBUG, green for INFO, yellow for WARN, red for ERROR).
+6. The bunyan-like format shall be implemented as a custom pino transport module within `@growi/logger`, so that `pino.transport()` can load it in a worker thread without function serialization issues.
+7. The bunyan-format transport module shall only be imported in development mode. In production, the module shall not be imported or bundled.
+8. While in production mode with `FORMAT_NODE_LOG` enabled, the Logger Factory shall use standard pino-pretty (not the bunyan-format transport) for formatted output.
+
+### Requirement 13: HTTP Logger Middleware Encapsulation
+
+**Objective:** As a developer, I want the HTTP request logging middleware encapsulated within `@growi/logger`, so that consumer applications do not need to depend on or import `pino-http` directly.
+
+#### Acceptance Criteria
+1. The `@growi/logger` package shall export a `createHttpLoggerMiddleware(options)` function that returns Express-compatible middleware for HTTP request logging.
+2. The middleware factory shall accept options for the logger namespace (defaulting to `'express'`) and optional `autoLogging` configuration (e.g., route ignore patterns).
+3. While in development mode, the middleware shall apply morgan-like formatting (custom success/error messages, custom log levels) via dynamic import. In production mode, the morgan-like format module shall not be imported; pino-http's default message format shall be used.
+4. After the encapsulation, `apps/app` and `apps/slackbot-proxy` shall not import `pino-http` directly; all HTTP logging shall go through `@growi/logger`.
+5. The `pino-http` dependency shall move from consumer applications to `@growi/logger`'s `dependencies`.
+6. The `morganLikeFormatOptions` module shall only be imported in development mode (dynamic import). In production, the module shall not be imported or bundled.
+7. The `pino-http` module shall be imported lazily inside the `createHttpLoggerMiddleware` function body (not at module top-level), so that bundlers (e.g., Turbopack, webpack) do not include the Node.js-only `pino-http` in browser bundles when `@growi/logger` is imported by shared/universal code.
+8. While in development mode with morgan-like formatting enabled, the HTTP log output shall suppress the verbose `req` and `res` serialized objects; the `customSuccessMessage` output (method, URL, status code, response time) is sufficient for development readability.
+9. While in development mode, the morgan-like format shall colorize the HTTP status code by range (2xx=green, 3xx=cyan, 4xx=yellow, 5xx=red) and dim the response time, respecting the `NO_COLOR` environment variable.

+ 224 - 0
.kiro/specs/migrate-logger-to-pino/research.md

@@ -0,0 +1,224 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Capture discovery findings, architectural investigations, and rationale that inform the technical design.
+---
+
+## Summary
+- **Feature**: `migrate-logger-to-pino`
+- **Discovery Scope**: Complex Integration
+- **Key Findings**:
+  - Pino and bunyan share identical argument patterns (`logger.info(obj, msg)`) — no call-site changes needed
+  - No `logger.child()` or custom serializers used in GROWI — simplifies migration significantly
+  - `@opentelemetry/instrumentation-pino` supports pino `<10`; need to verify v9.x or v10 compatibility
+  - No off-the-shelf pino package replicates universal-bunyan's namespace-based level control; custom wrapper required
+
+## Research Log
+
+### Pino Core API Compatibility with Bunyan
+- **Context**: Need to confirm argument pattern compatibility to minimize call-site changes
+- **Sources Consulted**: pino GitHub docs (api.md), npm pino@10.3.1
+- **Findings**:
+  - Log level numeric values are identical: trace=10, debug=20, info=30, warn=40, error=50, fatal=60
+  - Method signature: `logger[level]([mergingObject], [message], [...interpolationValues])` — same as bunyan
+  - `name` option adds a `"name"` field to JSON output, same as bunyan
+  - `msg` is the default message key (same as bunyan), configurable via `messageKey`
+  - `pino.child(bindings, options)` works similarly to bunyan's `child()`
+- **Implications**: Call sites using `logger.info('msg')`, `logger.info({obj}, 'msg')`, `logger.error(err)` require no changes
+
+### Pino Browser Support
+- **Context**: universal-bunyan uses browser-bunyan + ConsoleFormattedStream for client-side logging
+- **Sources Consulted**: pino GitHub docs (browser.md)
+- **Findings**:
+  - Pino has built-in browser mode activated via package.json `browser` field
+  - Maps to console methods: `console.error` (fatal/error), `console.warn`, `console.info`, `console.debug`, `console.trace`
+  - `browser.asObject: true` outputs structured objects
+  - `browser.write` allows custom per-level handlers
+  - Level control works the same as Node.js (`level` option)
+  - No separate package needed (unlike browser-bunyan)
+- **Implications**: Eliminates browser-bunyan and @browser-bunyan/console-formatted-stream dependencies entirely
+
+### Pino-Pretty as Bunyan-Format Replacement
+- **Context**: universal-bunyan uses bunyan-format with `short` (dev) and `long` (prod) output modes
+- **Sources Consulted**: pino-pretty npm (v13.1.3)
+- **Findings**:
+  - Can be used as transport (worker thread) or stream (main thread)
+  - Short mode equivalent: `singleLine: true` + `ignore: 'pid,hostname'`
+  - Long mode equivalent: default multi-line output
+  - `translateTime: 'SYS:standard'` for human-readable timestamps
+  - TTY-only pattern: conditionally enable based on `process.stdout.isTTY`
+- **Implications**: Direct replacement for bunyan-format with equivalent modes
+
+### Pino-HTTP as Morgan/Express-Bunyan-Logger Replacement
+- **Context**: GROWI uses morgan (dev) and express-bunyan-logger (prod) for HTTP request logging
+- **Sources Consulted**: pino-http npm (v11.0.0)
+- **Findings**:
+  - Express middleware with `autoLogging.ignore` for route skipping (replaces morgan's `skip`)
+  - Accepts custom pino logger instance via `logger` option
+  - `customLogLevel` for status-code-based level selection
+  - `req.log` provides child logger with request context
+  - Replaces both morgan and express-bunyan-logger in a single package
+- **Implications**: Unified HTTP logging for both dev and prod, with route filtering support
+
+### Namespace-Based Level Control
+- **Context**: universal-bunyan provides namespace-to-level mapping with minimatch glob patterns and env var overrides
+- **Sources Consulted**: pino-debug (v4.0.2), pino ecosystem search
+- **Findings**:
+  - pino-debug bridges the `debug` module but doesn't provide general namespace-level control
+  - No official pino package replicates universal-bunyan's behavior
+  - Custom implementation needed: wrapper that caches pino instances per namespace, reads config + env vars, applies minimatch matching
+  - Can use pino's `level` option per-instance (set at creation time)
+- **Implications**: Must build `@growi/logger` package as a custom wrapper around pino, replacing universal-bunyan
+
+### OpenTelemetry Instrumentation
+- **Context**: GROWI has a custom DiagLogger adapter wrapping bunyan, and disables @opentelemetry/instrumentation-bunyan
+- **Sources Consulted**: @opentelemetry/instrumentation-pino npm (v0.59.0)
+- **Findings**:
+  - Supports pino `>=5.14.0 <10` — pino v10 may not be supported yet
+  - Provides trace correlation (trace_id, span_id injection) and log sending to OTel SDK
+  - GROWI's DiagLoggerBunyanAdapter pattern maps cleanly to pino (same method names)
+  - Current code disables bunyan instrumentation; equivalent disable for pino instrumentation may be needed
+- **Implications**: Pin pino to v9.x for OTel compatibility, or verify v10 support. DiagLogger adapter changes are minimal.
+
+### Existing Call-Site Analysis
+- **Context**: Need to understand what API surface is actually used to minimize migration risk
+- **Sources Consulted**: Codebase grep across all apps and packages
+- **Findings**:
+  - **No `logger.child()` usage** anywhere in the codebase
+  - **No custom serializers** registered or used
+  - **No `logger.fields` access** or other bunyan-specific APIs
+  - Call patterns: ~30% simple string, ~50% string+object, ~10% error-only, ~10% string+error
+  - All loggers created via `loggerFactory(name)` — single entry point
+- **Implications**: Migration is primarily a factory-level change; call sites need no modification
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Drop-in wrapper (`@growi/logger`) | Shared package providing `loggerFactory()` over pino with namespace/config/env support | Minimal call-site changes, single source of truth, testable in isolation | Must implement namespace matching (minimatch) | Mirrors universal-bunyan's role |
+| Direct pino usage per app | Each app creates pino instances directly | No wrapper overhead | Duplicated config logic, inconsistent behavior across apps | Rejected: violates Req 8 |
+| pino-debug bridge | Use pino-debug for namespace control | Leverages existing package | Only works with `debug()` calls, not general logging | Rejected: wrong abstraction |
+
+## Design Decisions
+
+### Decision: Create `@growi/logger` as Shared Package
+- **Context**: universal-bunyan is a custom wrapper; need equivalent for pino
+- **Alternatives Considered**:
+  1. Direct pino usage in each app — too much duplication
+  2. Fork/patch universal-bunyan for pino — complex, hard to maintain
+  3. New shared package `@growi/logger` — clean, purpose-built
+- **Selected Approach**: New `@growi/logger` package in `packages/logger/`
+- **Rationale**: Single source of truth, testable, follows monorepo patterns (like @growi/core)
+- **Trade-offs**: One more package to maintain, but replaces external dependency
+- **Follow-up**: Define package exports, ensure tree-shaking for browser builds
+
+### Decision: Pin Pino to v9.x for OpenTelemetry Compatibility
+- **Context**: @opentelemetry/instrumentation-pino supports `<10`
+- **Alternatives Considered**:
+  1. Use pino v10 and skip OTel auto-instrumentation — loses correlation
+  2. Use pino v9 for compatibility — safe choice
+  3. Use pino v10 and verify latest instrumentation support — risky
+- **Selected Approach**: Start with pino v9.x; upgrade to v10 when OTel adds support
+- **Rationale**: OTel trace correlation is valuable for production observability
+- **Trade-offs**: Miss latest pino features temporarily
+- **Follow-up**: Monitor @opentelemetry/instrumentation-pino releases for v10 support
+
+### Decision: Use pino-pretty as Transport in Development
+- **Context**: Need human-readable output for dev, JSON for prod
+- **Alternatives Considered**:
+  1. pino-pretty as transport (worker thread) — standard approach
+  2. pino-pretty as sync stream — simpler but blocks main thread
+- **Selected Approach**: Transport for async dev logging; raw JSON in production
+- **Rationale**: Transport keeps main thread clear; dev perf is less critical but the pattern is correct
+- **Trade-offs**: Slightly more complex setup
+- **Follow-up**: Verify transport works correctly with Next.js dev server
+
+### Decision: Unified HTTP Logging with pino-http
+- **Context**: Currently uses morgan (dev) and express-bunyan-logger (prod) — two different middlewares
+- **Alternatives Considered**:
+  1. Separate dev/prod middleware (maintain split) — unnecessary complexity
+  2. Single pino-http middleware for both — clean, consistent
+- **Selected Approach**: pino-http with route filtering replaces both
+- **Rationale**: Single middleware, consistent output format, built-in request context
+- **Trade-offs**: Dev output slightly different from morgan's compact format (mitigated by pino-pretty)
+- **Follow-up**: Configure `autoLogging.ignore` for `/_next/static/` paths
+
+## Risks & Mitigations
+- **OTel instrumentation compatibility with pino version** — Mitigated by pinning to v9.x
+- **Browser bundle size increase** — Pino browser mode is lightweight; monitor with build metrics
+- **Subtle log format differences** — Acceptance test comparing output before/after
+- **Missing env var behavior** — Port minimatch logic carefully with unit tests
+- **Express middleware ordering** — Ensure pino-http is added at the same point in middleware chain
+
+### Phase 2: Formatting Improvement Research
+
+#### pino-http Custom Message API (v11.0.0)
+- **Context**: Need morgan-like concise HTTP log messages instead of pino-http's verbose default
+- **Sources Consulted**: pino-http v11.0.0 type definitions (index.d.ts), source code (logger.js)
+- **Findings**:
+  - `customSuccessMessage: (req: IM, res: SR, responseTime: number) => string` — called on successful response (statusCode < 500)
+  - `customErrorMessage: (req: IM, res: SR, error: Error) => string` — called on error response
+  - `customReceivedMessage: (req: IM, res: SR) => string` — called when request received (optional, only if autoLogging enabled)
+  - `customLogLevel: (req: IM, res: SR, error?: Error) => LevelWithSilent` — dynamic log level based on status code
+  - `customSuccessObject: (req, res, val) => any` — custom fields for successful response log
+  - `customErrorObject: (req, res, error, val) => any` — custom fields for error response log
+  - `customAttributeKeys: { req?, res?, err?, reqId?, responseTime? }` — rename default keys
+  - Response time is calculated as `Date.now() - res[startTime]` in milliseconds
+  - Error conditions: error passed to handler, `res.err` set, or `res.statusCode >= 500`
+- **Implications**: `customSuccessMessage` + `customErrorMessage` + `customLogLevel` are sufficient to achieve morgan-like output format
+
+#### pino-pretty singleLine Option
+- **Context**: User wants one-liner readable logs when FORMAT_NODE_LOG=true
+- **Sources Consulted**: pino-pretty v13.x documentation
+- **Findings**:
+  - `singleLine: true` forces all log properties onto a single line
+  - `singleLine: false` (default) outputs properties on separate indented lines
+  - Combined with `ignore: 'pid,hostname'`, singleLine produces concise output
+  - The `messageFormat` option can further customize the format string
+- **Implications**: Changing `singleLine` from `false` to `true` in the production FORMAT_NODE_LOG path directly addresses the user's readability concern
+
+#### FORMAT_NODE_LOG Default Semantics Analysis
+- **Context**: `isFormattedOutputEnabled()` returns `true` when env var is unset; production JSON depends on `.env.production`
+- **Analysis**:
+  - `.env.production` sets `FORMAT_NODE_LOG=false` — this is the mechanism that ensures JSON in production
+  - CI sets `FORMAT_NODE_LOG=true` explicitly — not affected by default change
+  - If `.env.production` fails to load in a Docker override scenario, production would silently get pino-pretty
+  - However, inverting the default is a behavioral change with broader implications
+- **Decision**: Defer to separate PR. Current behavior is correct in practice (`.env.production` always loaded by Next.js dotenv-flow).
+
+## Phase 3: Implementation Discoveries
+
+### Browser Bundle Compatibility — pino-http Top-Level Import
+- **Context**: `pino-http` was initially imported at the module top-level in `http-logger.ts`. This caused Turbopack to include the Node.js-only module in browser bundles, producing `TypeError: __turbopack_context__.r(...).symbols is undefined`.
+- **Root cause**: `@growi/logger` is imported by shared page code that runs in both browser and server contexts. Any top-level import of a Node.js-only module (like pino-http) gets pulled into the browser bundle.
+- **Fix**: Move the `pino-http` import inside the async function body using dynamic import: `const { default: pinoHttp } = await import('pino-http')`. This defers the import to runtime when the function is actually called (server-side only).
+- **Pattern**: This is the standard pattern for Node.js-only modules in packages shared with browser code. Apply the same treatment to any future Node.js-only additions to `@growi/logger`.
+
+### Dev-Only Module Physical Isolation (`src/dev/`)
+- **Context**: `bunyan-format.ts` (custom pino transport) and `morgan-like-format-options.ts` were initially placed at `src/transports/` and `src/` root respectively, mixed with production modules.
+- **Problem**: No clear boundary between dev-only and production-safe modules; risk of accidentally importing dev modules in production paths.
+- **Fix**: Created `src/dev/` directory as the explicit boundary for development-only modules. `TransportFactory` references `./dev/bunyan-format.js` only in the dev branch — the path is never constructed in production code paths.
+- **Vite config**: `preserveModules: true` ensures `src/dev/bunyan-format.ts` builds to `dist/dev/bunyan-format.js` with the exact path that `pino.transport({ target: ... })` references at runtime.
+
+### Single Worker Thread Model — Critical Implementation Detail
+- **Context**: Initial implementation called `pino.transport()` inside `loggerFactory(name)`, spawning a new Worker thread for each namespace.
+- **Fix**: Refactored so `pino.transport()` is called **once** in `initializeLoggerFactory`, and `loggerFactory(name)` calls `rootLogger.child({ name })` to create namespace-bound loggers sharing the single Worker thread.
+- **Root logger level**: Must be set to `'trace'` (not `'info'`) so child loggers can independently set their resolved level without being silenced by the root. If the root is `'info'`, a child with `level: 'debug'` will still be filtered at the root level.
+- **Constraint for future changes**: Never call `pino.transport()` or `pino()` inside `loggerFactory()`. All transport setup belongs in `initializeLoggerFactory()`.
+
+### pino Logger Type Compatibility with pino-http
+- **Context**: `loggerFactory()` returned `pino.Logger<never>` (the default), which is not assignable to pino-http's expected `Logger` type.
+- **Fix**: Export `Logger<string>` from `@growi/logger` and type `loggerFactory` to return `Logger<string>`. This is compatible with pino-http's `logger` option.
+- **Why `<string>` not `<never>`**: pino's default generic `CustomLevels` is `never`, which makes the type incompatible with APIs expecting custom levels to potentially be strings. `Logger<string>` is the correct type for external APIs.
+
+### `@growi/logger` Package Visibility
+- **Decision**: `"private": true` is correct and intentional.
+- **Rationale**: All consumers (`apps/app`, `apps/slackbot-proxy`, `packages/slack`, etc.) are monorepo-internal packages that reference `@growi/logger` via `workspace:*` protocol. The `private` flag only prevents npm publish, not workspace usage. `@growi/logger` is logging infrastructure — there is no reason to expose it externally (unlike `@growi/core` or `@growi/pluginkit` which are published for external plugin developers).
+
+## References
+- [pino API docs](https://github.com/pinojs/pino/blob/main/docs/api.md)
+- [pino browser docs](https://github.com/pinojs/pino/blob/main/docs/browser.md)
+- [pino-pretty npm](https://www.npmjs.com/package/pino-pretty)
+- [pino-http npm](https://www.npmjs.com/package/pino-http)
+- [@opentelemetry/instrumentation-pino](https://www.npmjs.com/package/@opentelemetry/instrumentation-pino)
+- [universal-bunyan source](https://github.com/weseek/universal-bunyan) — current implementation reference

+ 23 - 0
.kiro/specs/migrate-logger-to-pino/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "migrate-logger-to-pino",
+  "created_at": "2026-03-23T00:00:00.000Z",
+  "updated_at": "2026-04-06T00:00:00.000Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "cleanup_completed": true,
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": false
+    }
+  },
+  "ready_for_implementation": true
+}

+ 263 - 0
.kiro/specs/migrate-logger-to-pino/tasks.md

@@ -0,0 +1,263 @@
+# Implementation Plan
+
+- [x] 1. Scaffold the @growi/logger shared package
+- [x] 1.1 Initialize the package directory, package.json, and TypeScript configuration within the monorepo packages directory
+  - Create the workspace entry as `@growi/logger` with pino v9.x and minimatch as dependencies, pino-pretty as an optional peer dependency
+  - Configure TypeScript with strict mode, ESM output, and appropriate path aliases
+  - Set up the package entry points (main, types, browser) so that bundlers resolve the correct build for Node.js vs browser
+  - Add vitest configuration for unit testing within the package
+  - _Requirements: 8.5_
+
+- [x] 1.2 Define the shared type contracts and configuration interface
+  - Define the `LoggerConfig` type representing a namespace-pattern-to-level mapping (including a `default` key)
+  - Define the `LoggerFactoryOptions` type accepted by the initialization function
+  - Export the pino `Logger` type so consumers can type-annotate their logger variables without importing pino directly
+  - _Requirements: 10.3_
+
+- [x] 2. Implement environment variable parsing and level resolution
+- [x] 2.1 (P) Build the environment variable parser
+  - Read the six log-level environment variables (`DEBUG`, `TRACE`, `INFO`, `WARN`, `ERROR`, `FATAL`) from the process environment
+  - Split each variable's value by commas and trim whitespace to extract individual namespace patterns
+  - Return a flat config map where each namespace pattern maps to its corresponding level string
+  - Handle edge cases: empty values, missing variables, duplicate patterns (last wins)
+  - Write unit tests covering: single variable with multiple patterns, all six variables set, no variables set, whitespace handling
+  - _Requirements: 3.1, 3.4, 3.5_
+
+- [x] 2.2 (P) Build the level resolver with glob pattern matching
+  - Accept a namespace string, a config map, and an env-override map; return the resolved level
+  - Check env-override map first (using minimatch for glob matching), then config map, then fall back to the config `default` entry
+  - When multiple patterns match, prefer the most specific (longest non-wildcard prefix) match
+  - Write unit tests covering: exact match, glob wildcard match, env override precedence over config, fallback to default, no matching pattern
+  - _Requirements: 2.1, 2.3, 2.4, 3.2, 3.3_
+
+- [x] 3. Implement the transport factory for dev, prod, and browser environments
+- [x] 3.1 (P) Build the Node.js transport configuration
+  - In development mode, produce pino-pretty transport options with human-readable timestamps, hidden pid/hostname fields, and multi-line output
+  - In production mode, produce raw JSON output to stdout by default
+  - When the `FORMAT_NODE_LOG` environment variable is unset or truthy in production, produce pino-pretty transport options with long-format output instead of raw JSON
+  - Include the logger namespace (`name` field) in all output configurations
+  - Write unit tests verifying correct options for each combination of NODE_ENV and FORMAT_NODE_LOG
+  - _Requirements: 5.1, 5.2, 5.3, 5.4_
+
+- [x] 3.2 (P) Build the browser transport configuration
+  - Detect the browser environment using window/document checks
+  - In browser development mode, produce pino browser options that output to the developer console with the resolved namespace level
+  - In browser production mode, produce pino browser options that default to `error` level to suppress non-critical console output
+  - Write unit tests verifying browser options for dev and prod scenarios
+  - _Requirements: 4.1, 4.2, 4.3, 4.4_
+
+- [x] 4. Implement the logger factory with caching and platform detection
+- [x] 4.1 Build the initialization and factory functions
+  - Implement `initializeLoggerFactory(options)` that stores the merged configuration, pre-parses environment overrides, and prepares the transport config
+  - Implement `loggerFactory(name)` that checks the cache for an existing logger, resolves the level via the level resolver, creates a pino instance with appropriate transport options, caches it, and returns it
+  - Detect the runtime platform (Node.js vs browser) and apply the corresponding transport configuration from the transport factory
+  - Ensure the module exports `loggerFactory` as the default export and `initializeLoggerFactory` as a named export for backward compatibility with existing import patterns
+  - Write unit tests covering: cache hit returns same instance, different namespaces return different instances, initialization stores config correctly
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 4.1, 10.1_
+
+- [x] 5. Migrate shared packages to @growi/logger (small scope first)
+- [x] 5.1 (P) Update packages/slack logger to use @growi/logger
+  - Replace the logger factory implementation to import from `@growi/logger` instead of universal-bunyan
+  - Update the inline config (`{ default: 'info' }`) to use the @growi/logger initialization pattern
+  - Replace bunyan type imports with the @growi/logger Logger type
+  - Add `@growi/logger` to packages/slack dependencies
+  - Run TypeScript compilation to verify no type errors
+  - _Requirements: 8.3_
+
+- [x] 5.2 (P) Update packages/remark-attachment-refs logger to use @growi/logger
+  - Replace the logger factory implementation to import from `@growi/logger`
+  - Update configuration and type imports to match the new package
+  - Add `@growi/logger` to packages/remark-attachment-refs dependencies
+  - Run TypeScript compilation to verify no type errors
+  - _Requirements: 8.4_
+
+- [x] 5.3 Fix pino-style logger call sites in packages/slack
+  - In the following files, convert all `logger.method('message', obj)` calls to the pino-canonical form `logger.method({ obj }, 'message')` (object first, message second)
+  - `src/middlewares/verify-growi-to-slack-request.ts` (lines 25, 34)
+  - `src/middlewares/verify-slack-request.ts` (lines 25, 36, 45, 76)
+  - `src/utils/interaction-payload-accessor.ts` (line 104)
+  - Run `pnpm --filter @growi/slack lint:typecheck` and confirm zero TS2769 errors
+  - _Requirements: 10.1_
+
+- [x] 5.4 Fix pino-style logger call site in packages/remark-attachment-refs
+  - In `src/client/services/renderer/refs.ts` (line 107), convert `logger.debug('message', attributes)` to `logger.debug({ attributes }, 'message')`
+  - Run `pnpm --filter @growi/remark-attachment-refs lint:typecheck` and confirm the TS2769 error is gone
+  - _Requirements: 10.1_
+
+- [x] 5.5 Migrate packages/remark-lsx server routes to use @growi/logger
+  - Add `@growi/logger` to packages/remark-lsx dependencies
+  - Create `src/utils/logger/index.ts` following the same pattern as remark-attachment-refs (import from `@growi/logger`, call `initializeLoggerFactory`, re-export `loggerFactory`)
+  - Replace `console.error` calls in `src/server/routes/list-pages/index.ts` (lines 89, 145-148) with proper logger calls using `loggerFactory('growi:remark-lsx:routes:list-pages')`
+  - Remove the `biome-ignore lint/suspicious/noConsole` comments from the replaced call sites
+  - Run `pnpm --filter @growi/remark-lsx lint:typecheck` to confirm no type errors
+  - _Requirements: 8.5_
+
+- [x] 6. Migrate apps/slackbot-proxy to @growi/logger
+- [x] 6.1 Replace the logger factory and HTTP middleware in slackbot-proxy
+  - Update the slackbot-proxy logger utility to import from `@growi/logger` and call `initializeLoggerFactory` with its existing dev/prod config
+  - Replace express-bunyan-logger and morgan usage in the server setup with pino-http middleware
+  - Replace all `import type Logger from 'bunyan'` references with the @growi/logger Logger type
+  - Add `@growi/logger` and `pino-http` to slackbot-proxy dependencies
+  - Run TypeScript compilation to verify no type errors
+  - _Requirements: 8.2, 6.1_
+
+- [x] 6.6 Fix pino-style logger call sites in apps/slackbot-proxy
+  - In the following files, convert all `logger.method('message', obj)` calls to `logger.method({ obj }, 'message')`
+  - `src/controllers/growi-to-slack.ts` (lines 109, 179, 231, 243, 359)
+  - `src/controllers/slack.ts` (lines 388, 586)
+  - `src/services/RegisterService.ts` (line 165)
+  - Run `pnpm --filter @growi/slackbot-proxy lint:typecheck` and confirm zero TS2769 errors
+  - _Requirements: 10.1_
+
+- [x] 6.7 Fix @growi/logger Logger type export and remove `as any` cast in slackbot-proxy
+  - In `packages/logger`, update the `loggerFactory` return type so it is compatible with `pino-http`'s `logger` option (i.e., `pino.Logger` without `<never>` narrowing, or by exporting `Logger<string>`)
+  - After the type export is fixed, remove the `as any` cast from `apps/slackbot-proxy/src/Server.ts` (line 166) and the associated `biome-ignore` comment
+  - Run `pnpm --filter @growi/slackbot-proxy lint:typecheck` to confirm no residual type errors
+  - _Requirements: 10.3_
+
+- [x] 6.5 Fix logger factory to preserve pino's single-worker-thread performance model
+  - Refactor `initializeLoggerFactory` to create the pino transport (`pino.transport()`) and root pino logger **once**, storing them in module scope
+  - Set the root logger's level to `'trace'` so that individual child loggers can apply their own resolved level without being silenced by the root
+  - Refactor `loggerFactory(name)` to call `rootLogger.child({ name })` and then set `childLogger.level = resolvedLevel` instead of calling `pino()` + `pino.transport()` per namespace
+  - Handle browser mode separately: the root browser logger is created once in `initializeLoggerFactory`; `loggerFactory` still calls `.child({ name })` and applies the resolved level
+  - Update unit tests in `logger-factory.spec.ts` to verify that calling `loggerFactory` for N distinct namespaces does not create N independent pino instances (all children share the root transport)
+  - _Requirements: 11.1, 11.2, 11.3, 11.4_
+
+- [x] 7. Migrate apps/app to @growi/logger (largest scope)
+- [x] 7.1 Replace the logger factory module in apps/app
+  - Update the apps/app logger utility to import from `@growi/logger` instead of `universal-bunyan`
+  - Call `initializeLoggerFactory` at application startup with the existing dev/prod config files (preserve current config content)
+  - Re-export `loggerFactory` as the default export so all existing consumer imports continue to work unchanged
+  - Add `@growi/logger` to apps/app dependencies and ensure pino-pretty is available for development formatting
+  - _Requirements: 8.1, 2.2_
+
+- [x] 7.2 Replace HTTP request logging middleware in apps/app
+  - Remove the morgan middleware (development mode) and express-bunyan-logger middleware (production mode) from the Express initialization
+  - Add pino-http middleware configured with a logger from the factory using the `express` namespace
+  - Configure route skipping to exclude `/_next/static/` paths in non-production mode
+  - Verify the middleware produces log entries containing method, URL, status code, and response time
+  - _Requirements: 6.1, 6.2, 6.3, 6.4_
+
+- [x] 7.3 Update the OpenTelemetry diagnostic logger adapter
+  - Rename the adapter class from `DiagLoggerBunyanAdapter` to `DiagLoggerPinoAdapter` and update the import to use pino types
+  - Preserve the existing `parseMessage` helper logic that parses JSON strings and merges argument objects
+  - Confirm the verbose-to-trace level mapping continues to work with pino's trace level
+  - Update the OpenTelemetry SDK configuration to disable `@opentelemetry/instrumentation-pino` instead of `@opentelemetry/instrumentation-bunyan`
+  - _Requirements: 7.1, 7.2, 7.3_
+
+- [x] 7.4 Update all bunyan type references in apps/app source files
+  - Replace `import type Logger from 'bunyan'` with the Logger type exported from `@growi/logger` across all source files in apps/app
+  - Verify that pino's Logger type is compatible with all existing usage patterns (info, debug, warn, error, trace, fatal method calls)
+  - Run the TypeScript compiler to confirm no type errors
+  - _Requirements: 10.1, 10.2, 10.3_
+
+- [x] 8. Remove old logging dependencies and verify cleanup
+- [x] 8.1 Remove bunyan-related packages from all package.json files
+  - Remove `bunyan`, `universal-bunyan`, `bunyan-format`, `express-bunyan-logger`, `browser-bunyan`, `@browser-bunyan/console-formatted-stream`, `@types/bunyan` from every package.json in the monorepo
+  - Remove `morgan` and `@types/morgan` from every package.json in the monorepo
+  - Run `pnpm install` to update the lockfile and verify no broken peer dependency warnings
+  - _Requirements: 9.1, 9.2_
+
+- [x] 8.2 Verify no residual references to removed packages
+  - Search all source files for any remaining imports or requires of the removed packages (bunyan, universal-bunyan, browser-bunyan, express-bunyan-logger, morgan, bunyan-format)
+  - Search all configuration and type definition files for stale bunyan references
+  - Fix any remaining references found during the search
+  - _Requirements: 9.3_
+
+- [x] 9. Run full monorepo validation
+- [x] 9.1 Execute lint, type-check, test, and build across the monorepo
+  - Run `turbo run lint --filter @growi/app` and fix any lint errors related to the migration
+  - Run `turbo run test --filter @growi/app` and verify all existing tests pass
+  - Run `turbo run build --filter @growi/app` and confirm the production build succeeds
+  - Run the same checks for slackbot-proxy and any other affected packages
+  - Verify the @growi/logger package's own tests pass
+  - _Requirements: 1.4, 8.1, 8.2, 8.3, 8.4, 10.1, 10.2_
+
+- [x] 10. Improve log output formatting for readability
+- [x] 10.1 (P) Differentiate pino-pretty singleLine between dev and production FORMAT_NODE_LOG
+  - In the transport factory, change the production + FORMAT_NODE_LOG path to use `singleLine: true` for concise one-liner output
+  - Keep the development path at `singleLine: false` so developers see full multi-line context
+  - Update unit tests to verify: dev returns `singleLine: false`, production + FORMAT_NODE_LOG returns `singleLine: true`, production without FORMAT_NODE_LOG still returns no transport
+  - _Requirements: 5.1, 5.3_
+
+- [x] 10.2 (P) Add morgan-like HTTP request message formatting to pino-http in apps/app
+  - Configure `customSuccessMessage` to produce `METHOD /url STATUS - TIMEms` format (e.g., `GET /page/path 200 - 12ms`)
+  - Configure `customErrorMessage` to include the error message alongside method, URL, and status code
+  - Configure `customLogLevel` to return `warn` for 4xx responses and `error` for 5xx or error responses, keeping `info` for successful requests
+  - Verify that `/_next/static/` path skipping in dev mode still works after the changes
+  - _Requirements: 6.1, 6.4_
+
+- [x] 10.3 (P) Add morgan-like HTTP request message formatting to pino-http in apps/slackbot-proxy
+  - Apply the same `customSuccessMessage`, `customErrorMessage`, and `customLogLevel` configuration as apps/app
+  - _Requirements: 6.1, 6.4_
+
+- [x] 11. Validate formatting improvements
+- [x] 11.1 Run tests and build for affected packages
+  - Run the @growi/logger package tests to confirm transport factory changes pass
+  - Run lint and type-check for apps/app and apps/slackbot-proxy
+  - Verify the production build succeeds
+  - _Requirements: 5.1, 5.3, 6.1, 6.4_
+
+- [x] 12. Implement bunyan-like output format (development only)
+- [x] 12.1 Create the bunyan-format custom transport module
+  - Create `packages/logger/src/transports/bunyan-format.ts` that default-exports a function returning a pino-pretty stream
+  - Use `customPrettifiers.time` to format epoch as `HH:mm:ss.SSSZ` (UTC time-only, no brackets)
+  - Use `customPrettifiers.level` to return `${label.padStart(5)} ${log.name}` (right-aligned 5-char level + namespace)
+  - Set `ignore: 'pid,hostname,name'` so name appears via the level prettifier, not in pino-pretty's default parens
+  - Accept `singleLine` option to pass through to pino-pretty
+  - Verify the module is built to `dist/transports/bunyan-format.js` by vite's `preserveModules` config
+  - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_
+
+- [x] 12.2 Update TransportFactory to use bunyan-format transport in dev only
+  - In the **development** branch of `createNodeTransportOptions`, change the transport target from `'pino-pretty'` to the resolved path of `bunyan-format.js` (via `import.meta.url`)
+  - Remove `translateTime` and `ignore` options from the dev transport config (now handled inside the custom transport)
+  - Pass `singleLine: false` for dev
+  - In the **production + FORMAT_NODE_LOG** branch, keep `target: 'pino-pretty'` with standard options (`translateTime: 'SYS:standard'`, `ignore: 'pid,hostname'`, `singleLine: true`) — do NOT use bunyan-format
+  - The bunyan-format module path is only resolved in the dev code path, ensuring it is never imported in production
+  - Update unit tests in `transport-factory.spec.ts`: dev target contains `bunyan-format`; prod + FORMAT_NODE_LOG target is `'pino-pretty'`
+  - _Requirements: 12.1, 12.6, 12.7, 12.8_
+
+- [x] 12.3 Verify bunyan-format output
+  - Run the dev server and confirm log output matches the bunyan-format "short" style: `HH:mm:ss.SSSZ LEVEL name: message`
+  - Confirm colorization works (DEBUG=cyan, INFO=green, WARN=yellow, ERROR=red)
+  - Confirm multi-line output in dev (extra fields on subsequent lines)
+  - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5_
+
+- [x] 13. Encapsulate pino-http in @growi/logger
+- [x] 13.1 Create HTTP logger middleware factory in @growi/logger
+  - Create `packages/logger/src/http-logger.ts` exporting `async createHttpLoggerMiddleware(options?)`
+  - The function creates `pinoHttp` middleware internally with `loggerFactory(namespace)`
+  - In development mode (`NODE_ENV !== 'production'`): dynamically import `morganLikeFormatOptions` via `await import('./morgan-like-format-options')` and apply to pino-http options
+  - In production mode: use pino-http with default message formatting (no morgan-like module imported)
+  - Accept optional `namespace` (default: `'express'`) and `autoLogging` options
+  - Handle the `Logger<string>` → pino-http's expected Logger type assertion internally
+  - Add `pino-http` to `@growi/logger` package.json dependencies
+  - Export `createHttpLoggerMiddleware` from `packages/logger/src/index.ts`
+  - _Requirements: 13.1, 13.2, 13.3, 13.5, 13.6_
+
+- [x] 13.2 (P) Migrate apps/app to use createHttpLoggerMiddleware
+  - Replace the direct `pinoHttp` import and configuration in `apps/app/src/server/crowi/index.ts` with `await createHttpLoggerMiddleware(...)` from `@growi/logger`
+  - Pass the `/_next/static/` autoLogging ignore function via the options
+  - Remove `pino-http` and its type imports from the file
+  - Remove `morganLikeFormatOptions` import (now applied internally in dev only)
+  - Remove `pino-http` from `apps/app/package.json` if no longer directly used
+  - Run `pnpm --filter @growi/app lint:typecheck` to confirm no type errors
+  - _Requirements: 13.4_
+
+- [x] 13.3 (P) Migrate apps/slackbot-proxy to use createHttpLoggerMiddleware
+  - Replace the direct `pinoHttp` import and configuration in `apps/slackbot-proxy/src/Server.ts` with `await createHttpLoggerMiddleware(...)` from `@growi/logger`
+  - Remove `pino-http` and its type imports from the file
+  - Remove `morganLikeFormatOptions` import (now applied internally in dev only)
+  - Remove the `as unknown as` type assertion (now handled internally)
+  - Remove `pino-http` from `apps/slackbot-proxy/package.json` if no longer directly used
+  - Run `pnpm --filter @growi/slackbot-proxy lint:typecheck` to confirm no type errors
+  - _Requirements: 13.4_
+
+- [x] 14. Validate bunyan-format and HTTP encapsulation
+- [x] 14.1 Run full validation
+  - Run `@growi/logger` package tests
+  - Run lint and type-check for apps/app and apps/slackbot-proxy
+  - Run `turbo run build --filter @growi/app` to verify production build succeeds
+  - Verify no remaining direct `pino-http` imports in apps/app or apps/slackbot-proxy source files
+  - Verify that bunyan-format transport and morganLikeFormatOptions are NOT imported in production (grep for dynamic import pattern)
+  - _Requirements: 12.1, 12.6, 12.7, 13.4, 13.5, 13.6_

+ 764 - 0
.kiro/specs/oauth2-email-support/design.md

@@ -0,0 +1,764 @@
+# OAuth 2.0 Email Support - Technical Design
+
+## Overview
+
+This feature adds OAuth 2.0 authentication support for sending emails through Google Workspace accounts in GROWI. Administrators can configure email transmission using OAuth 2.0 credentials (Client ID, Client Secret, Refresh Token) instead of traditional SMTP passwords. This integration extends the existing mail service architecture while maintaining full backward compatibility with SMTP and SES configurations.
+
+**Purpose**: Enable secure, token-based email authentication for Google Workspace accounts, improving security by eliminating password-based SMTP authentication and following Google's recommended practices for application email integration.
+
+**Users**: GROWI administrators configuring email transmission settings will use the new OAuth 2.0 option alongside existing SMTP and SES methods.
+
+**Impact**: Extends the mail service to support a third transmission method (oauth2) without modifying existing SMTP or SES functionality. No breaking changes to existing deployments.
+
+### Goals
+
+- Add OAuth 2.0 as a transmission method option in mail settings
+- Support Google Workspace email sending via Gmail API with OAuth 2.0 credentials
+- Maintain backward compatibility with existing SMTP and SES configurations
+- Provide consistent admin UI experience following SMTP/SES patterns
+- Implement automatic OAuth 2.0 token refresh using nodemailer's built-in support
+- Ensure secure storage and handling of OAuth 2.0 credentials
+
+### Non-Goals
+
+- OAuth 2.0 providers beyond Google Workspace (Microsoft 365, generic OAuth 2.0 servers)
+- Migration tool from SMTP to OAuth 2.0 (administrators manually reconfigure)
+- Authorization flow UI for obtaining refresh tokens (documented external process via Google Cloud Console)
+- Multi-account or account rotation support (single OAuth 2.0 account per instance)
+- Email queuing or rate limiting specific to OAuth 2.0 (relies on existing mail service behavior)
+
+## Architecture
+
+### Existing Architecture Analysis
+
+**Current Mail Service Implementation**:
+- **Service Location**: `apps/app/src/server/service/mail.ts` (MailService class)
+- **Initialization**: MailService instantiated from Crowi container, loaded on app startup
+- **Transmission Methods**: Currently supports 'smtp' and 'ses' via `mail:transmissionMethod` config
+- **Factory Pattern**: `createSMTPClient()` and `createSESClient()` create nodemailer transports
+- **Configuration**: ConfigManager loads settings from MongoDB via `mail:*` namespace keys
+- **S2S Messaging**: Supports distributed config updates via `mailServiceUpdated` events
+- **Test Email**: SMTP-only test email functionality in admin UI
+
+**Current Admin UI Structure**:
+- **Main Component**: `MailSetting.tsx` - form container with transmission method radio buttons
+- **Sub-Components**: `SmtpSetting.tsx`, `SesSetting.tsx` - conditional rendering based on selected method
+- **State Management**: AdminAppContainer (unstated) manages form state and API calls
+- **Form Library**: react-hook-form for validation and submission
+- **API Integration**: `updateMailSettingHandler()` saves all mail settings via REST API
+
+**Integration Points**:
+- Config definition in `config-definition.ts` (add OAuth 2.0 keys)
+- MailService initialize() method (add OAuth 2.0 branch)
+- MailSetting.tsx transmission method array (add 'oauth2' option)
+- AdminAppContainer state methods (add OAuth 2.0 credential methods)
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph "Client Layer"
+        MailSettingUI[MailSetting Component]
+        OAuth2SettingUI[OAuth2Setting Component]
+        SmtpSettingUI[SmtpSetting Component]
+        SesSettingUI[SesSetting Component]
+        AdminContainer[AdminAppContainer]
+    end
+
+    subgraph "API Layer"
+        AppSettingsAPI[App Settings API]
+        MailTestAPI[Mail Test API]
+    end
+
+    subgraph "Service Layer"
+        MailService[MailService]
+        ConfigManager[ConfigManager]
+        S2SMessaging[S2S Messaging]
+    end
+
+    subgraph "External Services"
+        GoogleOAuth[Google OAuth 2.0 API]
+        GmailAPI[Gmail API]
+        SMTPServer[SMTP Server]
+        SESAPI[AWS SES API]
+    end
+
+    subgraph "Data Layer"
+        MongoDB[(MongoDB Config)]
+    end
+
+    MailSettingUI --> AdminContainer
+    OAuth2SettingUI --> AdminContainer
+    SmtpSettingUI --> AdminContainer
+    SesSettingUI --> AdminContainer
+
+    AdminContainer --> AppSettingsAPI
+    AdminContainer --> MailTestAPI
+
+    AppSettingsAPI --> ConfigManager
+    MailTestAPI --> MailService
+
+    MailService --> ConfigManager
+    MailService --> S2SMessaging
+
+    ConfigManager --> MongoDB
+
+    MailService --> GoogleOAuth
+    MailService --> GmailAPI
+    MailService --> SMTPServer
+    MailService --> SESAPI
+
+    S2SMessaging -.->|mailServiceUpdated| MailService
+```
+
+**Architecture Integration**:
+- **Selected Pattern**: Factory Method Extension - adds `createOAuth2Client()` to existing MailService factory methods
+- **Domain Boundaries**:
+  - **Client**: Admin UI components for OAuth 2.0 configuration (follows existing SmtpSetting/SesSetting pattern)
+  - **Service**: MailService handles all transmission methods; OAuth 2.0 isolated in new factory method
+  - **Config**: ConfigManager persists OAuth 2.0 credentials using `mail:oauth2*` namespace
+  - **External**: Google OAuth 2.0 API for token management; Gmail API for email transmission
+- **Existing Patterns Preserved**:
+  - Transmission method selection pattern (radio buttons, conditional rendering)
+  - Factory method pattern for transport creation
+  - Config namespace pattern (`mail:*` keys)
+  - Unstated container state management
+  - S2S messaging for distributed config updates
+- **New Components Rationale**:
+  - **OAuth2Setting Component**: Maintains UI consistency with SMTP/SES; enables modular development
+  - **createOAuth2Client() Method**: Isolates OAuth 2.0 transport logic; follows existing factory pattern
+  - **Four Config Keys**: Minimal set for OAuth 2.0 (user, clientId, clientSecret, refreshToken)
+- **Steering Compliance**:
+  - Feature-based organization (mail service domain)
+  - Named exports throughout
+  - Type safety with explicit TypeScript interfaces
+  - Immutable config updates
+  - Security-first credential handling
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | React 18.x + TypeScript | OAuth2Setting UI component | Existing stack, no new dependencies |
+| Frontend | react-hook-form | Form validation and state | Existing dependency, consistent with SmtpSetting/SesSetting |
+| Backend | Node.js + TypeScript | MailService OAuth 2.0 integration | Existing runtime, no version changes |
+| Backend | nodemailer 6.x | OAuth 2.0 transport creation | Existing dependency with built-in OAuth 2.0 support |
+| Data | MongoDB | Config storage for OAuth 2.0 credentials | Existing database, new config keys only |
+| External | Google OAuth 2.0 API | Token refresh endpoint | Standard Google API, https://oauth2.googleapis.com/token |
+| External | Gmail API | Email transmission via OAuth 2.0 | Accessed via nodemailer Gmail transport |
+
+**Key Technology Decisions**:
+- **Nodemailer OAuth 2.0**: Built-in support eliminates need for additional OAuth 2.0 libraries; automatic token refresh reduces complexity
+- **No New Dependencies**: Feature fully implemented with existing packages; zero dependency risk
+- **MongoDB Encryption**: Credentials stored using existing ConfigManager encryption (same as SMTP passwords)
+- **Gmail Service Shortcut**: Nodemailer's `service: "gmail"` simplifies configuration and handles Gmail API specifics
+
+## System Flows
+
+### OAuth 2.0 Configuration Flow
+
+```mermaid
+sequenceDiagram
+    participant Admin as Administrator
+    participant UI as MailSetting UI
+    participant Container as AdminAppContainer
+    participant API as App Settings API
+    participant Config as ConfigManager
+    participant DB as MongoDB
+
+    Admin->>UI: Select "oauth2" transmission method
+    UI->>UI: Render OAuth2Setting component
+    Admin->>UI: Enter OAuth 2.0 credentials
+    Admin->>UI: Click Update button
+    UI->>Container: handleSubmit formData
+    Container->>API: POST app-settings
+    API->>API: Validate OAuth 2.0 fields
+    alt Validation fails
+        API-->>Container: 400 Bad Request
+        Container-->>UI: Display error toast
+    else Validation passes
+        API->>Config: setConfig mail:oauth2*
+        Config->>DB: Save encrypted credentials
+        DB-->>Config: Success
+        Config-->>API: Success
+        API-->>Container: 200 OK
+        Container-->>UI: Display success toast
+    end
+```
+
+### Email Sending with OAuth 2.0 Flow
+
+```mermaid
+sequenceDiagram
+    participant App as GROWI Application
+    participant Mail as MailService
+    participant Nodemailer as Nodemailer Transport
+    participant Google as Google OAuth 2.0 API
+    participant Gmail as Gmail API
+
+    App->>Mail: send emailConfig
+    Mail->>Mail: Check mailer setup
+    alt Mailer not setup
+        Mail-->>App: Error Mailer not set up
+    else Mailer setup oauth2
+        Mail->>Nodemailer: sendMail mailConfig
+        Nodemailer->>Nodemailer: Check access token validity
+        alt Access token expired
+            Nodemailer->>Google: POST token refresh
+            Google-->>Nodemailer: New access token
+            Nodemailer->>Nodemailer: Cache access token
+        end
+        Nodemailer->>Gmail: POST send message
+        alt Authentication failure
+            Gmail-->>Nodemailer: 401 Unauthorized
+            Nodemailer-->>Mail: Error Invalid credentials
+            Mail-->>App: Error with OAuth 2.0 details
+        else Success
+            Gmail-->>Nodemailer: 200 OK message ID
+            Nodemailer-->>Mail: Success
+            Mail->>Mail: Log transmission success
+            Mail-->>App: Email sent successfully
+        end
+    end
+```
+
+**Flow-Level Decisions**:
+- **Token Refresh**: Handled entirely by nodemailer; MailService does not implement custom refresh logic
+- **Error Handling**: OAuth 2.0 errors logged with specific Google API error codes for admin troubleshooting
+- **Credential Validation**: Performed at API layer before persisting to database; prevents invalid config states
+- **S2S Sync**: OAuth 2.0 config changes trigger `mailServiceUpdated` event for distributed deployments (existing pattern)
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1 | Add OAuth 2.0 transmission method option | MailSetting.tsx, config-definition.ts | ConfigDefinition | Configuration |
+| 1.2 | Display OAuth 2.0 config fields when selected | OAuth2Setting.tsx, MailSetting.tsx | React Props | Configuration |
+| 1.3 | Validate email address format | AdminAppContainer, App Settings API | API Contract | Configuration |
+| 1.4 | Validate non-empty OAuth 2.0 credentials | AdminAppContainer, App Settings API | API Contract | Configuration |
+| 1.5 | Securely store OAuth 2.0 credentials with encryption | ConfigManager, MongoDB | Data Model | Configuration |
+| 1.6 | Confirm successful configuration save | AdminAppContainer, MailSetting.tsx | API Contract | Configuration |
+| 1.7 | Display descriptive error messages on save failure | AdminAppContainer, MailSetting.tsx | API Contract | Configuration |
+| 2.1 | Use nodemailer Gmail OAuth 2.0 transport | MailService.createOAuth2Client() | Service Interface | Email Sending |
+| 2.2 | Authenticate to Gmail API with OAuth 2.0 | MailService.createOAuth2Client() | External API | Email Sending |
+| 2.3 | Set FROM address to configured email | MailService.setupMailConfig() | Service Interface | Email Sending |
+| 2.4 | Log successful email transmission | MailService.send() | Service Interface | Email Sending |
+| 2.5 | Support all email content types | MailService.send() (existing) | Service Interface | Email Sending |
+| 2.6 | Process email queue sequentially | MailService.send() (existing) | Service Interface | Email Sending |
+| 3.1 | Use nodemailer automatic token refresh | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 3.2 | Request new access token with refresh token | Nodemailer OAuth 2.0 transport | External API | Email Sending |
+| 3.3 | Continue email sending after token refresh | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 3.4 | Log error and notify admin on refresh failure | MailService.send(), Error Handler | Service Interface | Email Sending |
+| 3.5 | Cache access tokens in memory | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 3.6 | Invalidate cached tokens on config update | MailService.initialize() | Service Interface | Configuration |
+| 4.1 | Display OAuth 2.0 form with consistent styling | OAuth2Setting.tsx | React Component | Configuration |
+| 4.2 | Preserve OAuth 2.0 credentials when switching methods | AdminAppContainer state | State Management | Configuration |
+| 4.3 | Provide field-level help text | OAuth2Setting.tsx | React Component | Configuration |
+| 4.4 | Mask sensitive fields (last 4 characters) | OAuth2Setting.tsx | React Component | Configuration |
+| 4.5 | Provide test email button | MailSetting.tsx | API Contract | Email Sending |
+| 4.6 | Display test email result with detailed errors | AdminAppContainer, MailSetting.tsx | API Contract | Email Sending |
+| 5.1 | Log specific OAuth 2.0 error codes | MailService error handler | Service Interface | Email Sending |
+| 5.2 | Retry email sending with exponential backoff | MailService.send() | Service Interface | Email Sending |
+| 5.3 | Store failed emails after all retries | MailService.send() | Service Interface | Email Sending |
+| 5.4 | Never log credentials in plain text | MailService, ConfigManager | Security Pattern | All flows |
+| 5.5 | Require admin authentication for config page | App Settings API | API Contract | Configuration |
+| 5.6 | Stop OAuth 2.0 sending when credentials deleted | MailService.initialize() | Service Interface | Email Sending |
+| 5.7 | Validate SSL/TLS for OAuth 2.0 endpoints | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 6.1 | Maintain backward compatibility with SMTP/SES | MailService, config-definition.ts | All Interfaces | All flows |
+| 6.2 | Use only active transmission method | MailService.initialize() | Service Interface | Email Sending |
+| 6.3 | Allow switching transmission methods without data loss | AdminAppContainer, ConfigManager | State Management | Configuration |
+| 6.4 | Display configuration error if no method set | MailService, MailSetting.tsx | Service Interface | Configuration |
+| 6.5 | Expose OAuth 2.0 status via admin API | App Settings API | API Contract | Configuration |
+
+## Components and Interfaces
+
+### Component Summary
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
+|-----------|--------------|--------|--------------|--------------------------|-----------|
+| MailService | Server/Service | Email transmission with OAuth 2.0 support | 2.1-2.6, 3.1-3.6, 5.1-5.7, 6.2, 6.4 | ConfigManager (P0), Nodemailer (P0), S2SMessaging (P1) | Service |
+| OAuth2Setting | Client/UI | OAuth 2.0 credential input form | 1.2, 4.1, 4.3, 4.4 | AdminAppContainer (P0), react-hook-form (P0) | State |
+| AdminAppContainer | Client/State | State management for mail settings | 1.3, 1.4, 1.6, 1.7, 4.2, 6.3 | App Settings API (P0) | API |
+| ConfigManager | Server/Service | Persist OAuth 2.0 credentials | 1.5, 6.1, 6.3 | MongoDB (P0) | Service, State |
+| App Settings API | Server/API | Mail settings CRUD operations | 1.3-1.7, 4.5-4.6, 5.5, 6.5 | ConfigManager (P0), MailService (P1) | API |
+| Config Definition | Server/Config | OAuth 2.0 config schema | 1.1, 6.1 | None | State |
+
+### Server / Service Layer
+
+#### MailService
+
+| Field | Detail |
+|-------|--------|
+| Intent | Extend email transmission service with OAuth 2.0 support using Gmail API |
+| Requirements | 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 5.1, 5.2, 5.3, 5.4, 5.6, 5.7, 6.2, 6.4 |
+| Owner / Reviewers | Backend team |
+
+**Responsibilities & Constraints**
+- Create OAuth 2.0 nodemailer transport using Gmail service with credentials from ConfigManager
+- Handle OAuth 2.0 authentication failures and token refresh errors with specific error logging
+- Implement retry logic with exponential backoff (1s, 2s, 4s) for transient failures
+- Store failed emails after all retry attempts for manual review
+- Maintain single active transmission method (smtp, ses, or oauth2) per instance
+- Invalidate cached OAuth 2.0 tokens when configuration changes via S2S messaging
+
+**Dependencies**
+- Inbound: Crowi container — service initialization (P0)
+- Inbound: Application modules — email sending requests (P0)
+- Inbound: S2S Messaging — config update notifications (P1)
+- Outbound: ConfigManager — load OAuth 2.0 credentials (P0)
+- Outbound: Nodemailer — create transport and send emails (P0)
+- External: Google OAuth 2.0 API — token refresh (P0)
+- External: Gmail API — email transmission (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+interface MailServiceOAuth2Extension {
+  /**
+   * Create OAuth 2.0 nodemailer transport for Gmail
+   */
+  createOAuth2Client(option?: OAuth2TransportOptions): Transporter | null;
+
+  /**
+   * Send email with retry logic and error handling
+   */
+  sendWithRetry(config: EmailConfig, maxRetries?: number): Promise<SendResult>;
+
+  /**
+   * Store failed email for manual review
+   */
+  storeFailedEmail(config: EmailConfig, error: Error): Promise<void>;
+
+  /**
+   * Wait with exponential backoff
+   */
+  exponentialBackoff(attempt: number): Promise<void>;
+}
+
+interface OAuth2TransportOptions {
+  user: string;
+  clientId: string;
+  clientSecret: string;
+  refreshToken: string;
+}
+
+interface MailService {
+  send(config: EmailConfig): Promise<void>;
+  initialize(): void;
+  isMailerSetup: boolean;
+}
+
+interface EmailConfig {
+  to: string;
+  from?: string;
+  subject?: string;
+  template: string;
+  vars?: Record<string, unknown>;
+}
+
+interface SendResult {
+  messageId: string;
+  response: string;
+  envelope: {
+    from: string;
+    to: string[];
+  };
+}
+```
+
+- **Preconditions**:
+  - ConfigManager loaded with valid `mail:oauth2*` configuration values
+  - Nodemailer package version supports OAuth 2.0 (v6.x+)
+  - Google OAuth 2.0 refresh token has `https://mail.google.com/` scope
+
+- **Postconditions**:
+  - OAuth 2.0 transport created with automatic token refresh enabled
+  - `isMailerSetup` flag set to true when transport successfully created
+  - Failed transport creation returns null and logs error
+  - Successful email sends logged with messageId and recipient
+  - Failed emails stored after retry exhaustion
+
+- **Invariants**:
+  - Only one transmission method active at a time
+  - Credentials never logged in plain text
+  - Token refresh handled transparently by nodemailer
+  - Retry backoff: 1s, 2s, 4s
+
+
+#### ConfigManager
+
+| Field | Detail |
+|-------|--------|
+| Intent | Persist and retrieve OAuth 2.0 credentials with encryption |
+| Requirements | 1.5, 6.1, 6.3 |
+
+**Responsibilities & Constraints**
+- Store four new OAuth 2.0 config keys with encryption
+- Support transmission method value 'oauth2'
+- Maintain all SMTP and SES config values when OAuth 2.0 is configured
+
+**Dependencies**
+- Inbound: MailService, App Settings API (P0)
+- Outbound: MongoDB, Encryption Service (P0)
+
+**Contracts**: Service [x] / State [x]
+
+##### Service Interface
+
+```typescript
+interface ConfigManagerOAuth2Extension {
+  getConfig(key: 'mail:oauth2User'): string | undefined;
+  getConfig(key: 'mail:oauth2ClientId'): string | undefined;
+  getConfig(key: 'mail:oauth2ClientSecret'): string | undefined;
+  getConfig(key: 'mail:oauth2RefreshToken'): string | undefined;
+  getConfig(key: 'mail:transmissionMethod'): 'smtp' | 'ses' | 'oauth2' | undefined;
+
+  setConfig(key: 'mail:oauth2User', value: string): Promise<void>;
+  setConfig(key: 'mail:oauth2ClientId', value: string): Promise<void>;
+  setConfig(key: 'mail:oauth2ClientSecret', value: string): Promise<void>;
+  setConfig(key: 'mail:oauth2RefreshToken', value: string): Promise<void>;
+  setConfig(key: 'mail:transmissionMethod', value: 'smtp' | 'ses' | 'oauth2'): Promise<void>;
+}
+```
+
+##### State Management
+
+- **State Model**: OAuth 2.0 credentials stored as separate config documents in MongoDB
+- **Persistence**: Encrypted at write time; decrypted at read time
+- **Consistency**: Atomic writes per config key
+- **Concurrency**: Last-write-wins; S2S messaging for eventual consistency
+
+
+### Client / UI Layer
+
+#### OAuth2Setting Component
+
+| Field | Detail |
+|-------|--------|
+| Intent | Render OAuth 2.0 credential input form with help text and field masking |
+| Requirements | 1.2, 4.1, 4.3, 4.4 |
+
+**Responsibilities & Constraints**
+- Display four input fields with help text
+- Mask saved Client Secret and Refresh Token (show last 4 characters)
+- Follow SMTP/SES visual patterns
+- Use react-hook-form register
+
+**Dependencies**
+- Inbound: MailSetting component (P0)
+- Outbound: AdminAppContainer (P1)
+- External: react-hook-form (P0)
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+interface OAuth2SettingProps {
+  register: UseFormRegister<MailSettingsFormData>;
+  adminAppContainer?: AdminAppContainer;
+}
+
+interface MailSettingsFormData {
+  fromAddress: string;
+  transmissionMethod: 'smtp' | 'ses' | 'oauth2';
+  smtpHost: string;
+  smtpPort: string;
+  smtpUser: string;
+  smtpPassword: string;
+  sesAccessKeyId: string;
+  sesSecretAccessKey: string;
+  oauth2User: string;
+  oauth2ClientId: string;
+  oauth2ClientSecret: string;
+  oauth2RefreshToken: string;
+}
+```
+
+
+#### AdminAppContainer (Extension)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Manage OAuth 2.0 credential state and API interactions |
+| Requirements | 1.3, 1.4, 1.6, 1.7, 4.2, 6.3 |
+
+**Responsibilities & Constraints**
+- Add four state properties and setter methods
+- Include OAuth 2.0 credentials in API payload
+- Validate email format before API call
+- Display success/error toasts
+
+**Dependencies**
+- Inbound: MailSetting, OAuth2Setting (P0)
+- Outbound: App Settings API (P0)
+
+**Contracts**: State [x] / API [x]
+
+##### State Management
+
+```typescript
+interface AdminAppContainerOAuth2State {
+  fromAddress?: string;
+  transmissionMethod?: 'smtp' | 'ses' | 'oauth2';
+  smtpHost?: string;
+  smtpPort?: string;
+  smtpUser?: string;
+  smtpPassword?: string;
+  sesAccessKeyId?: string;
+  sesSecretAccessKey?: string;
+  isMailerSetup: boolean;
+  oauth2User?: string;
+  oauth2ClientId?: string;
+  oauth2ClientSecret?: string;
+  oauth2RefreshToken?: string;
+}
+
+interface AdminAppContainerOAuth2Methods {
+  changeOAuth2User(oauth2User: string): Promise<void>;
+  changeOAuth2ClientId(oauth2ClientId: string): Promise<void>;
+  changeOAuth2ClientSecret(oauth2ClientSecret: string): Promise<void>;
+  changeOAuth2RefreshToken(oauth2RefreshToken: string): Promise<void>;
+  updateMailSettingHandler(): Promise<void>;
+}
+```
+
+
+### Server / API Layer
+
+#### App Settings API (Extension)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Handle OAuth 2.0 credential CRUD operations with validation |
+| Requirements | 1.3, 1.4, 1.5, 1.6, 1.7, 4.5, 4.6, 5.5, 6.5 |
+
+**Responsibilities & Constraints**
+- Accept OAuth 2.0 credentials in PUT request
+- Validate email format and non-empty credentials
+- Persist via ConfigManager
+- Trigger S2S messaging
+- Require admin authentication
+
+**Dependencies**
+- Inbound: AdminAppContainer (P0)
+- Outbound: ConfigManager, MailService, S2S Messaging (P0/P1)
+
+**Contracts**: API [x]
+
+##### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|--------|----------|---------|----------|--------|
+| PUT | /api/v3/app-settings | UpdateMailSettingsRequest | AppSettingsResponse | 400, 401, 500 |
+| GET | /api/v3/app-settings | - | AppSettingsResponse | 401, 500 |
+| POST | /api/v3/mail/send-test | - | TestEmailResponse | 400, 401, 500 |
+
+**Request/Response Schemas**:
+
+```typescript
+interface UpdateMailSettingsRequest {
+  'mail:from'?: string;
+  'mail:transmissionMethod'?: 'smtp' | 'ses' | 'oauth2';
+  'mail:smtpHost'?: string;
+  'mail:smtpPort'?: string;
+  'mail:smtpUser'?: string;
+  'mail:smtpPassword'?: string;
+  'mail:sesAccessKeyId'?: string;
+  'mail:sesSecretAccessKey'?: string;
+  'mail:oauth2User'?: string;
+  'mail:oauth2ClientId'?: string;
+  'mail:oauth2ClientSecret'?: string;
+  'mail:oauth2RefreshToken'?: string;
+}
+
+interface AppSettingsResponse {
+  appSettings: {
+    'mail:from'?: string;
+    'mail:transmissionMethod'?: 'smtp' | 'ses' | 'oauth2';
+    'mail:smtpHost'?: string;
+    'mail:smtpPort'?: string;
+    'mail:smtpUser'?: string;
+    'mail:sesAccessKeyId'?: string;
+    'mail:oauth2User'?: string;
+    'mail:oauth2ClientId'?: string;
+  };
+  isMailerSetup: boolean;
+}
+
+interface TestEmailResponse {
+  success: boolean;
+  message?: string;
+  error?: {
+    code: string;
+    message: string;
+  };
+}
+```
+
+
+### Server / Config Layer
+
+#### Config Definition (Extension)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Define OAuth 2.0 configuration schema with type safety |
+| Requirements | 1.1, 6.1 |
+
+**Config Schema**:
+
+```typescript
+const CONFIG_KEYS = [
+  'mail:oauth2User',
+  'mail:oauth2ClientId',
+  'mail:oauth2ClientSecret',
+  'mail:oauth2RefreshToken',
+];
+
+'mail:transmissionMethod': defineConfig<'smtp' | 'ses' | 'oauth2' | undefined>({
+  defaultValue: undefined,
+}),
+
+'mail:oauth2User': defineConfig<string | undefined>({
+  defaultValue: undefined,
+}),
+'mail:oauth2ClientId': defineConfig<string | undefined>({
+  defaultValue: undefined,
+}),
+'mail:oauth2ClientSecret': defineConfig<string | undefined>({
+  defaultValue: undefined,
+  isSecret: true,
+}),
+'mail:oauth2RefreshToken': defineConfig<string | undefined>({
+  defaultValue: undefined,
+  isSecret: true,
+}),
+```
+
+## Data Models
+
+### Domain Model
+
+**Mail Configuration Aggregate**:
+- **Root Entity**: MailConfiguration
+- **Value Objects**: TransmissionMethod, OAuth2Credentials, SmtpCredentials, SesCredentials
+- **Business Rules**: Only one transmission method active; OAuth2Credentials complete when all fields present
+- **Invariants**: Credentials encrypted; FROM address required
+
+### Logical Data Model
+
+**Structure Definition**:
+- **Entity**: Config (MongoDB document)
+- **Attributes**: ns, key, value, createdAt, updatedAt
+- **Natural Keys**: ns field (unique)
+
+**Consistency & Integrity**:
+- **Transaction Boundaries**: Each config key saved independently
+- **Temporal Aspects**: updatedAt tracked per entry
+
+### Physical Data Model
+
+- Config documents stored in MongoDB with ns/key/value pattern
+- FailedEmail documents track failed email attempts with error context
+- **Encryption**: AES-256 for clientSecret and refreshToken via environment-provided key
+
+### Data Contracts & Integration
+
+**API Data Transfer**:
+- OAuth 2.0 credentials via JSON in PUT /api/v3/app-settings
+- Client Secret and Refresh Token never returned in GET responses
+
+**Cross-Service Data Management**:
+- S2S messaging broadcasts mailServiceUpdated event
+- Eventual consistency across instances
+
+
+## Critical Implementation Constraints
+
+### Nodemailer XOAuth2 Compatibility (CRITICAL)
+
+**Constraint**: OAuth 2.0 credential validation **must use falsy checks** (`!value`) not null checks (`value != null`) to match nodemailer's internal XOAuth2 handler behavior.
+
+**Rationale**: Nodemailer's XOAuth2.generateToken() method uses `!this.options.refreshToken` at line 184, which rejects empty strings as invalid. Using `!= null` checks in GROWI would allow empty strings through validation, causing runtime failures when nodemailer rejects them.
+
+**Implementation Pattern**:
+```typescript
+// ✅ CORRECT: Falsy check matches nodemailer behavior
+if (!clientId || !clientSecret || !refreshToken || !user) {
+  return null;
+}
+```
+
+**Impact**: Affects MailService.createOAuth2Client(), ConfigManager validation, and API validators. All OAuth 2.0 credential checks must follow this pattern.
+
+**Reference**: [mail.ts:219-226](../../../apps/app/src/server/service/mail.ts#L219-L226), [research.md](research.md#1-nodemailer-xoauth2-falsy-check-requirement)
+
+---
+
+### Credential Preservation Pattern (CRITICAL)
+
+**Constraint**: PUT requests updating OAuth 2.0 configuration **must only include secret fields (clientSecret, refreshToken) when non-empty values are provided**, preventing accidental credential overwrites.
+
+**Rationale**: Standard PUT pattern sending all form fields would overwrite secrets with empty strings when administrators update non-secret fields (from address, user email). GET endpoint returns `undefined` for secrets (not masked placeholders) to prevent re-submission of placeholder text.
+
+**Implementation Pattern**:
+```typescript
+// Build params with non-secret fields
+const params = {
+  'mail:oauth2ClientId': req.body.oauth2ClientId,
+  'mail:oauth2User': req.body.oauth2User,
+};
+
+// Only include secrets if non-empty
+if (req.body.oauth2ClientSecret) {
+  params['mail:oauth2ClientSecret'] = req.body.oauth2ClientSecret;
+}
+```
+
+**Impact**: Affects App Settings API PUT handler and any future API that updates OAuth 2.0 credentials.
+
+**Reference**: [apiv3/app-settings/index.ts:293-306](../../../apps/app/src/server/routes/apiv3/app-settings/index.ts#L293-L306), [research.md](research.md#3-credential-preservation-pattern)
+
+---
+
+### Gmail API FROM Address Behavior (LIMITATION)
+
+**Limitation**: Gmail API **rewrites FROM addresses to the authenticated account email** unless send-as aliases are configured in Google Workspace.
+
+**Example**:
+```
+Configured: mail:from = "notifications@example.com"
+Authenticated: oauth2User = "admin@company.com"
+Actual sent FROM: "admin@company.com"
+```
+
+**Workaround**: Google Workspace administrators must configure send-as aliases in Gmail Settings → Accounts and Import → Send mail as, then verify domain ownership.
+
+**Why This Happens**: Gmail API security policy prevents email spoofing by restricting FROM addresses to authenticated accounts or verified aliases.
+
+**Impact**: GROWI's `mail:from` configuration has limited effect with OAuth 2.0. Custom FROM addresses require Google Workspace configuration. This is expected Gmail behavior, not a GROWI limitation.
+
+**Reference**: [research.md](research.md#2-gmail-api-from-address-rewriting)
+
+---
+
+### OAuth 2.0 Retry Integration (DESIGN DECISION)
+
+**Decision**: OAuth 2.0 transmission uses `sendWithRetry()` with exponential backoff (1s, 2s, 4s), while SMTP/SES use direct `sendMail()` without retries.
+
+**Rationale**: OAuth 2.0 token refresh can fail transiently due to network issues or Google API rate limiting. Exponential backoff provides resilience without overwhelming the API.
+
+**Implementation**:
+```typescript
+if (transmissionMethod === 'oauth2') {
+  return this.sendWithRetry(mailConfig);
+}
+return this.mailer.sendMail(mailConfig);
+```
+
+**Impact**: OAuth 2.0 email failures are automatically retried, improving reliability for production deployments.
+
+**Reference**: [mail.ts:392-400](../../../apps/app/src/server/service/mail.ts#L392-L400)

+ 57 - 0
.kiro/specs/oauth2-email-support/requirements.md

@@ -0,0 +1,57 @@
+# Requirements Document
+
+## Project Description (Input)
+OAuth 2.0 authentication で Google Workspace を利用し email を送信する機能を追加したい
+
+### Context from User
+This implementation adds OAuth 2.0 authentication support for sending emails using Google Workspace accounts. The feature is fully integrated into the admin settings UI and follows the existing patterns for SMTP and SES configuration.
+
+Key configuration parameters:
+- Email Address: The authorized Google account email
+- Client ID: OAuth 2.0 Client ID from Google Cloud Console
+- Client Secret: OAuth 2.0 Client Secret
+- Refresh Token: OAuth 2.0 Refresh Token obtained from authorization flow
+
+The implementation uses nodemailer's built-in Gmail OAuth 2.0 support, which handles token refresh automatically.
+
+## Introduction
+
+This specification defines the requirements for adding OAuth 2.0 authentication support for email transmission using Google Workspace accounts in GROWI. The feature enables administrators to configure email sending through Google's Gmail API using OAuth 2.0 credentials instead of traditional SMTP authentication. This provides enhanced security through token-based authentication and follows Google's recommended practices for application email integration.
+
+## Requirements
+
+### Requirement 1: OAuth 2.0 Configuration Management
+
+**Objective:** As a GROWI administrator, I want to configure OAuth 2.0 credentials for Google Workspace email sending, so that the system can securely send emails without using SMTP passwords.
+
+**Summary**: The Admin Settings UI provides OAuth 2.0 as a transmission method option alongside SMTP and SES. The configuration form includes fields for Email Address, Client ID, Client Secret, and Refresh Token. All fields are validated (email format, non-empty strings using falsy checks), and secrets are encrypted before database storage. Configuration updates preserve existing secrets when empty values are submitted, preventing accidental credential overwrites. Success and error feedback is displayed to administrators.
+
+### Requirement 2: Email Sending Functionality
+
+**Objective:** As a GROWI system, I want to send emails using OAuth 2.0 authenticated Google Workspace accounts, so that notifications and system emails can be delivered securely without SMTP credentials.
+
+**Summary**: The Email Service uses nodemailer with Gmail OAuth 2.0 transport for email sending when OAuth 2.0 is configured. Authentication to Gmail API is automatic using configured credentials. The service supports all email content types (plain text, HTML, attachments, standard headers). Successful transmissions are logged with timestamp and recipient information. OAuth 2.0 sends use retry logic with exponential backoff (1s, 2s, 4s) to handle transient failures. Note: Gmail API rewrites FROM address to the authenticated account unless send-as aliases are configured in Google Workspace.
+
+### Requirement 3: Token Management
+
+**Objective:** As a GROWI system, I want to automatically manage OAuth 2.0 access token lifecycle, so that email sending continues without manual intervention when tokens expire.
+
+**Summary**: Token refresh is handled automatically by nodemailer's built-in OAuth 2.0 support. Access tokens are cached in memory and reused until expiration. When refresh tokens are used, nodemailer requests new access tokens from Google's OAuth 2.0 endpoint transparently. Token refresh failures are logged with specific error codes for troubleshooting. When OAuth 2.0 configuration is updated, cached tokens are invalidated via service reinitialization triggered by S2S messaging.
+
+### Requirement 4: Admin UI Integration
+
+**Objective:** As a GROWI administrator, I want OAuth 2.0 email configuration to follow the same UI patterns as SMTP and SES, so that I can configure it consistently with existing mail settings.
+
+**Summary**: The Mail Settings page displays OAuth 2.0 configuration form with consistent visual styling, preserves credentials when switching transmission methods, and shows configuration status. Browser autofill is prevented for secret fields, and placeholder text indicates that blank fields will preserve existing values.
+
+### Requirement 5: Error Handling and Security
+
+**Objective:** As a GROWI administrator, I want clear error messages and secure credential handling, so that I can troubleshoot configuration issues and ensure credentials are protected.
+
+**Summary**: Authentication failures are logged with specific OAuth 2.0 error codes from Google's API for troubleshooting. Email sending failures trigger automatic retry with exponential backoff (3 attempts: 1s, 2s, 4s). Failed emails after retry exhaustion are stored in the database for manual review. Credentials are never logged in plain text (Client ID masked to last 4 characters). Admin authentication is required to access configuration. SSL/TLS validation is enforced by nodemailer. When OAuth 2.0 credentials are incomplete or deleted, the Email Service stops sending and displays configuration errors via isMailerSetup flag.
+
+### Requirement 6: Migration and Compatibility
+
+**Objective:** As a GROWI system, I want OAuth 2.0 email support to coexist with existing SMTP and SES configurations, so that administrators can choose the most appropriate transmission method for their deployment.
+
+**Summary**: OAuth 2.0 is added as a third transmission method option without breaking changes to existing SMTP and SES functionality. Only the active transmission method is used for sending emails. Administrators can switch between methods without data loss (credentials for all methods are preserved). Configuration errors are displayed when no transmission method is properly configured (via isMailerSetup flag). OAuth 2.0 configuration status is exposed through existing admin API endpoints following the same pattern as SMTP/SES.

+ 449 - 0
.kiro/specs/oauth2-email-support/research.md

@@ -0,0 +1,449 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Capture discovery findings, architectural investigations, and rationale that inform the technical design for OAuth 2.0 email support.
+
+**Usage**:
+- Log research activities and outcomes during the discovery phase.
+- Document design decision trade-offs that are too detailed for `design.md`.
+- Provide references and evidence for future audits or reuse.
+---
+
+## Summary
+- **Feature**: `oauth2-email-support`
+- **Discovery Scope**: Extension (integrating OAuth2 into existing mail service architecture)
+- **Key Findings**:
+  - Existing mail service supports SMTP and SES via transmission method pattern
+  - Nodemailer has built-in OAuth2 support for Gmail with automatic token refresh
+  - Admin UI follows modular pattern with separate setting components per transmission method
+  - Config management uses `mail:*` namespace with type-safe definitions
+
+## Research Log
+
+### Existing Mail Service Architecture
+
+- **Context**: Need to understand integration points for OAuth2 support
+- **Sources Consulted**:
+  - `apps/app/src/server/service/mail.ts` (MailService implementation)
+  - `apps/app/src/client/components/Admin/App/MailSetting.tsx` (Admin UI)
+  - `apps/app/src/server/service/config-manager/config-definition.ts` (Config schema)
+- **Findings**:
+  - MailService uses factory pattern: `createSMTPClient()`, `createSESClient()`
+  - Transmission method determined by `mail:transmissionMethod` config value ('smtp' | 'ses')
+  - `initialize()` method called on service startup and S2S message updates
+  - Nodemailer transporter created based on transmission method
+  - Admin UI uses conditional rendering for SMTP vs SES settings
+  - State management via AdminAppContainer (unstated pattern)
+  - Test email functionality exists for SMTP only
+- **Implications**:
+  - OAuth2 follows same pattern: add `createOAuth2Client()` method
+  - Extend `mail:transmissionMethod` type to `'smtp' | 'ses' | 'oauth2'`
+  - Create new `OAuth2Setting.tsx` component following SMTP/SES pattern
+  - Add OAuth2-specific config keys following `mail:*` namespace
+
+### Nodemailer OAuth2 Integration
+
+- **Context**: Verify OAuth2 support in nodemailer and configuration requirements
+- **Sources Consulted**:
+  - [OAuth2 | Nodemailer](https://nodemailer.com/smtp/oauth2)
+  - [Using Gmail | Nodemailer](https://nodemailer.com/usage/using-gmail)
+  - [Sending Emails Securely Using Node.js, Nodemailer, SMTP, Gmail, and OAuth2](https://dev.to/chandrapantachhetri/sending-emails-securely-using-node-js-nodemailer-smtp-gmail-and-oauth2-g3a)
+  - Web search: "nodemailer gmail oauth2 configuration 2026"
+- **Findings**:
+  - Nodemailer has first-class OAuth2 support with type `'OAuth2'`
+  - Configuration structure:
+    ```javascript
+    {
+      service: "gmail",
+      auth: {
+        type: "OAuth2",
+        user: "user@gmail.com",
+        clientId: process.env.GOOGLE_CLIENT_ID,
+        clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+        refreshToken: process.env.GOOGLE_REFRESH_TOKEN
+      }
+    }
+    ```
+  - Automatic access token refresh handled by nodemailer
+  - Requires `https://mail.google.com/` OAuth scope
+  - Gmail service shortcut available (simplifies configuration)
+  - Production consideration: Gmail designed for individual users, not automated services
+- **Implications**:
+  - No additional dependencies needed (nodemailer already installed)
+  - Four config values required: user email, clientId, clientSecret, refreshToken
+  - Token refresh is automatic - no manual refresh logic needed
+  - Should validate credentials before saving to config
+  - Security: clientSecret and refreshToken must be encrypted in database
+
+### Config Manager Pattern Analysis
+
+- **Context**: Understand how to add new config keys for OAuth2 credentials
+- **Sources Consulted**:
+  - `apps/app/src/server/service/config-manager/config-definition.ts`
+  - Existing mail config keys: `mail:from`, `mail:transmissionMethod`, `mail:smtpHost`, etc.
+- **Findings**:
+  - Config keys use namespace pattern: `mail:*`
+  - Type-safe definitions using `defineConfig<T>()`
+  - Existing transmission method: `defineConfig<'smtp' | 'ses' | undefined>()`
+  - Config values stored in database via ConfigManager
+  - No explicit encryption layer visible in config definition (handled elsewhere)
+- **Implications**:
+  - Add four new keys: `mail:oauth2User`, `mail:oauth2ClientId`, `mail:oauth2ClientSecret`, `mail:oauth2RefreshToken`
+  - Update `mail:transmissionMethod` type to `'smtp' | 'ses' | 'oauth2' | undefined`
+  - Encryption should be handled at persistence layer (ConfigManager or database model)
+  - Follow same pattern as SMTP/SES for consistency
+
+### Admin UI State Management Pattern
+
+- **Context**: Understand how to integrate OAuth2 settings into admin UI
+- **Sources Consulted**:
+  - `apps/app/src/client/components/Admin/App/SmtpSetting.tsx`
+  - `apps/app/src/client/components/Admin/App/SesSetting.tsx`
+  - `apps/app/src/client/services/AdminAppContainer.js`
+- **Findings**:
+  - Separate component per transmission method (SmtpSetting, SesSetting)
+  - Components receive `register` from react-hook-form
+  - Unstated container pattern for state management
+  - Container methods: `changeSmtpHost()`, `changeFromAddress()`, etc.
+  - `updateMailSettingHandler()` saves all settings via API
+  - Test email button only shown for SMTP
+- **Implications**:
+  - Create `OAuth2Setting.tsx` component following same structure
+  - Add four state methods to AdminAppContainer: `changeOAuth2User()`, `changeOAuth2ClientId()`, etc.
+  - Include OAuth2 credentials in `updateMailSettingHandler()` API call
+  - Test email functionality should work for OAuth2 (same as SMTP)
+  - Field masking needed for clientSecret and refreshToken
+
+### Security Considerations
+
+- **Context**: Ensure secure handling of OAuth2 credentials
+- **Sources Consulted**:
+  - GROWI security guidelines (`.claude/rules/security.md`)
+  - Existing SMTP/SES credential handling
+- **Findings**:
+  - Credentials stored in MongoDB via ConfigManager
+  - Input fields use `type="password"` for sensitive values
+  - No explicit encryption visible in UI layer
+  - Logging should not expose credentials
+- **Implications**:
+  - Use `type="password"` for clientSecret and refreshToken fields
+  - Mask values when displaying saved configuration (show last 4 characters)
+  - Never log credentials in plain text
+  - Validate SSL/TLS when connecting to Google OAuth endpoints
+  - Ensure admin authentication required before accessing config page
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Factory Method Extension | Add `createOAuth2Client()` to existing MailService | Follows existing pattern, minimal changes, consistent with SMTP/SES | None significant | Recommended - aligns with current architecture |
+| Separate OAuth2Service | Create dedicated service for OAuth2 mail | Better separation of concerns | Over-engineering for simple extension, breaks existing pattern | Not recommended - unnecessary complexity |
+| Adapter Pattern | Wrap OAuth2 in adapter implementing mail interface | More flexible for future auth methods | Premature abstraction, more code to maintain | Not needed for single OAuth2 implementation |
+
+## Design Decisions
+
+### Decision: Extend Existing MailService with OAuth2 Support
+
+- **Context**: Need to add OAuth2 email sending without breaking existing SMTP/SES functionality
+- **Alternatives Considered**:
+  1. Create separate OAuth2MailService - more modular but introduces service management complexity
+  2. Refactor to plugin architecture - future-proof but over-engineered for current needs
+  3. Extend existing MailService with factory method - follows current pattern
+- **Selected Approach**: Extend existing MailService with `createOAuth2Client()` method
+- **Rationale**:
+  - Maintains consistency with existing architecture
+  - Minimal code changes reduce risk
+  - Clear migration path (no breaking changes)
+  - GROWI already uses this pattern successfully for SMTP/SES
+- **Trade-offs**:
+  - Benefits: Low risk, fast implementation, familiar pattern
+  - Compromises: All transmission methods in single service (acceptable given simplicity)
+- **Follow-up**: Ensure test coverage for OAuth2 path alongside existing SMTP/SES tests
+
+### Decision: Use Nodemailer's Built-in OAuth2 Support
+
+- **Context**: Need reliable OAuth2 implementation with automatic token refresh
+- **Alternatives Considered**:
+  1. Manual OAuth2 implementation with googleapis library - more control but complex
+  2. Third-party OAuth2 wrapper - additional dependency
+  3. Nodemailer built-in OAuth2 - zero additional dependencies
+- **Selected Approach**: Use nodemailer's native OAuth2 support with Gmail service
+- **Rationale**:
+  - No additional dependencies (nodemailer already installed)
+  - Automatic token refresh reduces complexity
+  - Well-documented and actively maintained
+  - Matches user's original plan (stated in requirements)
+- **Trade-offs**:
+  - Benefits: Simple, reliable, no new dependencies
+  - Compromises: Limited to Gmail/Google Workspace (acceptable per requirements)
+- **Follow-up**: Document Google Cloud Console setup steps for administrators
+
+### Decision: Preserve Existing Transmission Method Pattern
+
+- **Context**: Maintain backward compatibility while adding OAuth2 option
+- **Alternatives Considered**:
+  1. Deprecate transmission method concept - breaking change
+  2. Add OAuth2 as transmission method option - extends existing pattern
+  3. Support multiple simultaneous methods - unnecessary complexity
+- **Selected Approach**: Add 'oauth2' as third transmission method option
+- **Rationale**:
+  - Zero breaking changes for existing users
+  - Consistent admin UI experience
+  - Clear mutual exclusivity (one method active at a time)
+  - Easy to test and validate
+- **Trade-offs**:
+  - Benefits: Backward compatible, simple mental model
+  - Compromises: Only one transmission method active (acceptable per requirements)
+- **Follow-up**: Ensure switching between methods preserves all config values
+
+### Decision: Component-Based UI Following SMTP/SES Pattern
+
+- **Context**: Need consistent admin UI for OAuth2 configuration
+- **Alternatives Considered**:
+  1. Inline OAuth2 fields in main form - cluttered UI
+  2. Modal dialog for OAuth2 setup - breaks existing pattern
+  3. Separate OAuth2Setting component - matches SMTP/SES pattern
+- **Selected Approach**: Create `OAuth2Setting.tsx` component rendered conditionally
+- **Rationale**:
+  - Maintains visual consistency across transmission methods
+  - Reuses existing form patterns (react-hook-form, unstated)
+  - Easy for admins familiar with SMTP/SES setup
+  - Supports incremental development (component isolation)
+- **Trade-offs**:
+  - Benefits: Consistent UX, modular code, easy testing
+  - Compromises: Minor code duplication in form field rendering (acceptable)
+- **Follow-up**: Add help text for each OAuth2 field explaining Google Cloud Console setup
+
+## Risks & Mitigations
+
+- **Risk**: OAuth2 credentials stored in plain text in database
+  - **Mitigation**: Implement encryption at ConfigManager persistence layer; use same encryption as SMTP passwords
+
+- **Risk**: Refresh token expiration or revocation not handled
+  - **Mitigation**: Nodemailer handles refresh automatically; log specific error codes for troubleshooting; document token refresh in admin help text
+
+- **Risk**: Google rate limiting or account suspension
+  - **Mitigation**: Document production usage considerations; implement exponential backoff retry logic; log detailed error responses from Gmail API
+
+- **Risk**: Incomplete credential configuration causing service failure
+  - **Mitigation**: Validate all four required fields before saving; display clear error messages; maintain isMailerSetup flag for health checks
+
+- **Risk**: Breaking changes to existing SMTP/SES functionality
+  - **Mitigation**: Preserve all existing code paths; add OAuth2 as isolated branch; comprehensive integration tests for all three methods
+
+## Session 2: Production Implementation Discoveries (2026-02-10)
+
+### Critical Technical Constraints Identified
+
+#### 1. Nodemailer XOAuth2 Falsy Check Requirement
+
+**Discovery**: Production testing revealed "Can't create new access token for user" errors from nodemailer's XOAuth2 handler.
+
+**Root Cause**: Nodemailer's XOAuth2 implementation uses **falsy checks** (`!this.options.refreshToken`) at line 184, not null checks, rejecting empty strings as invalid credentials.
+
+**Implementation Requirement**:
+```typescript
+// ❌ WRONG: Allows empty strings to pass validation
+if (clientId != null && clientSecret != null && refreshToken != null) {
+  // This passes validation but nodemailer will reject it
+}
+
+// ✅ CORRECT: Matches nodemailer's falsy check behavior
+if (!clientId || !clientSecret || !refreshToken || !user) {
+  logger.warn('OAuth 2.0 credentials incomplete, skipping transport creation');
+  return null;
+}
+```
+
+**Why This Matters**: Empty strings (`""`) are falsy in JavaScript. Using `!= null` in GROWI would allow empty strings through validation, but nodemailer's falsy check would then reject them, causing runtime failures.
+
+**Impact**: All credential validation logic in MailService and ConfigManager **must use falsy checks** for OAuth 2.0 credentials to maintain compatibility with nodemailer.
+
+**Reference**: [mail.ts:219-226](../../../apps/app/src/server/service/mail.ts#L219-L226)
+
+---
+
+#### 2. Gmail API FROM Address Rewriting
+
+**Discovery**: Gmail API rewrites the FROM address to the authenticated account email, ignoring GROWI's configured `mail:from` address.
+
+**Gmail API Behavior**: Gmail API enforces that emails are sent FROM the authenticated account unless send-as aliases are explicitly configured in Google Workspace.
+
+**Example**:
+```
+Configured: mail:from = "notifications@example.com"
+Authenticated: oauth2User = "admin@company.com"
+Actual sent FROM: "admin@company.com"
+```
+
+**Workaround**: Google Workspace administrators must configure **send-as aliases**:
+1. Gmail Settings → Accounts and Import → Send mail as
+2. Add desired FROM address as an alias
+3. Verify domain ownership
+
+**Why This Happens**: Gmail API security policy prevents email spoofing by restricting FROM addresses to authenticated account or verified aliases.
+
+**Impact**:
+- GROWI's `mail:from` configuration has **limited effect** with OAuth 2.0
+- Custom FROM addresses require Google Workspace send-as alias configuration
+- This is **expected Gmail behavior**, not a GROWI limitation
+
+**Documentation Note**: This behavior must be documented in admin UI help text and user guides.
+
+---
+
+#### 3. Credential Preservation Pattern
+
+**Discovery**: Initial implementation allowed secret credentials to be accidentally overwritten with empty strings or masked placeholder values when updating non-secret fields.
+
+**Problem**: Standard PUT request pattern sending all form fields would overwrite secrets with empty values when administrators only wanted to update non-secret fields like `from` address or `oauth2User`.
+
+**Solution**: Conditional secret inclusion pattern:
+
+```typescript
+// Build request params with non-secret fields
+const requestOAuth2SettingParams: Record<string, any> = {
+  'mail:from': req.body.fromAddress,
+  'mail:transmissionMethod': req.body.transmissionMethod,
+  'mail:oauth2ClientId': req.body.oauth2ClientId,
+  'mail:oauth2User': req.body.oauth2User,
+};
+
+// Only include secrets if non-empty values provided
+if (req.body.oauth2ClientSecret) {
+  requestOAuth2SettingParams['mail:oauth2ClientSecret'] = req.body.oauth2ClientSecret;
+}
+if (req.body.oauth2RefreshToken) {
+  requestOAuth2SettingParams['mail:oauth2RefreshToken'] = req.body.oauth2RefreshToken;
+}
+```
+
+**Frontend Consideration**: GET endpoint returns `undefined` for secrets (not masked values) to prevent accidental re-submission:
+
+```typescript
+// ❌ WRONG: Returns masked value that could be saved back
+oauth2ClientSecret: '(set)',
+
+// ✅ CORRECT: Returns undefined, frontend shows placeholder
+oauth2ClientSecret: undefined,
+```
+
+**Why This Pattern**: Allows administrators to update non-secret OAuth 2.0 settings without re-entering sensitive credentials every time.
+
+**Impact**: This pattern must be followed for **any API that updates OAuth 2.0 credentials** to prevent accidental secret overwrites.
+
+**Reference**:
+- PUT handler: [apiv3/app-settings/index.ts:293-306](../../../apps/app/src/server/routes/apiv3/app-settings/index.ts#L293-L306)
+- GET response: [apiv3/app-settings/index.ts:273-276](../../../apps/app/src/server/routes/apiv3/app-settings/index.ts#L273-L276)
+
+---
+
+### Type Safety Enhancements
+
+**NonBlankString Type**: OAuth 2.0 config definitions use `NonBlankString | undefined` for compile-time protection against empty string assignments:
+
+```typescript
+'mail:oauth2ClientSecret': defineConfig<NonBlankString | undefined>({
+  defaultValue: undefined,
+  isSecret: true,
+}),
+```
+
+This provides **compile-time protection** complementing runtime falsy checks.
+
+---
+
+### Integration Pattern Discovered
+
+**OAuth 2.0 Retry Logic**: OAuth 2.0 requires retry logic with exponential backoff due to potential token refresh failures:
+
+```typescript
+// OAuth 2.0 uses sendWithRetry() for automatic retry
+if (transmissionMethod === 'oauth2') {
+  return this.sendWithRetry(mailConfig as EmailConfig);
+}
+
+// SMTP/SES use direct sendMail()
+return this.mailer.sendMail(mailConfig);
+```
+
+**Rationale**: OAuth 2.0 token refresh can fail transiently due to network issues or Google API rate limiting. Exponential backoff (1s, 2s, 4s) provides resilience.
+
+---
+
+## Session 3: Post-Refactoring Architecture (2026-02-10)
+
+### MailService Modular Structure
+
+The MailService was refactored from a single monolithic file (`mail.ts`, ~408 lines) into a feature-based directory structure with separate transport modules. This is the current production architecture.
+
+#### Directory Structure
+
+```
+src/server/service/mail/
+├── index.ts              # Barrel export (default: MailService, backward-compatible)
+├── mail.ts               # MailService class (orchestration, S2S, retry logic)
+├── mail.spec.ts          # MailService tests
+├── smtp.ts               # SMTP transport factory: createSMTPClient()
+├── smtp.spec.ts          # SMTP transport tests
+├── ses.ts                # SES transport factory: createSESClient()
+├── ses.spec.ts           # SES transport tests
+├── oauth2.ts             # OAuth2 transport factory: createOAuth2Client()
+├── oauth2.spec.ts        # OAuth2 transport tests
+└── types.ts              # Shared types (StrictOAuth2Options, MailConfig, etc.)
+```
+
+#### Transport Factory Pattern
+
+Each transport module exports a factory function with a consistent signature:
+
+```typescript
+export function create[Transport]Client(
+  configManager: IConfigManagerForApp,
+  option?: TransportOptions
+): Transporter | null;
+```
+
+- Returns `null` if required credentials are missing (logs warning)
+- MailService delegates transport creation based on `mail:transmissionMethod` config
+
+#### StrictOAuth2Options Type
+
+Defined in `types.ts`, this branded type prevents empty string credentials at compile time:
+
+```typescript
+import type { NonBlankString } from '@growi/core/dist/interfaces';
+
+export type StrictOAuth2Options = {
+  service: 'gmail';
+  auth: {
+    type: 'OAuth2';
+    user: NonBlankString;
+    clientId: NonBlankString;
+    clientSecret: NonBlankString;
+    refreshToken: NonBlankString;
+  };
+};
+```
+
+This is stricter than nodemailer's default `XOAuth2.Options` which allows `string | undefined`. The branded type ensures compile-time validation complementing runtime falsy checks.
+
+#### Backward Compatibility
+
+The barrel export at `mail/index.ts` maintains the existing import pattern:
+```typescript
+import MailService from '~/server/service/mail';  // Still works
+```
+
+**Source**: Migrated from `.kiro/specs/refactor-mailer-service/` (spec deleted after implementation completion).
+
+---
+
+## References
+
+- [OAuth2 | Nodemailer](https://nodemailer.com/smtp/oauth2) - Official OAuth2 configuration documentation
+- [Using Gmail | Nodemailer](https://nodemailer.com/usage/using-gmail) - Gmail-specific integration guide
+- [Sending Emails Securely Using Node.js, Nodemailer, SMTP, Gmail, and OAuth2](https://dev.to/chandrapantachhetri/sending-emails-securely-using-node-js-nodemailer-smtp-gmail-and-oauth2-g3a) - Implementation tutorial
+- [Using OAuth2 with Nodemailer for Secure Email Sending](https://shazaali.substack.com/p/using-oauth2-with-nodemailer-for) - Security best practices
+- Internal: `apps/app/src/server/service/mail.ts` - Existing mail service implementation
+- Internal: `apps/app/src/client/components/Admin/App/MailSetting.tsx` - Admin UI patterns

+ 23 - 0
.kiro/specs/oauth2-email-support/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "oauth2-email-support",
+  "created_at": "2026-02-06T11:43:56Z",
+  "updated_at": "2026-02-13T00:00:00Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "cleanup_completed": true,
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 41 - 0
.kiro/specs/oauth2-email-support/tasks.md

@@ -0,0 +1,41 @@
+# Implementation Tasks - OAuth 2.0 Email Support
+
+## Status Overview
+
+**Final Status**: Production-Ready (2026-02-10)
+**Requirements Coverage**: 35/37 (95%)
+
+## Completed Tasks
+
+### Phase A: Critical Production Requirements (3 tasks)
+
+- [x] 1. Retry logic with exponential backoff (1s, 2s, 4s) - Req: 5.1, 5.2
+- [x] 2. Failed email storage after retry exhaustion - Req: 5.3
+- [x] 3. Enhanced OAuth 2.0 error logging - Req: 5.4, 5.7
+
+Session 2 additional fixes:
+- Credential validation changed to falsy check (nodemailer XOAuth2 compatibility)
+- PUT handler preserves secrets when empty values submitted
+- Config types changed to `NonBlankString | undefined`
+- GET response returns `undefined` for secrets
+- Browser autofill prevention (`autoComplete="new-password"`)
+- Static IDs replaced with `useId()` hook (Biome lint compliance)
+
+### Baseline Implementation (12 tasks)
+
+- [x] Configuration schema (4 config keys, encryption, NonBlankString types) - Req: 1.1, 1.5, 6.1
+- [x] OAuth 2.0 transport creation (nodemailer Gmail service) - Req: 2.1, 2.2, 3.1-3.3, 3.5, 6.2
+- [x] Service initialization and token management (S2S integration) - Req: 2.3, 2.5, 2.6, 3.6, 5.6, 6.2, 6.4
+- [x] API validation and persistence (PUT/GET endpoints) - Req: 1.3, 1.4, 1.5, 1.6, 5.5, 6.5
+- [x] Field-specific validation error messages - Req: 1.7
+- [x] OAuth2Setting UI component (react-hook-form integration) - Req: 1.2, 4.1
+- [x] AdminAppContainer state management (4 state properties) - Req: 4.2, 6.3
+- [x] Mail settings form submission - Req: 1.3, 1.6, 1.7
+- [x] Transmission method selection ('oauth2' option) - Req: 1.1, 1.2
+- [x] Multi-language translations (en, ja, fr, ko, zh) - Req: 1.2, 4.1, 4.3
+
+## Not Implemented (Optional Enhancements)
+
+- Help text for 2 of 4 fields incomplete (Req 4.3)
+- Credential field masking in UI (Req 4.4)
+- Test email button for OAuth 2.0 (Req 4.5)

+ 233 - 0
.kiro/specs/official-docker-image/design.md

@@ -0,0 +1,233 @@
+# Design Document: official-docker-image
+
+## Overview
+
+**Purpose**: Modernize the Dockerfile and entrypoint for the GROWI official Docker image based on 2025-2026 best practices, achieving enhanced security, optimized memory management, and improved build efficiency.
+
+**Users**: Infrastructure administrators (build/deploy), GROWI operators (memory tuning), and Docker image end users (usage via docker-compose).
+
+**Impact**: Redesign the existing 3-stage Dockerfile into a 5-stage configuration. Migrate the base image to Docker Hardened Images (DHI). Change the entrypoint from a shell script to TypeScript (using Node.js 24 native TypeScript execution), achieving a fully hardened configuration that requires no shell.
+
+### Goals
+
+- Up to 95% CVE reduction through DHI base image adoption
+- **Fully shell-free TypeScript entrypoint** — Node.js 24 native TypeScript execution (type stripping), maintaining the minimized attack surface of the DHI runtime as-is
+- Memory management via 3-tier fallback: `V8_MAX_HEAP_SIZE` / cgroup auto-calculation / V8 default
+- Environment variable names aligned with V8 option names (`V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`)
+- Improved build cache efficiency through the `turbo prune --docker` pattern
+- Privilege drop via gosu → `process.setuid/setgid` (Node.js native)
+
+### Non-Goals
+
+- Changes to Kubernetes manifests / Helm charts (GROWI.cloud `V8_MAX_HEAP_SIZE` configuration is out of scope)
+- Application code changes (adding gc(), migrating to .pipe(), etc. are separate specs)
+- Updating docker-compose.yml (documentation updates only)
+- Support for Node.js versions below 24
+- Adding HEALTHCHECK instructions (k8s uses its own probes, Docker Compose users can configure their own)
+
+## Architecture
+
+### Existing Architecture Analysis
+
+**Current Dockerfile 3-stage configuration:**
+
+| Stage | Base Image | Role |
+|-------|-----------|------|
+| `base` | `node:20-slim` | Install pnpm + turbo |
+| `builder` | `base` | `COPY . .` → install → build → artifacts |
+| release (unnamed) | `node:20-slim` | gosu install → artifact extraction → execution |
+
+**Main issues:**
+- `COPY . .` includes the entire monorepo in the build layer
+- pnpm version is hardcoded (`PNPM_VERSION="10.32.1"`)
+- Typo in `---frozen-lockfile`
+- Base image is node:20-slim (prone to CVE accumulation)
+- No memory management flags
+- No OCI labels
+- gosu installation requires apt-get (runtime dependency on apt)
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph BuildPhase
+        base[base stage<br>DHI dev + pnpm + turbo]
+        pruner[pruner stage<br>turbo prune --docker]
+        deps[deps stage<br>dependency install]
+        builder[builder stage<br>build + artifacts]
+    end
+
+    subgraph ReleasePhase
+        release[release stage<br>DHI runtime - no shell]
+    end
+
+    base --> pruner
+    pruner --> deps
+    deps --> builder
+    builder -->|artifacts| release
+
+    subgraph RuntimeFiles
+        entrypoint[docker-entrypoint.ts<br>TypeScript entrypoint]
+    end
+
+    entrypoint --> release
+```
+
+**Architecture Integration:**
+- Selected pattern: Multi-stage build with dependency caching separation
+- Domain boundaries: Build concerns (stages 1-4) vs Runtime concerns (stage 5 + entrypoint)
+- Existing patterns preserved: Production dependency extraction via pnpm deploy, tar.gz artifact transfer
+- New components: pruner stage (turbo prune), TypeScript entrypoint
+- **Key change**: gosu + shell script → TypeScript entrypoint (`process.setuid/setgid` + `fs` module + `child_process.execFileSync/spawn`). Eliminates the need for copying busybox/bash, maintaining the minimized attack surface of the DHI runtime as-is. Executes `.ts` directly via Node.js 24 type stripping
+- Steering compliance: Maintains Debian base (glibc performance), maintains monorepo build pattern
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Base Image (build) | `dhi.io/node:24-debian13-dev` | Base for build stages | apt/bash/git/util-linux available |
+| Base Image (runtime) | `dhi.io/node:24-debian13` | Base for release stage | Minimal configuration, 95% CVE reduction, **no shell** |
+| Entrypoint | Node.js (TypeScript) | Initialization, heap calculation, privilege drop, process startup | Node.js 24 native type stripping, no busybox/bash needed |
+| Privilege Drop | `process.setuid/setgid` (Node.js) | root → node user switch | No external binaries needed |
+| Build Tool | `turbo prune --docker` | Monorepo minimization | Official Turborepo recommendation |
+| Package Manager | pnpm (wget standalone) | Dependency management | corepack not adopted (scheduled for removal in Node.js 25+) |
+
+> For the rationale behind adopting the TypeScript entrypoint and comparison with busybox-static/setpriv, see `research.md`.
+
+## System Flows
+
+### Entrypoint Execution Flow
+
+```mermaid
+flowchart TD
+    Start[Container Start<br>as root via node entrypoint.ts] --> Setup[Directory Setup<br>fs.mkdirSync + symlinkSync + chownSync]
+    Setup --> HeapCalc{V8_MAX_HEAP_SIZE<br>is set?}
+    HeapCalc -->|Yes| UseEnv[Use V8_MAX_HEAP_SIZE]
+    HeapCalc -->|No| CgroupCheck{cgroup limit<br>detectable?}
+    CgroupCheck -->|Yes| AutoCalc[Auto-calculate<br>60% of cgroup limit]
+    CgroupCheck -->|No| NoFlag[No heap flag<br>V8 default]
+    UseEnv --> OptFlags[Check V8_OPTIMIZE_FOR_SIZE<br>and V8_LITE_MODE]
+    AutoCalc --> OptFlags
+    NoFlag --> OptFlags
+    OptFlags --> LogFlags[console.log applied flags]
+    LogFlags --> DropPriv[Drop privileges<br>process.setgid + setuid]
+    DropPriv --> Migration[Run migration<br>execFileSync node migrate-mongo]
+    Migration --> SpawnApp[Spawn app process<br>node --max-heap-size=X ... app.js]
+    SpawnApp --> SignalFwd[Forward SIGTERM/SIGINT<br>to child process]
+```
+
+**Key Decisions:**
+- Prioritize cgroup v2 (`/sys/fs/cgroup/memory.max`), fall back to v1
+- Treat cgroup v1 unlimited value (very large number) as no flag (threshold: 64GB)
+- `--max-heap-size` is passed to the spawned child process (the application itself), not the entrypoint process
+- Migration is invoked directly via `child_process.execFileSync` calling node (no `npm run`, no shell needed)
+- App startup uses `child_process.spawn` + signal forwarding to fulfill PID 1 responsibilities
+
+### Docker Build Flow
+
+```mermaid
+flowchart LR
+    subgraph Stage1[base]
+        S1[DHI dev image<br>+ pnpm + turbo]
+    end
+
+    subgraph Stage2[pruner]
+        S2A[COPY monorepo]
+        S2B[turbo prune --docker]
+    end
+
+    subgraph Stage3[deps]
+        S3A[COPY json + lockfile]
+        S3B[pnpm install --frozen-lockfile]
+    end
+
+    subgraph Stage4[builder]
+        S4A[COPY full source]
+        S4B[turbo run build]
+        S4C[pnpm deploy + tar.gz]
+    end
+
+    subgraph Stage5[release]
+        S5A[DHI runtime<br>no additional binaries]
+        S5B[Extract artifacts]
+        S5C[COPY entrypoint.js]
+    end
+
+    Stage1 --> Stage2 --> Stage3 --> Stage4
+    Stage4 -->|tar.gz| Stage5
+```
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Key Dependencies |
+|-----------|-------------|--------|-----------------|
+| Dockerfile | Infrastructure | 5-stage Docker image build definition | DHI images, turbo, pnpm |
+| docker-entrypoint.ts | Infrastructure | Container startup initialization (TypeScript) | Node.js fs/child_process, cgroup fs |
+| docker-entrypoint.spec.ts | Infrastructure | Unit tests for entrypoint | vitest |
+| Dockerfile.dockerignore | Infrastructure | Build context filter | — |
+| README.md | Documentation | Docker Hub image documentation | — |
+| buildspec.yml | CI/CD | CodeBuild build definition | AWS Secrets Manager, dhi.io |
+
+### Dockerfile
+
+**Responsibilities & Constraints**
+- 5-stage configuration: `base` → `pruner` → `deps` → `builder` → `release`
+- Use of DHI base images (`dhi.io/node:24-debian13-dev` / `dhi.io/node:24-debian13`)
+- **No shell or additional binary copying in runtime** (everything is handled by the Node.js entrypoint)
+
+**Stage Definitions:**
+- **base**: DHI dev image + pnpm (wget) + turbo + apt packages (`ca-certificates`, `wget`)
+- **pruner**: `COPY . .` + `turbo prune @growi/app --docker`
+- **deps**: COPY json/lockfile from pruner + `pnpm install --frozen-lockfile` + node-gyp
+- **builder**: COPY full source from pruner + `turbo run build` + `pnpm deploy` + artifact packaging
+- **release**: DHI runtime (no shell) + `COPY --from=builder` artifacts + entrypoint + OCI labels + EXPOSE/VOLUME
+
+### docker-entrypoint.ts
+
+**Responsibilities & Constraints**
+- Written in TypeScript, executed via Node.js 24 native type stripping (enums not allowed)
+- Directory setup as root (`/data/uploads` + symlink, `/tmp/page-bulk-export`)
+- Heap size determination via 3-tier fallback
+- Privilege drop via `process.setgid()` + `process.setuid()`
+- Migration execution via `child_process.execFileSync` (direct node invocation, no shell)
+- App process startup via `child_process.spawn` with signal forwarding (PID 1 responsibilities)
+- No external binary dependencies
+
+**Environment Variable Interface**
+
+| Variable | Type | Default | Description |
+|----------|------|---------|-------------|
+| `V8_MAX_HEAP_SIZE` | int (MB) | (unset) | Explicitly specify the --max-heap-size value for Node.js |
+| `V8_OPTIMIZE_FOR_SIZE` | `"true"` / (unset) | (unset) | Enable the --optimize-for-size flag |
+| `V8_LITE_MODE` | `"true"` / (unset) | (unset) | Enable the --lite-mode flag |
+
+> **Naming Convention**: Environment variable names are aligned with their corresponding V8 option names (`--max-heap-size`, `--optimize-for-size`, `--lite-mode`) prefixed with `V8_`. This improves discoverability and self-documentation compared to the previous `GROWI_`-prefixed names.
+
+**Batch Contract**
+- **Trigger**: On container startup (`ENTRYPOINT ["node", "/docker-entrypoint.ts"]`)
+- **Input validation**: V8_MAX_HEAP_SIZE (positive int, empty = unset), V8_OPTIMIZE_FOR_SIZE/V8_LITE_MODE (only `"true"` is valid), cgroup v2 (`memory.max`: numeric or `"max"`), cgroup v1 (`memory.limit_in_bytes`: numeric, large value = unlimited)
+- **Output**: Node flags passed directly as arguments to `child_process.spawn`
+- **Idempotency**: Executed on every restart, safe via `fs.mkdirSync({ recursive: true })`
+
+### README.md
+
+**Responsibilities & Constraints**
+- Docker Hub image documentation (published to hub.docker.com/r/growilabs/growi)
+- Document the V8 memory management environment variables under Configuration > Environment Variables section
+- Include variable name, type, default, and description for each: `V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`
+
+## Error Handling
+
+| Error | Category | Response |
+|-------|----------|----------|
+| cgroup file read failure | System | Warn and continue with no flag (V8 default) |
+| V8_MAX_HEAP_SIZE is invalid | User | Warn and continue with no flag (container still starts) |
+| Directory creation/permission failure | System | `process.exit(1)` — check volume mount configuration |
+| Migration failure | Business Logic | `execFileSync` throws → `process.exit(1)` — Docker/k8s restarts |
+| App process abnormal exit | System | Propagate child process exit code |
+
+## Performance & Scalability
+
+- **Build cache**: `turbo prune --docker` caches the dependency install layer. Skips dependency installation during rebuilds when only source code changes
+- **Image size**: No additional binaries in DHI runtime. Base layer is smaller compared to node:24-slim
+- **Memory efficiency**: Total heap control via `--max-heap-size` avoids the v24 trusted_space overhead issue. Prevents memory pressure in multi-tenant environments

+ 82 - 0
.kiro/specs/official-docker-image/requirements.md

@@ -0,0 +1,82 @@
+# Requirements Document
+
+## Introduction
+
+Modernize and optimize the GROWI official Docker image's Dockerfile (`apps/app/docker/Dockerfile`) and `docker-entrypoint.sh` based on 2025-2026 best practices. Target Node.js 24 and incorporate findings from the memory report (`apps/app/tmp/memory-results/REPORT.md`) to improve memory management.
+
+### Summary of Current State Analysis
+
+**Current Dockerfile structure:**
+- 3-stage structure: `base` → `builder` → `release` (based on node:20-slim)
+- Monorepo build with pnpm + turbo, production dependency extraction via `pnpm deploy`
+- Privilege drop from root to node user using gosu (after directory creation in entrypoint)
+- `COPY . .` copies the entire context into the builder
+- Application starts after running `npm run migrate` in CMD
+
+**GROWI-specific design intentions (items to maintain):**
+- Privilege drop pattern: The entrypoint must create and set permissions for `/data/uploads` and `/tmp/page-bulk-export` with root privileges, then drop to the node user for execution
+- `pnpm deploy --prod`: The official method for extracting only production dependencies from a pnpm monorepo
+- Inter-stage artifact transfer via tar.gz: Cleanly transfers build artifacts to the release stage
+- `apps/app/tmp` directory: Required in the production image as files are placed there during operation
+- `--expose_gc` flag: Required for explicitly calling `gc()` in batch processing (ES rebuild, import, etc.)
+- `npm run migrate` in CMD: Automatically runs migrations at startup for the convenience of Docker image users
+
+**References:**
+- [Future Architect: 2024 Dockerfile Best Practices](https://future-architect.github.io/articles/20240726a/)
+- [Snyk: 10 best practices to containerize Node.js](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/)
+- [ByteScrum: Dockerfile Best Practices 2025](https://blog.bytescrum.com/dockerfile-best-practices-2025-secure-fast-and-modern)
+- [OneUptime: Docker Health Check Best Practices 2026](https://oneuptime.com/blog/post/2026-01-30-docker-health-check-best-practices/view)
+- [Docker: Introduction to heredocs in Dockerfiles](https://www.docker.com/blog/introduction-to-heredocs-in-dockerfiles/)
+- [Docker Hardened Images: Node.js Migration Guide](https://docs.docker.com/dhi/migration/examples/node/)
+- [Docker Hardened Images Catalog: Node.js](https://hub.docker.com/hardened-images/catalog/dhi/node)
+- GROWI Memory Usage Investigation Report (`apps/app/tmp/memory-results/REPORT.md`)
+
+## Requirements
+
+### Requirement 1: Modernize Base Image and Build Environment
+
+**Objective:** As an infrastructure administrator, I want the Dockerfile's base image and syntax to comply with the latest best practices, so that security patch application, performance improvements, and maintainability enhancements are achieved
+
+**Summary**: DHI base images adopted (`dhi.io/node:24-debian13-dev` for build, `dhi.io/node:24-debian13` for release) with up to 95% CVE reduction. Syntax directive updated to auto-follow latest stable. pnpm installed via wget standalone script (corepack not adopted due to planned removal in Node.js 25+). Fixed `---frozen-lockfile` typo and eliminated hardcoded pnpm version.
+
+### Requirement 2: Memory Management Optimization
+
+**Objective:** As a GROWI operator, I want the Node.js heap size to be appropriately controlled according to container memory constraints, so that the risk of OOMKilled is reduced and memory efficiency in multi-tenant environments is improved
+
+**Summary**: 3-tier heap size fallback implemented in docker-entrypoint.ts: (1) `GROWI_HEAP_SIZE` env var, (2) cgroup v2/v1 auto-calculation at 60%, (3) V8 default. Uses `--max-heap-size` (not `--max_old_space_size`) passed as direct spawn arguments (not `NODE_OPTIONS`). Additional flags: `--optimize-for-size` via `GROWI_OPTIMIZE_MEMORY=true`, `--lite-mode` via `GROWI_LITE_MODE=true`.
+
+### Requirement 3: Build Efficiency and Cache Optimization
+
+**Objective:** As a developer, I want Docker builds to be fast and efficient, so that CI/CD pipeline build times are reduced and image size is minimized
+
+**Summary**: `turbo prune --docker` pattern adopted to eliminate `COPY . .` and maximize layer cache (dependency install cached separately from source changes). pnpm store and apt-get cache mounts maintained. `.next/cache` excluded from release stage. Artifact transfer uses `COPY --from=builder` (adapted from design's `--mount=type=bind,from=builder` due to shell-less DHI runtime).
+
+### Requirement 4: Security Hardening
+
+**Objective:** As a security officer, I want the Docker image to comply with security best practices, so that the attack surface is minimized and the safety of the production environment is improved
+
+**Summary**: Non-root execution via Node.js native `process.setuid/setgid` (no gosu/setpriv). Release stage contains no unnecessary packages — no shell, no apt, no build tools. Enhanced `.dockerignore` excludes `.git`, secrets, test files, IDE configs. `--no-install-recommends` used for apt-get in build stage.
+
+### Requirement 5: Operability and Observability Improvement
+
+**Objective:** As an operations engineer, I want the Docker image to have appropriate metadata configured, so that management by container orchestrators is facilitated
+
+**Summary**: OCI standard LABEL annotations added (`org.opencontainers.image.source`, `.title`, `.description`, `.vendor`). `EXPOSE 3000` and `VOLUME /data` maintained.
+
+### Requirement 6: Entrypoint and CMD Refactoring
+
+**Objective:** As a developer, I want the entrypoint script and CMD to have a clear and maintainable structure, so that dynamic assembly of memory flags and future extensions are facilitated
+
+**Summary**: Entrypoint rewritten in TypeScript (`docker-entrypoint.ts`) executed via Node.js 24 native type stripping. Handles: directory setup (`/data/uploads`, `/tmp/page-bulk-export`), heap size calculation (3-tier fallback), privilege drop (`process.setgid` + `process.setuid`), migration execution (`execFileSync`), app process spawn with signal forwarding. Always includes `--expose_gc`. Logs applied flags to stdout.
+
+### Requirement 7: Backward Compatibility
+
+**Objective:** As an existing Docker image user, I want existing operations to not break when migrating to the new Dockerfile, so that the risk during upgrades is minimized
+
+**Summary**: Full backward compatibility maintained. Environment variables (`MONGO_URI`, `FILE_UPLOAD`, etc.), `VOLUME /data`, port 3000, and docker-compose usage patterns all work as before. Without memory management env vars, behavior is equivalent to V8 defaults.
+
+### Requirement 8: Production Replacement and CI/CD Support
+
+**Objective:** As an infrastructure administrator, I want the artifacts in the docker-new directory to officially replace the existing docker directory and the CI/CD pipeline to operate with the new Dockerfile, so that DHI-based images are used in production builds
+
+**Summary**: All files moved from `apps/app/docker-new/` to `apps/app/docker/`, old files deleted. Dockerfile self-referencing path updated. `docker login dhi.io` added to buildspec.yml pre_build phase, reusing existing `DOCKER_REGISTRY_PASSWORD` secret. `codebuild/` directory and `README.md` maintained.

+ 288 - 0
.kiro/specs/official-docker-image/research.md

@@ -0,0 +1,288 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Discovery findings and design decision rationale for the official Docker image modernization.
+---
+
+## Summary
+- **Feature**: `official-docker-image`
+- **Discovery Scope**: Extension (major improvement of existing Dockerfile)
+- **Key Findings**:
+  - The DHI runtime image (`dhi.io/node:24-debian13`) is a minimal configuration that does not include a shell, package manager, or coreutils. By adopting a Node.js entrypoint (TypeScript), a configuration requiring no shell or additional binaries is achieved
+  - `--mount=type=bind` is impractical for monorepo multi-step builds. `turbo prune --docker` is the officially recommended Docker optimization approach by Turborepo
+  - gosu is replaced by Node.js native `process.setuid/setgid`. External binaries (gosu/setpriv/busybox) are completely unnecessary
+  - HEALTHCHECK is not adopted (k8s uses its own probes. Docker Compose users can configure it themselves)
+  - Node.js 24 supports native TypeScript execution (type stripping). The entrypoint can be written in TypeScript
+
+## Research Log
+
+### DHI Runtime Image Configuration
+
+- **Context**: Investigation of constraints when adopting `dhi.io/node:24-debian13` as the base image for the release stage
+- **Sources Consulted**:
+  - [DHI Catalog GitHub](https://github.com/docker-hardened-images/catalog) — `image/node/debian-13/` directory
+  - [DHI Documentation](https://docs.docker.com/dhi/)
+  - [DHI Use an Image](https://docs.docker.com/dhi/how-to/use/)
+- **Findings**:
+  - Pre-installed packages in the runtime image: only `base-files`, `ca-certificates`, `libc6`, `libgomp1`, `libstdc++6`, `netbase`, `tzdata`
+  - **No shell**, **no apt**, **no coreutils**, **no curl/wget**
+  - Default user: `node` (UID 1000, GID 1000)
+  - Dev image (`-dev`): `apt`, `bash`, `git`, `util-linux`, `coreutils`, etc. are pre-installed
+  - Available tags: `dhi.io/node:24-debian13`, `dhi.io/node:24-debian13-dev`
+  - Platforms: `linux/amd64`, `linux/arm64`
+- **Implications**:
+  - By writing the entrypoint in Node.js (TypeScript), neither a shell nor additional binaries are needed at all
+  - gosu/setpriv are replaced by Node.js native `process.setuid/setgid`. No need to copy external binaries
+  - HEALTHCHECK is not adopted (k8s uses its own probes). Health checks via curl/Node.js http module are unnecessary
+
+### Applicability of `--mount=type=bind` in Monorepo Builds
+
+- **Context**: Investigation of the feasibility of Requirement 3.1 "Use `--mount=type=bind` instead of `COPY . .` in the builder stage"
+- **Sources Consulted**:
+  - [Docker Build Cache Optimization](https://docs.docker.com/build/cache/optimize/)
+  - [Dockerfile Reference - RUN --mount](https://docs.docker.com/reference/dockerfile/)
+  - [pnpm Docker Documentation](https://pnpm.io/docker)
+  - [Turborepo Docker Guide](https://turbo.build/repo/docs/handbook/deploying-with-docker)
+- **Findings**:
+  - `--mount=type=bind` is **only valid during the execution of a RUN instruction** and is not carried over to the next RUN instruction
+  - In the multi-step process of monorepo builds (install -> build -> deploy), each step depends on artifacts from the previous step, making it difficult to achieve with bind mounts alone
+  - It is possible to combine all steps into a single RUN, but this loses the benefits of layer caching
+  - **Turborepo official recommendation**: Use `turbo prune --docker` to minimize the monorepo for Docker
+    - `out/json/` — only package.json files needed for dependency install
+    - `out/pnpm-lock.yaml` — lockfile
+    - `out/full/` — source code needed for the build
+  - This approach avoids `COPY . .` while leveraging layer caching
+- **Implications**:
+  - Requirement 3.1 should be achieved using the `turbo prune --docker` pattern instead of `--mount=type=bind`
+  - The goal (minimizing source code layers / improving cache efficiency) can be equally achieved
+  - **However**, compatibility of `turbo prune --docker` with pnpm workspaces needs to be verified during implementation
+
+### Alternatives to gosu
+
+- **Context**: Investigation of alternatives since gosu is not available in the DHI runtime image
+- **Sources Consulted**:
+  - [gosu GitHub](https://github.com/tianon/gosu) — list of alternative tools
+  - [Debian Packages - gosu in trixie](https://packages.debian.org/trixie/admin/gosu)
+  - [PhotoPrism: Switch from gosu to setpriv](https://github.com/photoprism/photoprism/pull/2730)
+  - [MongoDB Docker: Replace gosu by setpriv](https://github.com/docker-library/mongo/pull/714)
+  - Node.js `process.setuid/setgid` documentation
+- **Findings**:
+  - `setpriv` is part of `util-linux` and is pre-installed in the DHI dev image
+  - `gosu node command` can be replaced with `setpriv --reuid=node --regid=node --init-groups -- command`
+  - PhotoPrism and the official MongoDB Docker image have already migrated from gosu to setpriv
+  - **Node.js native**: Can be fully replaced with `process.setgid(1000)` + `process.setuid(1000)` + `process.initgroups('node', 1000)`
+  - When adopting a Node.js entrypoint, no external binaries (gosu/setpriv/busybox) are needed at all
+- **Implications**:
+  - **Final decision**: Adopt Node.js native `process.setuid/setgid` (setpriv is also unnecessary)
+  - No need to copy gosu/setpriv binaries, resulting in no additional binaries in the release stage
+  - Maintains the minimized attack surface of the DHI runtime as-is
+
+### HEALTHCHECK Implementation Approach (Not Adopted)
+
+- **Context**: Investigation of HEALTHCHECK implementation approaches since curl is not available in the DHI runtime image
+- **Sources Consulted**:
+  - [Docker Healthchecks in Distroless Node.js](https://www.mattknight.io/blog/docker-healthchecks-in-distroless-node-js)
+  - [Docker Healthchecks: Why Not to Use curl](https://blog.sixeyed.com/docker-healthchecks-why-not-to-use-curl-or-iwr/)
+  - GROWI healthcheck endpoint: `apps/app/src/server/routes/apiv3/healthcheck.ts`
+- **Findings**:
+  - Node.js `http` module is sufficient (curl is unnecessary)
+  - GROWI's `/_api/v3/healthcheck` endpoint returns `{ status: 'OK' }` without any parameters
+  - Docker HEALTHCHECK is useful for Docker Compose's `depends_on: service_healthy` dependency order control
+  - In k8s environments, custom probes (liveness/readiness) are used, so the Dockerfile's HEALTHCHECK is unnecessary
+- **Implications**:
+  - **Final decision: Not adopted**. k8s uses its own probes, and Docker Compose users can configure it themselves in compose.yaml
+  - By not including HEALTHCHECK in the Dockerfile, simplicity is maintained
+
+### Shell Dependency of npm run migrate
+
+- **Context**: Investigation of whether `npm run migrate` within CMD requires a shell
+- **Sources Consulted**:
+  - GROWI `apps/app/package.json`'s `migrate` script
+- **Findings**:
+  - The actual `migrate` script content: `node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js`
+  - `npm run` internally uses `sh -c`, so a shell is required
+  - Alternative: Running the script contents directly with node eliminates the need for npm/sh
+  - However, using npm run is more maintainable (can track changes in package.json)
+- **Implications**:
+  - **Final decision**: Use `child_process.execFileSync` in the Node.js entrypoint to directly execute the migration command (not using npm run, no shell needed)
+  - Adopt the approach of directly writing the `migrate` script contents within the entrypoint
+  - When package.json changes, the entrypoint also needs to be updated, but priority is given to fully shell-less DHI runtime
+
+### Node.js 24 Native TypeScript Execution
+
+- **Context**: Investigation of whether Node.js 24's native TypeScript execution feature can be used when writing the entrypoint in TypeScript
+- **Sources Consulted**:
+  - [Node.js 23 Release Notes](https://nodejs.org/en/blog/release/v23.0.0) — `--experimental-strip-types` unflagged
+  - [Node.js Type Stripping Documentation](https://nodejs.org/docs/latest/api/typescript.html)
+- **Findings**:
+  - From Node.js 23, type stripping is enabled by default (no `--experimental-strip-types` flag needed)
+  - Available as a stable feature in Node.js 24
+  - **Constraint**: "Non-erasable syntax" such as enum and namespace cannot be used. `--experimental-transform-types` is required for those
+  - interface, type alias, and type annotations (`: string`, `: number`, etc.) can be used without issues
+  - Can be executed directly with `ENTRYPOINT ["node", "docker-entrypoint.ts"]`
+- **Implications**:
+  - The entrypoint can be written in TypeScript, enabling type-safe implementation
+  - Do not use enum; use union types (`type Foo = 'a' | 'b'`) as alternatives
+  - tsconfig.json is not required (type stripping operates independently)
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| DHI runtime + busybox-static | Copy busybox-static to provide sh/coreutils | Minimal addition (~1MB) enables full functionality | Contradicts the original intent of DHI adoption (minimizing attack surface). Additional binaries are attack vectors | Rejected |
+| DHI runtime + bash/coreutils copy | Copy bash and various binaries individually from the dev stage | Full bash functionality available | Shared library dependencies are complex, many files need to be copied | Rejected |
+| DHI dev image as runtime | Use the dev image as-is for production | Minimal configuration changes | Increased attack surface due to apt/git etc., diminishes the meaning of DHI | Rejected |
+| Node.js entrypoint (TypeScript, shell-less) | Write the entrypoint in TypeScript. Runs with Node.js 24's native TypeScript execution | Completely shell-free, maintains DHI runtime's attack surface as-is, type-safe | Migration command written directly (not using npm run), updates needed when package.json changes | **Adopted** |
+
+## Design Decisions
+
+### Decision: Node.js TypeScript Entrypoint (Completely Shell-Free)
+
+- **Context**: The DHI runtime image contains neither a shell nor coreutils. Copying busybox-static contradicts the intent of DHI adoption (minimizing attack surface)
+- **Alternatives Considered**:
+  1. Copy busybox-static to provide shell + coreutils — Contradicts DHI's attack surface minimization
+  2. Copy bash + coreutils individually — Complex dependencies
+  3. Node.js TypeScript entrypoint — Everything can be accomplished with `fs`, `child_process`, and `process.setuid/setgid`
+- **Selected Approach**: Write the entrypoint in TypeScript (`docker-entrypoint.ts`). Execute directly using Node.js 24's native TypeScript execution (type stripping)
+- **Rationale**: No additional binaries needed in the DHI runtime whatsoever. Directory operations via fs module, privilege dropping via process.setuid/setgid, migration via execFileSync, and app startup via spawn. Improved maintainability through type safety
+- **Trade-offs**: Migration command is written directly (not using npm run). When the migrate script in package.json changes, the entrypoint also needs to be updated
+- **Follow-up**: Verify that Node.js 24's type stripping works correctly with a single-file entrypoint without import statements
+
+### Decision: Privilege Dropping via Node.js Native process.setuid/setgid
+
+- **Context**: gosu cannot be installed in the DHI runtime. busybox-static/setpriv are also not adopted (policy of eliminating additional binaries)
+- **Alternatives Considered**:
+  1. Copy gosu binary — Works but goes against industry trends
+  2. Copy setpriv binary — Works but goes against the policy of eliminating additional binaries
+  3. Node.js `process.setuid/setgid` — Standard Node.js API
+  4. Docker `--user` flag — Cannot handle dynamic processing in the entrypoint
+- **Selected Approach**: Drop privileges with `process.initgroups('node', 1000)` + `process.setgid(1000)` + `process.setuid(1000)`
+- **Rationale**: No external binaries needed at all. Can be called directly within the Node.js entrypoint. Safe privilege dropping in the order setgid -> setuid
+- **Trade-offs**: The entrypoint starts as a Node.js process running as root, and the app becomes its child process (not an exec like gosu). However, the app process is separated via spawn, and signal forwarding fulfills PID 1 responsibilities
+- **Follow-up**: None
+
+### Decision: turbo prune --docker Pattern
+
+- **Context**: Requirement 3.1 requires eliminating `COPY . .`, but `--mount=type=bind` is impractical for monorepo builds
+- **Alternatives Considered**:
+  1. `--mount=type=bind` — Does not persist across RUN instructions, unsuitable for multi-step builds
+  2. Combine all steps into a single RUN — Poor cache efficiency
+  3. `turbo prune --docker` — Officially recommended by Turborepo
+- **Selected Approach**: Use `turbo prune --docker` to minimize the monorepo for Docker, using optimized COPY patterns
+- **Rationale**: Officially recommended by Turborepo. Separates dependency install and source copy to maximize layer cache utilization. Eliminates `COPY . .` while remaining practical
+- **Trade-offs**: One additional build stage (pruner stage), but offset by improved cache efficiency
+- **Follow-up**: Verify `turbo prune --docker` compatibility with pnpm workspaces during implementation
+
+### Decision: Flag Injection via spawn Arguments
+
+- **Context**: `--max-heap-size` cannot be used in `NODE_OPTIONS`. It needs to be passed as a direct argument to the node command
+- **Alternatives Considered**:
+  1. Export environment variable `GROWI_NODE_FLAGS` and inject via shell variable expansion in CMD — Requires a shell
+  2. Rewrite CMD string with sed in the entrypoint — Fragile
+  3. Pass directly as arguments to `child_process.spawn` in the Node.js entrypoint — No shell needed
+- **Selected Approach**: Build a flag array within the entrypoint and pass it directly with `spawn(process.execPath, [...nodeFlags, ...appArgs])`
+- **Rationale**: No shell variable expansion needed. Passed directly as an array, resulting in zero risk of shell injection. Natural integration with the Node.js entrypoint
+- **Trade-offs**: CMD becomes unnecessary (the entrypoint handles all startup processing). Overriding the command with docker run does not affect the logic within the entrypoint
+- **Follow-up**: None
+
+### DHI Registry Authentication and CI/CD Integration
+
+- **Context**: Investigation of the authentication method required for pulling DHI base images and how to integrate with the existing CodeBuild pipeline
+- **Sources Consulted**:
+  - [DHI How to Use an Image](https://docs.docker.com/dhi/how-to/use/) — DHI usage instructions
+  - Existing `apps/app/docker/codebuild/buildspec.yml` — Current CodeBuild build definition
+  - Existing `apps/app/docker/codebuild/secretsmanager.tf` — AWS Secrets Manager configuration
+- **Findings**:
+  - DHI uses Docker Hub credentials (DHI is a feature of Docker Business/Team subscriptions)
+  - Authentication is possible with `docker login dhi.io --username <dockerhub-user> --password-stdin`
+  - The existing buildspec.yml is already logged into docker.io with the `DOCKER_REGISTRY_PASSWORD` secret
+  - The same credentials can be used to log into `dhi.io` as well (no additional secrets required)
+  - The flow of CodeBuild's `reusable-app-build-image.yml` -> CodeBuild Project -> buildspec.yml does not need to change
+- **Implications**:
+  - Can be addressed by simply adding one line of `docker login dhi.io` to the pre_build in buildspec.yml
+  - No changes to `secretsmanager.tf` are needed
+  - Login to both Docker Hub and DHI is required (docker.io for push, dhi.io for pull)
+
+### Impact Scope of Directory Replacement (Codebase Investigation)
+
+- **Context**: Confirming that existing references will not break when replacing `apps/app/docker-new/` with `apps/app/docker/`
+- **Sources Consulted**: Grep investigation of the entire codebase with the `apps/app/docker` keyword
+- **Findings**:
+  - `buildspec.yml`: `-f ./apps/app/docker/Dockerfile` — Same path after replacement (no change needed)
+  - `codebuild.tf`: `buildspec = "apps/app/docker/codebuild/buildspec.yml"` — Same (no change needed)
+  - `.github/workflows/release.yml`: `readme-filepath: ./apps/app/docker/README.md` — Same (no change needed)
+  - `.github/workflows/ci-app.yml` / `ci-app-prod.yml`: `!apps/app/docker/**` exclusion pattern — Same (no change needed)
+  - `apps/app/bin/github-actions/update-readme.sh`: `cd docker` + sed — Same (no change needed)
+  - Within Dockerfile: line 122 `apps/app/docker-new/docker-entrypoint.ts` — **Needs updating** (self-referencing path)
+  - `package.json` and `vitest.config` for docker-related references — None
+  - `lefthook.yml` for docker-related hooks — None
+- **Implications**:
+  - Only one location within the Dockerfile (self-referencing path) needs to be updated during replacement
+  - All external references (CI/CD, GitHub Actions) already use the `apps/app/docker/` path and require no changes
+  - The `codebuild/` directory and `README.md` are maintained as-is within `docker/`
+
+### Environment Variable Renaming: GROWI_ prefix → V8_ prefix
+
+- **Context**: The initial implementation used `GROWI_HEAP_SIZE`, `GROWI_OPTIMIZE_MEMORY`, and `GROWI_LITE_MODE` as environment variable names. These names obscure the relationship between the env var and the underlying V8 flag it controls
+- **Motivation**: Align environment variable names with the actual V8 option names they map to, improving discoverability and self-documentation
+- **Mapping**:
+  | Old Name | New Name | V8 Flag |
+  |----------|----------|---------|
+  | `GROWI_HEAP_SIZE` | `V8_MAX_HEAP_SIZE` | `--max-heap-size` |
+  | `GROWI_OPTIMIZE_MEMORY` | `V8_OPTIMIZE_FOR_SIZE` | `--optimize-for-size` |
+  | `GROWI_LITE_MODE` | `V8_LITE_MODE` | `--lite-mode` |
+- **Benefits**:
+  - Users can immediately understand which V8 flag each variable controls
+  - Naming convention is consistent: `V8_` prefix + option name in UPPER_SNAKE_CASE
+  - No need to consult documentation to understand the mapping
+- **Impact scope**:
+  - `docker-entrypoint.ts`: Code changes (env var reads, comments, log messages)
+  - `docker-entrypoint.spec.ts`: Test updates (env var references in test cases)
+  - `README.md`: Add documentation for the new environment variables
+  - `design.md`, `requirements.md`, `tasks.md`: Spec document updates
+- **Breaking change**: Yes — users who have configured `GROWI_HEAP_SIZE`, `GROWI_OPTIMIZE_MEMORY`, or `GROWI_LITE_MODE` in their docker-compose.yml or deployment configs will need to update to the new names. This is acceptable as these variables were introduced in the same release (v7.5.x) and have not been published yet
+- **Implications**: No backward compatibility shim needed since the variables are new in this version
+
+## Risks & Mitigations
+
+- **Stability of Node.js 24 native TypeScript execution**: Type stripping was unflagged in Node.js 23. It is a stable feature in Node.js 24. However, non-erasable syntax such as enum cannot be used -> Use only interface/type
+- **Direct description of migration command**: The `migrate` script from package.json is written directly in the entrypoint, so synchronization is needed when changes occur -> Clearly noted in comments during implementation
+- **turbo prune compatibility with pnpm workspaces**: Verify during implementation. If incompatible, fall back to an optimized COPY pattern
+- **Limitations of process.setuid/setgid**: `process.initgroups` is required for supplementary group initialization. The order setgid -> setuid must be strictly followed
+- **docker login requirement for DHI images**: `docker login dhi.io` is required in CI/CD. Security considerations for credential management are needed
+
+## Production Implementation Discoveries
+
+### DHI Dev Image Minimal Configuration (Phase 1 E2E)
+
+- **Issue**: The DHI dev image (`dhi.io/node:24-debian13-dev`) did not include the `which` command
+- **Resolution**: Changed pnpm installation from `SHELL="$(which sh)"` to `SHELL=/bin/sh`
+- **Impact**: Minor — only affects the pnpm install script invocation
+
+### Complete Absence of Shell in DHI Runtime Image (Phase 1 E2E)
+
+- **Issue**: The DHI runtime image (`dhi.io/node:24-debian13`) did not have `/bin/sh`. The design planned `--mount=type=bind,from=builder` + `RUN tar -zxf`, but `RUN` instructions require `/bin/sh`
+- **Resolution**:
+  - **builder stage**: Changed from `tar -zcf` to `cp -a` into a staging directory `/tmp/release/`
+  - **release stage**: Changed from `RUN --mount=type=bind... tar -zxf` to `COPY --from=builder --chown=node:node`
+- **Impact**: Design Req 3.5 (`--mount=type=bind,from=builder` pattern) was replaced with `COPY --from=builder`. The security goal of not requiring a shell at runtime was achieved even more robustly
+- **Lesson**: DHI runtime images are truly minimal — `COPY`, `WORKDIR`, `ENV`, `LABEL`, `ENTRYPOINT` are processed by the Docker daemon and do not require a shell
+
+### process.initgroups() Type Definition Gap
+
+- **Issue**: `process.initgroups('node', 1000)` was called for in the design, but implementation was deferred because the type definition does not exist in `@types/node`
+- **Status**: Deferred (Known Issue)
+- **Runtime**: `process.initgroups` does exist at runtime in Node.js 24
+- **Workaround options**: Wait for `@types/node` fix, or use `(process as any).initgroups('node', 1000)`
+- **Practical impact**: Low — the node user in a Docker container typically has no supplementary groups
+
+## References
+
+- [Docker Hardened Images Documentation](https://docs.docker.com/dhi/) — Overview and usage of DHI
+- [DHI Catalog GitHub](https://github.com/docker-hardened-images/catalog) — Image definitions and tag list
+- [Turborepo Docker Guide](https://turbo.build/repo/docs/handbook/deploying-with-docker) — turbo prune --docker pattern
+- [pnpm Docker Documentation](https://pnpm.io/docker) — pnpm Docker build recommendations
+- [Future Architect: 2024 Edition Dockerfile Best Practices](https://future-architect.github.io/articles/20240726a/) — Modern Dockerfile syntax
+- [MongoDB Docker: gosu -> setpriv](https://github.com/docker-library/mongo/pull/714) — Precedent for setpriv migration
+- [Docker Healthchecks in Distroless](https://www.mattknight.io/blog/docker-healthchecks-in-distroless-node-js) — Health checks without curl
+- GROWI memory usage investigation report (`apps/app/tmp/memory-results/REPORT.md`) — Basis for heap size control

+ 22 - 0
.kiro/specs/official-docker-image/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "official-docker-image",
+  "created_at": "2026-02-20T00:00:00.000Z",
+  "updated_at": "2026-02-24T15:30:00.000Z",
+  "language": "en",
+  "phase": "tasks-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": false
+    }
+  },
+  "ready_for_implementation": true
+}

+ 193 - 0
.kiro/specs/official-docker-image/tasks.md

@@ -0,0 +1,193 @@
+# Implementation Plan
+
+> **Task ordering design policy**:
+> - **Phase 1 (this phase)**: Reproduce an image with the same specifications as the current one using a DHI base image + TypeScript entrypoint. The build pipeline (3-stage structure using `COPY . .`) is kept as-is, **prioritizing a safe runtime migration**.
+> - **Phase 2 (next phase)**: Introduction of build optimization via the `turbo prune --docker` pattern. This will be done after runtime is stable in Phase 1. Adding pruner/deps stages to create a 5-stage structure.
+>
+> **Implementation directory**: Create new files in `apps/app/docker-new/`. The existing `apps/app/docker/` will not be modified at all. Maintain a state where parallel comparison and verification is possible.
+>
+> Directory permission handling is implemented and tested as the highest priority to detect regressions early. Since the entrypoint (TypeScript) and Dockerfile are independent files, some tasks can be executed in parallel.
+
+## Phase 1: DHI + TypeScript entrypoint (maintaining current build pattern)
+
+- [x] 1. (P) Strengthen build context filter
+  - Add `.git`, `.env*` (except production), test files, IDE configuration files, etc. to the current exclusion rules
+  - Verify that security-sensitive files (secrets, credentials) are not included in the context
+  - Maintain the current exclusion rules (`node_modules`, `.next`, `.turbo`, `apps/slackbot-proxy`, etc.)
+  - _Requirements: 4.3_
+
+- [x] 2. TypeScript entrypoint directory initialization and permission management
+- [x] 2.1 (P) Create entrypoint skeleton and recursive chown helper
+  - Create a new TypeScript file that can be directly executed with Node.js 24 type stripping (no enums, erasable syntax only)
+  - Structure the main execution flow as a `main()` function with top-level try-catch for error handling
+  - Implement a helper function that recursively changes ownership of files and subdirectories within a directory
+  - Create unit tests for the helper function (verify recursive behavior with nested directory structures)
+  - _Requirements: 6.8_
+
+- [x] 2.2 Implement directory initialization processing
+  - Implement creation of `/data/uploads`, symlink creation to `./public/uploads`, and recursive ownership change
+  - Implement creation of `/tmp/page-bulk-export`, recursive ownership change, and permission 700 setting
+  - Ensure idempotency (`recursive: true` for mkdir, prevent duplicate symlink creation)
+  - Create unit tests that **guarantee the same behavior as the current `docker-entrypoint.sh`** (using fs mocks, verifying each state of directories, symlinks, ownership, and permissions)
+  - Verify that the process exits (exit code 1) on failure (e.g., volume mount not configured)
+  - _Requirements: 6.3, 6.4_
+
+- [x] 2.3 Implement privilege dropping
+  - Implement demotion from root to node user (UID 1000, GID 1000)
+  - Initialize supplementary groups, strictly following the order of setgid then setuid (reverse order causes setgid to fail)
+  - Output an error message and exit the process on privilege drop failure
+  - _Requirements: 4.1, 6.2_
+
+- [x] 3. Heap size calculation and node flag assembly
+- [x] 3.1 (P) Implement cgroup memory limit detection
+  - Implement reading and numeric parsing of cgroup v2 files (treat the `"max"` string as unlimited)
+  - Implement fallback to cgroup v1 files (treat values exceeding 64GB as unlimited)
+  - Calculate 60% of the memory limit as the heap size (in MB)
+  - On file read failure, output a warning log and continue without flags (V8 default)
+  - Create unit tests for each pattern (v2 normal detection, v2 unlimited, v1 fallback, v1 unlimited, detection unavailable)
+  - _Requirements: 2.2, 2.3_
+
+- [x] 3.2 (P) Implement heap size specification via environment variable
+  - Implement parsing and validation of the `GROWI_HEAP_SIZE` environment variable (positive integer, in MB)
+  - On invalid values (NaN, negative numbers, empty string), output a warning log and fall back to no flags
+  - Confirm via tests that the environment variable takes priority over cgroup auto-calculation
+  - _Requirements: 2.1_
+
+- [x] 3.3 Implement node flag assembly and log output
+  - Implement the 3-tier fallback integration logic (environment variable -> cgroup calculation -> V8 default)
+  - Always include the `--expose_gc` flag
+  - Add `--optimize-for-size` when `GROWI_OPTIMIZE_MEMORY=true`, and `--lite-mode` when `GROWI_LITE_MODE=true`
+  - Pass `--max-heap-size` directly as a spawn argument (do not use `--max_old_space_size`, do not include in `NODE_OPTIONS`)
+  - Log the applied flags to stdout (including which tier determined the value)
+  - Create unit tests for each combination of environment variables (all unset, HEAP_SIZE only, all enabled, etc.)
+  - _Requirements: 2.4, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
+
+- [x] 4. Migration execution and app process management
+- [x] 4.1 Direct migration execution
+  - Execute migrate-mongo by directly calling the node binary (do not use npm run, do not go through a shell)
+  - Inherit stdio to display migration logs
+  - On migration failure, catch the exception and exit the process, prompting restart by the container orchestrator
+  - _Requirements: 6.5_
+
+- [x] 4.2 App process startup and signal management
+  - Start the application as a child process with the calculated node flags included in the arguments
+  - Forward SIGTERM, SIGINT, and SIGHUP to the child process
+  - Propagate the child process exit code (or signal) as the entrypoint exit code
+  - Create tests to verify PID 1 responsibilities (signal forwarding, child process reaping, graceful shutdown)
+  - _Requirements: 6.2, 6.5_
+
+- [x] 5. Dockerfile reconstruction (current 3-stage pattern + DHI)
+- [x] 5.1 (P) Build the base stage
+  - Set the DHI dev image as the base and update the syntax directive to auto-follow the latest stable version
+  - Install pnpm via wget standalone script (eliminate hardcoded versions)
+  - Install turbo globally
+  - Install packages required for building with `--no-install-recommends` and apply apt cache mounts
+  - _Requirements: 1.1, 1.2, 1.3, 1.5, 3.3, 4.4_
+
+- [x] 5.2 Build the builder stage
+  - Maintain the current `COPY . .` pattern to copy the entire monorepo, then install dependencies, build, and extract production dependencies
+  - Fix the `--frozen-lockfile` typo (3 dashes -> 2 dashes)
+  - Configure pnpm store cache mounts to reduce rebuild time
+  - Extract only production dependencies and package them into tar.gz (including the `apps/app/tmp` directory)
+  - Guarantee that `.next/cache` is not included in the artifact
+  - _Requirements: 1.4, 3.2, 3.4_
+
+- [x] 5.3 Build the release stage
+  - Set the DHI runtime image as the base with no additional binary copying
+  - Extract build stage artifacts via bind mount
+  - COPY the TypeScript entrypoint file and set ENTRYPOINT to direct execution via node
+  - Verify that build tools (turbo, pnpm, node-gyp, etc.) and build packages (wget, curl, etc.) are not included in the release stage
+  - _Requirements: 1.1, 3.5, 4.2, 4.5_
+
+- [x] 5.4 (P) Configure OCI labels and port/volume declarations
+  - Set OCI standard labels (source, title, description, vendor)
+  - Maintain `EXPOSE 3000` and `VOLUME /data`
+  - _Requirements: 5.1, 5.2, 5.3_
+
+- [x] 6. Integration verification and backward compatibility confirmation
+- [x] 6.1 Docker build E2E verification
+  - Execute a Docker build where all 3 stages complete successfully and confirm there are no build errors
+  - Verify that the release image does not contain a shell, apt, or build tools
+  - _Requirements: 1.1, 4.2, 4.5_
+
+- [x] 6.2 Runtime behavior and backward compatibility verification
+  - Verify that environment variables (`MONGO_URI`, `FILE_UPLOAD`, etc.) are transparently passed to the application as before
+  - Verify compatibility with `/data` volume mounts and file upload functionality
+  - Verify listening on port 3000
+  - Verify that V8 default behavior is used when memory management environment variables are not set
+  - Verify startup with `docker compose up` and graceful shutdown via SIGTERM
+  - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
+
+## Phase 2: turbo prune --docker build optimization
+
+> To be done after runtime is stable in Phase 1. Migrate from the current `COPY . .` + 3-stage structure to a `turbo prune --docker` + 5-stage structure to improve build cache efficiency.
+
+- [x] 7. Introduction of turbo prune --docker pattern
+- [x] 7.1 Create pruner stage
+  - Add a pruner stage immediately after the base stage, minimizing the monorepo for Docker with `turbo prune @growi/app @growi/pdf-converter --docker`
+  - Reason for including `@growi/pdf-converter`: `@growi/pdf-converter-client/turbo.json` has a task dependency on `@growi/pdf-converter#gen:swagger-spec`, so turbo cannot resolve task dependencies unless it is included in the pruned workspace
+  - Verified compatibility with pnpm workspace (18 packages are correctly output)
+  - Confirmed that the output (json directory, lockfile, full directory) is generated correctly
+  - _Requirements: 3.1_
+
+- [x] 7.2 Separate deps stage and restructure builder
+  - Separate dependency installation from the builder stage into an independent deps stage
+  - Copy only the package.json files and lockfile from pruner output to install dependencies (layer cache optimization)
+  - Change the builder stage to a structure that uses deps as a base and only copies source code and builds
+  - Verify that the dependency installation layer is cached when there are no dependency changes and only source code changes
+  - _Requirements: 3.1, 3.2_
+
+- [x] 7.3 Integration verification of 5-stage structure
+  - Confirm that all 5 stages (base -> pruner -> deps -> builder -> release) complete successfully
+  - Confirm that the same runtime behavior as the Phase 1 3-stage structure is maintained
+  - Verify improvement in build cache efficiency (dependency installation is skipped when only source code changes)
+  - _Requirements: 3.1, 3.2, 3.4_
+
+## Phase 3: Production replacement and CI/CD support
+
+> To be done after the 5-stage structure is stable in Phase 2. Move the artifacts from `apps/app/docker-new/` to `apps/app/docker/`, delete the old files, and update the CI/CD pipeline for DHI support.
+
+- [x] 8. Production replacement and CI/CD support
+- [x] 8.1 (P) Replace docker-new directory with docker directory
+  - Delete old files in `apps/app/docker/` (old `Dockerfile`, `docker-entrypoint.sh`, old `Dockerfile.dockerignore`)
+  - Move all files in `apps/app/docker-new/` (`Dockerfile`, `docker-entrypoint.ts`, `docker-entrypoint.spec.ts`, `Dockerfile.dockerignore`) to `apps/app/docker/`
+  - Delete the `apps/app/docker-new/` directory
+  - Confirm that the `codebuild/` directory and `README.md` are maintained within `apps/app/docker/`
+  - Update the entrypoint copy path in the Dockerfile (from `apps/app/docker-new/docker-entrypoint.ts` to `apps/app/docker/docker-entrypoint.ts`)
+  - _Requirements: 8.1, 8.2_
+
+- [x] 8.2 (P) Add DHI registry login to buildspec.yml
+  - Add a `docker login dhi.io` command to the pre_build phase of `apps/app/docker/codebuild/buildspec.yml`
+  - DHI uses Docker Hub credentials, so reuse the existing `DOCKER_REGISTRY_PASSWORD` secret and `growimoogle` username
+  - Confirm that the Dockerfile path in buildspec.yml (`./apps/app/docker/Dockerfile`) is correct after replacement
+  - _Requirements: 8.3, 8.4_
+
+- [x] 8.3 Integration verification after replacement
+  - Confirm that Docker build completes successfully with the replaced `apps/app/docker/Dockerfile`
+  - Confirm that existing external references (`codebuild.tf`, `.github/workflows/release.yml`, `ci-app.yml`, `update-readme.sh`) work correctly
+  - _Requirements: 8.1, 8.2, 8.3, 8.4_
+
+## Phase 4: Environment variable renaming and README documentation
+
+> Rename the `GROWI_`-prefixed memory management environment variables to `V8_`-prefixed names aligned with V8 option names, and add documentation to the Docker Hub README.
+
+- [x] 9. Rename environment variables to align with V8 option names
+- [x] 9.1 (P) Rename all GROWI_-prefixed environment variables to V8_-prefixed names in the entrypoint
+  - Rename `GROWI_HEAP_SIZE` to `V8_MAX_HEAP_SIZE` in the heap size detection function, validation logic, and error messages
+  - Rename `GROWI_OPTIMIZE_MEMORY` to `V8_OPTIMIZE_FOR_SIZE` in the node flag assembly function
+  - Rename `GROWI_LITE_MODE` to `V8_LITE_MODE` in the node flag assembly function
+  - Update the heap size source log message to reflect the new variable name
+  - Update the file header comment documenting the heap size detection fallback chain
+  - _Requirements: 2.1, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
+
+- [x] 9.2 (P) Update all environment variable references in entrypoint unit tests
+  - Update heap size detection tests: replace all `GROWI_HEAP_SIZE` references with `V8_MAX_HEAP_SIZE`
+  - Update node flag assembly tests: replace `GROWI_OPTIMIZE_MEMORY` with `V8_OPTIMIZE_FOR_SIZE` and `GROWI_LITE_MODE` with `V8_LITE_MODE`
+  - Verify all tests pass with the new environment variable names
+  - _Requirements: 2.1, 2.5, 2.6_
+
+- [x] 10. Add V8 memory management environment variable documentation to README
+  - Add a subsection under Configuration > Environment Variables documenting the three V8 memory management variables
+  - Include variable name, type, default value, and description for each: `V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`
+  - Describe the 3-tier heap size fallback behavior (env var → cgroup auto-calculation → V8 default)
+  - _Requirements: 5.1_

+ 284 - 0
.kiro/specs/presentation/design.md

@@ -0,0 +1,284 @@
+# Design Document: presentation
+
+## Overview
+
+**Purpose**: This feature decouples heavy Marp rendering dependencies (`@marp-team/marp-core`, `@marp-team/marpit`) from the common slide rendering path, so they are loaded only when a page explicitly uses `marp: true` frontmatter.
+
+**Users**: All GROWI users benefit from reduced JavaScript payload when viewing slide pages that do not use Marp. Developers benefit from clearer module boundaries.
+
+**Impact**: Changes the `@growi/presentation` package's internal import structure. No external API or behavioral changes.
+
+### Goals
+- Eliminate `@marp-team/marp-core` and `@marp-team/marpit` from the GrowiSlides module graph
+- Load MarpSlides and its Marp dependencies only on demand via dynamic import
+- Maintain identical rendering behavior for all slide types
+
+### Non-Goals
+- Optimizing the `useSlidesByFrontmatter` hook (already lightweight with internal dynamic imports)
+- Changing the app-level lazy-loading architecture (`useLazyLoader`, `next/dynamic` for the modal)
+- Modifying the Marp rendering logic or configuration itself
+- Reducing the size of `@marp-team/marp-core` internals
+
+## Architecture
+
+### Existing Architecture Analysis
+
+Current module dependency graph for `Slides.tsx`:
+
+```mermaid
+graph TD
+  Slides[Slides.tsx]
+  MarpSlides[MarpSlides.tsx]
+  GrowiSlides[GrowiSlides.tsx]
+  GrowiMarpit[growi-marpit.ts]
+  MarpCore[marp-team/marp-core]
+  Marpit[marp-team/marpit]
+
+  Slides --> MarpSlides
+  Slides --> GrowiSlides
+  MarpSlides --> GrowiMarpit
+  GrowiSlides --> GrowiMarpit
+  GrowiMarpit --> MarpCore
+  GrowiMarpit --> Marpit
+```
+
+**Problem**: Both `MarpSlides` and `GrowiSlides` statically import `growi-marpit.ts`, which instantiates Marp at module scope. This forces ~896KB of Marp modules to load even when rendering non-Marp slides.
+
+**Constraints**:
+- `growi-marpit.ts` exports both runtime Marp instances and a simple string constant (`MARP_CONTAINER_CLASS_NAME`)
+- `GrowiSlides` uses Marp only for CSS extraction (`marpit.render('')`), not for content rendering
+- The package uses Vite with `preserveModules: true` and `nodeExternals({ devDeps: true })`
+
+### Architecture Pattern & Boundary Map
+
+Target module dependency graph:
+
+```mermaid
+graph TD
+  Slides[Slides.tsx]
+  MarpSlides[MarpSlides.tsx]
+  GrowiSlides[GrowiSlides.tsx]
+  GrowiMarpit[growi-marpit.ts]
+  MarpCore[marp-team/marp-core]
+  Marpit[marp-team/marpit]
+  Consts[consts/index.ts]
+  BaseCss[consts/marpit-base-css.ts]
+
+  Slides -.->|React.lazy| MarpSlides
+  Slides --> GrowiSlides
+  MarpSlides --> GrowiMarpit
+  GrowiMarpit --> MarpCore
+  GrowiMarpit --> Marpit
+  GrowiMarpit --> Consts
+  GrowiSlides --> Consts
+  GrowiSlides --> BaseCss
+```
+
+**Key changes**:
+- Dashed arrow = dynamic import (React.lazy), solid arrow = static import
+- `GrowiSlides` no longer imports `growi-marpit.ts`; uses pre-extracted CSS from `marpit-base-css.ts`
+- `MARP_CONTAINER_CLASS_NAME` moves to shared `consts/index.ts`
+- Marp modules are only reachable through the dynamic `MarpSlides` path
+
+**Steering compliance**:
+- Follows "subpath imports over barrel imports" principle
+- Aligns with `next/dynamic({ ssr: false })` technique from build-optimization skill
+- Preserves existing lazy-loading boundaries at app level
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | React 18 (`React.lazy`, `Suspense`) | Dynamic import boundary for MarpSlides | Standard React code-splitting; SSR not a concern (see research.md) |
+| Build | Vite (existing) | Package compilation with `preserveModules` | Dynamic `import()` preserved in output |
+| Build script | Node.js ESM (.mjs) | CSS extraction at build time | No additional tooling dependency |
+
+## System Flows
+
+### Slide Rendering Decision Flow
+
+```mermaid
+flowchart TD
+  A[Slides component receives props] --> B{hasMarpFlag?}
+  B -->|true| C[React.lazy loads MarpSlides chunk]
+  C --> D[MarpSlides renders via marp-core]
+  B -->|false| E[GrowiSlides renders with pre-extracted CSS]
+  E --> F[No Marp modules loaded]
+```
+
+### Build-Time CSS Extraction Flow
+
+```mermaid
+flowchart TD
+  A[pnpm run build] --> B[pre:build:src script runs]
+  B --> C[extract-marpit-css.mjs executes]
+  C --> D[Instantiate Marp with config]
+  D --> E[Call slideMarpit.render and presentationMarpit.render]
+  E --> F[Write marpit-base-css.ts with CSS constants]
+  F --> G[vite build compiles all sources]
+```
+
+## Components and Interfaces
+
+| Component | Domain | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|--------|--------|--------------|------------------|-----------|
+| Slides | UI / Routing | Route between MarpSlides and GrowiSlides based on hasMarpFlag | 2.1, 2.2, 2.3 | MarpSlides (P1, dynamic), GrowiSlides (P0, static) | — |
+| GrowiSlides | UI / Rendering | Render non-Marp slides with pre-extracted CSS | 1.1, 1.2, 1.3, 1.4 | consts (P0), marpit-base-css (P0) | — |
+| marpit-base-css | Constants | Provide pre-extracted Marp theme CSS | 1.3, 3.3 | — | State |
+| consts/index.ts | Constants | Shared constants including MARP_CONTAINER_CLASS_NAME | 1.4 | — | — |
+| growi-marpit.ts | Service | Marp engine setup (unchanged, now only reached via MarpSlides) | 4.1, 4.3 | marp-core (P0, external), marpit (P0, external) | Service |
+| extract-marpit-css.mjs | Build Script | Generate marpit-base-css.ts at build time | 3.1, 3.2 | marp-core (P0, external), marpit (P0, external) | — |
+
+### UI / Routing Layer
+
+#### Slides
+
+| Field | Detail |
+|-------|--------|
+| Intent | Route rendering to MarpSlides (dynamic) or GrowiSlides (static) based on `hasMarpFlag` prop |
+| Requirements | 2.1, 2.2, 2.3 |
+
+**Responsibilities & Constraints**
+- Conditionally render MarpSlides or GrowiSlides based on `hasMarpFlag`
+- Wrap MarpSlides in `<Suspense>` with a loading fallback
+- Must not statically import MarpSlides
+
+**Dependencies**
+- Outbound: MarpSlides — dynamic import for Marp rendering (P1)
+- Outbound: GrowiSlides — static import for non-Marp rendering (P0)
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+// Updated Slides component signature (unchanged externally)
+type SlidesProps = {
+  options: PresentationOptions;
+  children?: string;
+  hasMarpFlag?: boolean;
+  presentation?: boolean;
+};
+
+// Internal: MarpSlides loaded via React.lazy
+// const MarpSlides = lazy(() => import('./MarpSlides').then(mod => ({ default: mod.MarpSlides })))
+```
+
+- Persistence: None (stateless component)
+- Concurrency: Single render path per props
+
+**Implementation Notes**
+- Integration: Replace static `import { MarpSlides }` with `React.lazy` dynamic import
+- Validation: `hasMarpFlag` is optional boolean; undefined/false → GrowiSlides path
+- Risks: Suspense fallback visible during first MarpSlides load (mitigated by parent-level loading indicators)
+
+### UI / Rendering Layer
+
+#### GrowiSlides
+
+| Field | Detail |
+|-------|--------|
+| Intent | Render non-Marp slides using pre-extracted CSS instead of runtime Marp |
+| Requirements | 1.1, 1.2, 1.3, 1.4 |
+
+**Responsibilities & Constraints**
+- Render slide content via ReactMarkdown with section extraction
+- Apply Marp container CSS from pre-extracted constants (no runtime Marp dependency)
+- Use `MARP_CONTAINER_CLASS_NAME` from shared constants module
+
+**Dependencies**
+- Inbound: Slides — rendering delegation (P0)
+- Outbound: consts/index.ts — `MARP_CONTAINER_CLASS_NAME` (P0)
+- Outbound: consts/marpit-base-css — `SLIDE_MARPIT_CSS`, `PRESENTATION_MARPIT_CSS` (P0)
+
+**Implementation Notes**
+- Integration: Replace `import { ... } from '../services/growi-marpit'` with imports from `../consts` and `../consts/marpit-base-css`
+- Replace `const { css } = marpit.render('')` with `const css = presentation ? PRESENTATION_MARPIT_CSS : SLIDE_MARPIT_CSS`
+
+### Constants Layer
+
+#### marpit-base-css
+
+| Field | Detail |
+|-------|--------|
+| Intent | Provide pre-extracted Marp theme CSS as string constants |
+| Requirements | 1.3, 3.3 |
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+// Generated file — do not edit manually
+// Regenerate with: node scripts/extract-marpit-css.mjs
+
+export const SLIDE_MARPIT_CSS: string;
+export const PRESENTATION_MARPIT_CSS: string;
+```
+
+- Persistence: Committed to repository; regenerated at build time
+- Consistency: Deterministic output from Marp config; changes only on `@marp-team/marp-core` version update
+
+#### consts/index.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | Shared constants for presentation package |
+| Requirements | 1.4 |
+
+**Implementation Notes**
+- Add `export const MARP_CONTAINER_CLASS_NAME = 'marpit'` (moved from `growi-marpit.ts`)
+- Existing `PresentationOptions` type export unchanged
+
+### Service Layer
+
+#### growi-marpit.ts
+
+| Field | Detail |
+|-------|--------|
+| Intent | Marp engine setup and instance creation (unchanged behavior, now only reachable via MarpSlides dynamic path) |
+| Requirements | 4.1, 4.3 |
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+// Existing exports (unchanged)
+export const MARP_CONTAINER_CLASS_NAME: string; // Re-exported from consts for backward compat
+export const slideMarpit: Marp;
+export const presentationMarpit: Marp;
+```
+
+- Preconditions: `@marp-team/marp-core` and `@marp-team/marpit` available
+- Postconditions: Marp instances configured with GROWI options
+- Invariants: Instances are singletons created at module scope
+
+**Implementation Notes**
+- Import `MARP_CONTAINER_CLASS_NAME` from `../consts` instead of defining locally
+- Re-export for backward compatibility (MarpSlides still imports from here)
+
+### Build Script
+
+#### extract-marpit-css.mjs
+
+| Field | Detail |
+|-------|--------|
+| Intent | Generate `marpit-base-css.ts` by running Marp with the same configuration as `growi-marpit.ts` |
+| Requirements | 3.1, 3.2 |
+
+**Responsibilities & Constraints**
+- Replicate the Marp configuration from `growi-marpit.ts` (container classes, inlineSVG, etc.)
+- Generate `slideMarpit.render('')` and `presentationMarpit.render('')` CSS output
+- Write TypeScript file with exported string constants
+- Must run in Node.js ESM without additional tooling (no `tsx`, no `ts-node`)
+
+**Dependencies**
+- External: `@marp-team/marp-core` — Marp rendering engine (P0)
+- External: `@marp-team/marpit` — Element class for container config (P0)
+
+**Implementation Notes**
+- Integration: Added as `"pre:build:src"` script in `package.json`; runs before `vite build`
+- Validation: Script exits with error if CSS extraction produces empty output
+- Risks: Marp options must stay synchronized with `growi-marpit.ts`
+

+ 26 - 0
.kiro/specs/presentation/requirements.md

@@ -0,0 +1,26 @@
+# Presentation Feature — Requirements Overview
+
+## Introduction
+
+The GROWI presentation feature (`@growi/presentation` package) provides slide rendering for wiki pages using frontmatter flags. It supports two rendering modes:
+
+- **GrowiSlides** (`slide: true`): Lightweight slide rendering using ReactMarkdown with Marp container styling applied via pre-extracted CSS constants. Does not load Marp runtime dependencies.
+- **MarpSlides** (`marp: true`): Full Marp-powered slide rendering using `@marp-team/marp-core`, loaded dynamically only when needed.
+
+## Key Requirements
+
+### 1. Module Separation
+
+GrowiSlides renders without loading `@marp-team/marp-core` or `@marp-team/marpit`. Marp dependencies are isolated behind a dynamic import boundary (`React.lazy`) and only loaded for pages with `marp: true`.
+
+### 2. Build-Time CSS Extraction
+
+Marp base CSS is pre-extracted at build time via `extract-marpit-css.mjs`. The generated constants file (`consts/marpit-base-css.ts`) is committed to the repository so that dev mode works without running the extraction script first. The extraction runs automatically before source compilation via the `pre:build:src` script.
+
+### 3. Functional Equivalence
+
+Both Marp and non-Marp slide pages render correctly in inline view and presentation modal. No behavioral differences from the user's perspective.
+
+### 4. Build Integrity
+
+Both `@growi/presentation` and `@growi/app` build successfully. The GrowiSlides build output contains no Marp module references, and the Slides build output contains a dynamic `import()` for MarpSlides.

+ 84 - 0
.kiro/specs/presentation/research.md

@@ -0,0 +1,84 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `optimize-presentation`
+- **Discovery Scope**: Extension
+- **Key Findings**:
+  - `GrowiSlides` imports `growi-marpit.ts` only for CSS extraction (`marpit.render('')`) and a string constant — no Marp rendering is performed
+  - `growi-marpit.ts` instantiates `new Marp(...)` at module scope, pulling in `@marp-team/marp-core` (~524KB) and `@marp-team/marpit` (~372KB) unconditionally
+  - All consumers of `Slides.tsx` are already behind SSR-disabled dynamic boundaries (`next/dynamic`, `useLazyLoader`), making `React.lazy` safe to use within the package
+
+## Research Log
+
+### GrowiSlides dependency on growi-marpit.ts
+- **Context**: Investigating why GrowiSlides requires Marp at all
+- **Sources Consulted**: `packages/presentation/src/client/components/GrowiSlides.tsx`, `packages/presentation/src/client/services/growi-marpit.ts`
+- **Findings**:
+  - `GrowiSlides` imports `MARP_CONTAINER_CLASS_NAME` (string `'marpit'`), `presentationMarpit`, `slideMarpit`
+  - Usage: `const { css } = marpit.render('');` — renders empty string to extract base theme CSS
+  - The CSS output is deterministic (same Marp config → same CSS every time)
+  - `MARP_CONTAINER_CLASS_NAME` is a plain string constant co-located with heavy Marp code
+- **Implications**: GrowiSlides can be fully decoupled by pre-extracting the CSS and moving the constant
+
+### Vite build with preserveModules and React.lazy
+- **Context**: Verifying that React.lazy dynamic import works correctly in the package's Vite build output
+- **Sources Consulted**: `packages/presentation/vite.config.ts`
+- **Findings**:
+  - Build uses `preserveModules: true` with `preserveModulesRoot: 'src'` — each source file → separate output module
+  - `nodeExternals({ devDeps: true })` externalizes `@marp-team/marp-core` and `@marp-team/marpit`
+  - Dynamic `import('./MarpSlides')` in source will produce dynamic `import('./MarpSlides.js')` in output
+  - Next.js (app bundler) handles code-splitting of the externalized Marp packages into async chunks
+- **Implications**: React.lazy in Slides.tsx will produce the correct dynamic import boundary in the build output
+
+### SSR safety of React.lazy
+- **Context**: React.lazy does not support SSR in React 18; need to verify all render paths are client-only
+- **Sources Consulted**: Consumer components in apps/app
+- **Findings**:
+  - `PagePresentationModal`: `useLazyLoader` → client-only
+  - `Presentation` wrapper: `next/dynamic({ ssr: false })`
+  - `SlideRenderer`: `next/dynamic({ ssr: false })` in PageView.tsx
+  - No SSR path exists for `Slides.tsx`
+- **Implications**: React.lazy is safe; no need for `next/dynamic` in the shared package
+
+### CSS extraction approach
+- **Context**: Evaluating how to pre-extract the Marp base CSS
+- **Sources Consulted**: `growi-marpit.ts`, package.json scripts, existing `build:vendor-styles` pattern
+- **Findings**:
+  - `slideMarpit.render('')` and `presentationMarpit.render('')` produce deterministic CSS strings
+  - CSS only changes when `@marp-team/marp-core` is upgraded or Marp options change
+  - The package already has a `build:vendor-styles` script pattern for pre-building CSS assets
+  - `tsx` is not available in the workspace; extraction script must use plain Node.js (.mjs)
+- **Implications**: An .mjs extraction script with dynamic imports from the built package or direct Marp usage is the simplest approach
+
+## Design Decisions
+
+### Decision: Pre-extracted CSS constants vs runtime generation
+- **Context**: GrowiSlides needs Marp theme CSS but should not load Marp runtime
+- **Alternatives Considered**:
+  1. Dynamic import of growi-marpit in GrowiSlides — adds async complexity for a static value
+  2. Pre-extract CSS at build time as string constants — zero runtime cost
+  3. CSS file extracted to dist — requires additional CSS import handling
+- **Selected Approach**: Pre-extract CSS as TypeScript string constants in `consts/marpit-base-css.ts`
+- **Rationale**: The CSS is deterministic; generating it at build time eliminates runtime overhead entirely. TypeScript constants integrate seamlessly with existing import patterns.
+- **Trade-offs**: Requires regeneration on Marp version upgrade (mitigated by `pre:build:src` script)
+- **Follow-up**: Verify CSS output matches runtime generation; commit generated file for dev mode
+
+### Decision: React.lazy vs next/dynamic for MarpSlides
+- **Context**: Need dynamic import boundary for MarpSlides within the shared `@growi/presentation` package
+- **Alternatives Considered**:
+  1. `next/dynamic` — Next.js-specific, couples package to framework
+  2. `React.lazy + Suspense` — standard React, works with any bundler
+- **Selected Approach**: `React.lazy + Suspense`
+- **Rationale**: Although the package already uses `next/head`, `React.lazy` is the standard React pattern for code-splitting and avoids further Next.js coupling. All consumer paths already disable SSR, so React.lazy's SSR limitation is irrelevant.
+- **Trade-offs**: Requires `<Suspense>` wrapper; fallback UI is visible during chunk load
+- **Follow-up**: Verify chunk splitting in Next.js production build
+
+### Decision: Shared constants module for MARP_CONTAINER_CLASS_NAME
+- **Context**: `MARP_CONTAINER_CLASS_NAME` is defined in `growi-marpit.ts` but needed by GrowiSlides without Marp dependency
+- **Selected Approach**: Move to `consts/index.ts` (existing shared constants module)
+- **Rationale**: The constant has no dependency on Marp; co-locating it with Marp code creates an unnecessary transitive import
+
+## Risks & Mitigations
+- **CSS drift on Marp upgrade**: Pre-extracted CSS may become stale → `pre:build:src` script auto-regenerates before every build
+- **Suspense flash on Marp load**: Brief loading indicator when MarpSlides loads → Masked by parent `next/dynamic` loading spinner in most paths
+- **Build script compatibility**: `.mjs` script must work in CI and local dev → Use standard Node.js ESM with no external tooling dependencies

+ 23 - 0
.kiro/specs/presentation/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "presentation",
+  "created_at": "2026-03-05T12:00:00Z",
+  "updated_at": "2026-03-23T00:00:00Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "cleanup_completed": true,
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 359 - 0
.kiro/specs/suggest-path/design.md

@@ -0,0 +1,359 @@
+# Design Document
+
+## Overview
+
+**Purpose**: AI-powered path suggestion API that helps AI clients (e.g., Claude via MCP) determine optimal save locations for page content in GROWI. The system analyzes content, searches for related pages, evaluates candidates, and returns directory path suggestions with metadata.
+
+**Users**: AI clients (Claude via MCP) call this endpoint on behalf of GROWI users during the "save to GROWI" workflow.
+
+### Goals
+
+- Single POST endpoint returning path suggestions with metadata (type, path, label, description, grant)
+- Memo path: guaranteed fallback with fixed metadata
+- Search-based suggestions: AI-powered with flow/stock classification, multi-candidate evaluation, and intelligent path proposals (including new paths)
+- Independent access control via separate `ai-tools` namespace from `/page`
+
+### Design Principles
+
+- **Client LLM independence**: Heavy reasoning (content analysis, candidate evaluation, path proposal, description generation) is centralized in GROWI AI on the server side. The API response includes structured data fields (`informationType`, `type`, `grant`) alongside natural language (`description`) so that even less capable LLM clients can make correct decisions.
+
+### Non-Goals
+
+- Page creation/saving (existing `POST /_api/v3/page` handles this)
+- Page title suggestion (Claude handles this via user dialogue)
+- Client-side "enter manually" option (Agent Skill responsibility)
+
+## Architecture
+
+### Boundary Map
+
+```mermaid
+graph TB
+    subgraph Client
+        MCP[MCP Server]
+    end
+
+    subgraph GROWI_API[GROWI API]
+        Router[ai-tools Router]
+        Handler[suggest-path Handler]
+        MemoGen[Memo Suggestion]
+        Analyzer[Content Analyzer - 1st AI Call]
+        Retriever[Search Candidate Retriever]
+        Evaluator[Candidate Evaluator - 2nd AI Call]
+        CategoryGen[Category Suggestion - Under Review]
+    end
+
+    subgraph Existing[Existing Services]
+        SearchSvc[Search Service]
+        GrantSvc[Page Grant Service]
+        AIFeature[GROWI AI - OpenAI Feature]
+    end
+
+    subgraph Data
+        ES[Elasticsearch]
+        Mongo[MongoDB - Pages]
+    end
+
+    MCP -->|POST suggest-path| Router
+    Router --> Handler
+    Handler --> MemoGen
+    Handler --> Analyzer
+    Analyzer --> AIFeature
+    Handler --> Retriever
+    Retriever --> SearchSvc
+    Handler --> Evaluator
+    Evaluator --> AIFeature
+    Handler --> CategoryGen
+    CategoryGen --> SearchSvc
+    SearchSvc --> ES
+    Evaluator --> GrantSvc
+    CategoryGen --> GrantSvc
+    GrantSvc --> Mongo
+```
+
+**Integration notes**:
+
+- Layered handler following existing GROWI route conventions
+- Domain boundaries: Route layer owns the endpoint, delegates to existing services (search, grant, AI) without modifying them
+- Existing patterns preserved: Handler factory pattern, middleware chain, `res.apiv3()` response format
+
+### Code Organization
+
+All suggest-path code resides in `features/ai-tools/suggest-path/` following the project's feature-based architecture pattern.
+
+```text
+apps/app/src/features/ai-tools/
+├── server/routes/apiv3/
+│   └── index.ts                              # Aggregation router for ai-tools namespace
+└── suggest-path/
+    ├── interfaces/
+    │   └── suggest-path-types.ts              # Shared types (PathSuggestion, ContentAnalysis, etc.)
+    └── server/
+        ├── routes/apiv3/
+        │   ├── index.ts                       # Route factory, handler + middleware chain
+        │   └── index.spec.ts
+        ├── services/
+        │   ├── generate-suggestions.ts        # Orchestrator
+        │   ├── generate-memo-suggestion.ts
+        │   ├── analyze-content.ts             # AI call #1: keyword extraction + flow/stock
+        │   ├── retrieve-search-candidates.ts  # ES search with score filtering
+        │   ├── evaluate-candidates.ts         # AI call #2: candidate evaluation + path proposal
+        │   ├── call-llm-for-json.ts           # Shared LLM call utility
+        │   ├── generate-category-suggestion.ts # Under review
+        │   ├── resolve-parent-grant.ts
+        │   └── *.spec.ts                      # Co-located tests
+        └── integration-tests/
+            └── suggest-path-integration.spec.ts
+```
+
+**Key decisions**:
+
+- **No barrel export**: Consumers import directly from subpaths (following `features/openai/` convention)
+- **Aggregation router retained**: The `ai-tools` router at `features/ai-tools/server/routes/apiv3/` imports the suggest-path route factory. This allows future ai-tools features to register under the same namespace
+- **R4 (CategorySuggestionGenerator)**: Under review — may be merged into AI evaluation approach post-discussion
+
+### Implementation Paradigm
+
+All components are pure functions with immutable data. No classes — no component currently meets class adoption criteria (shared dependency management or singleton state).
+
+### Request Flow
+
+```mermaid
+sequenceDiagram
+    participant Client as MCP Client
+    participant Handler as Orchestrator
+    participant AI1 as Content Analyzer
+    participant Search as Search Service
+    participant AI2 as Candidate Evaluator
+    participant Grant as Grant Resolver
+    participant CatGen as Category Generator
+
+    Client->>Handler: POST with body content
+    Handler->>Handler: Generate memo suggestion
+
+    Handler->>AI1: Analyze content body
+    Note over AI1: 1st AI Call
+    AI1-->>Handler: keywords + informationType
+
+    par Search and evaluate
+        Handler->>Search: Search by keywords
+        Search-->>Handler: Raw results with scores
+        Handler->>Handler: Filter by score threshold
+        Handler->>AI2: body + analysis + candidates
+        Note over AI2: 2nd AI Call
+        AI2-->>Handler: Evaluated suggestions with paths and descriptions
+        loop For each evaluated suggestion
+            Handler->>Grant: Resolve grant for proposed path
+            Grant-->>Handler: Grant value
+        end
+    and Category suggestion
+        Handler->>CatGen: Generate from keywords
+        CatGen->>Search: Scoped keyword search
+        Search-->>CatGen: Top-level pages
+        CatGen->>Grant: Resolve parent grant
+        Grant-->>CatGen: Grant value
+        CatGen-->>Handler: Category suggestion or null
+    end
+
+    Handler-->>Client: 200 suggestions array
+```
+
+**Key decisions**:
+
+- Content analysis and candidate evaluation are structurally sequential — Elasticsearch sits between them
+- Search-evaluate flow and category generation run in parallel
+- If content analysis fails → memo-only response
+- If candidate evaluation fails → memo + category (if available)
+- Category generator runs independently (under review)
+
+## Component Interfaces
+
+### Orchestrator
+
+```typescript
+function generateSuggestions(
+  user: IUserHasId,
+  body: string,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<PathSuggestion[]>;
+```
+
+- **No DI pattern**: Imports service functions directly; only `searchService` is passed as a parameter (the sole external dependency that cannot be statically imported)
+- **Invariant**: Returns array with at least one suggestion (memo type), regardless of failures
+- **informationType mapping**: Attaches `ContentAnalysis.informationType` to each search-type suggestion (Req 13.1)
+
+### Content Analyzer (1st AI Call)
+
+```typescript
+type ContentAnalysis = {
+  keywords: string[];            // 1-5 keywords, proper nouns prioritized
+  informationType: 'flow' | 'stock';
+};
+
+function analyzeContent(body: string): Promise<ContentAnalysis>;
+```
+
+### Search Candidate Retriever
+
+```typescript
+type SearchCandidate = {
+  pagePath: string;
+  snippet: string;
+  score: number;
+};
+
+function retrieveSearchCandidates(
+  keywords: string[],
+  user: IUserHasId,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<SearchCandidate[]>;
+```
+
+- `searchService` is a direct positional argument (not wrapped in an options object)
+- Score threshold is a module-level constant (`SCORE_THRESHOLD = 5.0`)
+- Filters by ES score threshold; returns empty array if no results pass
+
+### Candidate Evaluator (2nd AI Call)
+
+```typescript
+type EvaluatedSuggestion = {
+  path: string;        // Proposed directory path with trailing /
+  label: string;
+  description: string; // AI-generated rationale
+};
+
+function evaluateCandidates(
+  body: string,
+  analysis: ContentAnalysis,
+  candidates: SearchCandidate[],
+): Promise<EvaluatedSuggestion[]>;
+```
+
+- Proposes paths using 3 structural patterns: (a) parent directory, (b) subdirectory, (c) sibling (may generate new paths at same hierarchy level)
+- Flow/stock alignment is a ranking factor, not a hard filter
+- Grant resolution performed by orchestrator after this returns
+
+### Category Suggestion Generator
+
+```typescript
+function generateCategorySuggestion(
+  candidates: SearchCandidate[],
+): Promise<PathSuggestion | null>;
+```
+
+- Under review — may be merged into AI evaluation approach post-discussion
+- Returns `null` when no matching top-level pages are found
+
+### Grant Resolver
+
+```typescript
+function resolveParentGrant(dirPath: string): Promise<number>;
+```
+
+- Traverses upward through ancestors for new paths (sibling pattern)
+- Returns `GRANT_OWNER` (4) as safe default if no ancestor found
+
+## Data Contracts
+
+### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|--------|----------|---------|----------|--------|
+| POST | `/_api/v3/ai-tools/suggest-path` | `SuggestPathRequest` | `SuggestPathResponse` | 400, 401, 403, 500 |
+
+### Request / Response Types
+
+```typescript
+// Request
+interface SuggestPathRequest {
+  body: string; // Page content for analysis (required, non-empty)
+}
+
+// Response
+type SuggestionType = 'memo' | 'search' | 'category';
+type InformationType = 'flow' | 'stock';
+
+interface PathSuggestion {
+  type: SuggestionType;
+  path: string;                        // Directory path with trailing '/'
+  label: string;
+  description: string;                 // Fixed for memo, AI-generated for search
+  grant: number;                       // Parent page grant (PageGrant value)
+  informationType?: InformationType;   // Search-based only
+}
+
+interface SuggestPathResponse {
+  suggestions: PathSuggestion[];       // Always ≥1 element (memo)
+}
+```
+
+**Invariants**: `path` ends with `/`, `grant` is a valid PageGrant value (1, 2, 4, or 5)
+
+### Response Example
+
+```json
+{
+  "suggestions": [
+    {
+      "type": "memo",
+      "path": "/user/alice/memo/",
+      "label": "Save as memo",
+      "description": "Save to your personal memo area",
+      "grant": 4
+    },
+    {
+      "type": "search",
+      "path": "/tech-notes/React/state-management/",
+      "label": "Save near related pages",
+      "description": "This area contains pages about React state management. Your stock content fits well alongside this existing reference material.",
+      "grant": 1,
+      "informationType": "stock"
+    },
+    {
+      "type": "category",
+      "path": "/tech-notes/",
+      "label": "Save under category",
+      "description": "Top-level category: tech-notes",
+      "grant": 1
+    }
+  ]
+}
+```
+
+## Error Handling & Graceful Degradation
+
+### User Errors (4xx)
+
+| Error | Status | Requirement |
+|-------|--------|-------------|
+| Missing or empty `body` | 400 | 9.1 |
+| No authentication | 401 | 8.2 |
+| AI service not enabled | 403 | 1.4 |
+
+### Graceful Degradation (returns 200)
+
+| Failure | Fallback |
+|---------|----------|
+| Content analysis (1st AI call) | Memo only (skips entire search pipeline) |
+| Search service | Memo + category (if available) |
+| Candidate evaluation (2nd AI call) | Memo + category (if available) |
+| Category generation | Memo + search-based (if available) |
+
+Each component fails independently. Memo is always generated first as guaranteed fallback.
+
+## Security Considerations
+
+- **Authentication**: All requests require valid API token or login session (standard middleware)
+- **Authorization**: Search results are permission-scoped via `searchKeyword()` user/group parameters
+- **Input safety**: Content body is passed to GROWI AI, not directly to Elasticsearch — no NoSQL injection risk
+- **AI prompt injection**: System prompt and user content are separated to minimize prompt injection risk
+- **Information leakage**: Error responses use generic messages (Req 9.2)
+
+## Performance Considerations
+
+- Content analysis and candidate evaluation are sequential (ES sits between) — 2 AI roundtrips minimum
+- Search-evaluate pipeline and category generation run in parallel to minimize total latency
+- ES snippets (not full page bodies) are passed to AI to manage context budget
+- Score threshold filtering reduces the number of candidates passed to the 2nd AI call

+ 77 - 0
.kiro/specs/suggest-path/requirements.md

@@ -0,0 +1,77 @@
+# Requirements Document
+
+## Introduction
+
+The suggest-path feature provides an AI-powered API endpoint for GROWI that suggests optimal page save locations. When an AI client (e.g., Claude via MCP) sends page content, the endpoint analyzes it and returns directory path suggestions with metadata including descriptions and grant (permission) constraints.
+
+The feature was delivered in two phases:
+
+- **Phase 1 (MVP)**: Personal memo path suggestion — endpoint, authentication, and response structure.
+- **Phase 2 (Full)**: AI-powered search-based path suggestions with flow/stock information classification, multi-candidate evaluation, and intelligent path proposal (including new paths).
+
+### Phase 2 Revision History
+
+Phase 2 was revised based on reviewer feedback: (1) flow/stock information classification, (2) multi-candidate AI evaluation instead of top-1 selection, (3) three-pattern path proposals (parent/subdirectory/sibling), (4) AI-generated descriptions.
+
+## Out of Scope
+
+- **Page creation/saving**: Uses existing `POST /_api/v3/page`. This feature only suggests *where* to save.
+- **Page title determination**: Handled via AI client-user dialogue.
+
+## Requirements
+
+### Requirement 1: Path Suggestion API Endpoint
+
+**Summary**: POST endpoint at `/_api/v3/ai-tools/suggest-path` accepts a `body` field and returns an array of path suggestions. Each suggestion includes `type`, `path` (directory with trailing `/`), `label`, `description`, and `grant`. Endpoint is under a separate namespace from `/_api/v3/page/` for independent access control.
+
+### Requirement 2: Memo Path Suggestion (Phase 1)
+
+**Summary**: Always includes a `memo` type suggestion as guaranteed fallback. Path is `/user/{username}/memo/` when user pages are enabled, or `/memo/{username}/` when disabled. Grant is `4` (owner only). Description is fixed text.
+
+### Requirement 3: Search-Based Path Suggestion (Phase 2)
+
+**Summary**: Searches for related pages using extracted keywords, filters by Elasticsearch score threshold, then passes all passing candidates to AI-based evaluation (Req 11). Includes parent page's grant. Omitted if no candidates pass the threshold.
+
+### Requirement 4: Category-Based Path Suggestion (Phase 2) — Under Review
+
+**Summary**: Extracts top-level path segment from keyword-matched pages as a `category` type suggestion. Includes parent grant. Omitted if no match found.
+
+> **Note**: May overlap with the AI-based evaluation approach (Reqs 11, 12). Whether to retain, merge, or remove will be determined after reviewer discussion.
+
+### Requirement 5: Content Analysis via GROWI AI (Phase 2)
+
+**Summary**: Single AI call performs keyword extraction (1-5 keywords, proper nouns prioritized) and flow/stock information type classification. Keywords (not raw content) are used for search. On failure, falls back to memo-only response.
+
+### Requirement 6: Suggestion Description Generation
+
+**Summary**: Each suggestion includes a `description` field. Memo uses fixed text. Search-based suggestions use AI-generated descriptions from candidate evaluation (Req 11).
+
+### Requirement 7: Grant Constraint Information
+
+**Summary**: Each suggestion includes a `grant` field representing the parent page's grant value — the upper bound of settable permissions for child pages (a constraint, not a recommendation).
+
+### Requirement 8: Authentication and Authorization
+
+**Summary**: Requires valid API token or login session. Returns authentication error if missing. Uses authenticated user's identity for user-specific suggestions.
+
+### Requirement 9: Input Validation and Error Handling
+
+**Summary**: Returns validation error for missing/empty `body`. Internal errors return appropriate responses without exposing system details.
+
+### Requirement 10: Flow/Stock Information Type Awareness (Phase 2)
+
+**Summary**: Candidate evaluation considers flow/stock alignment between content and candidate locations. Flow = time-bound (date-based paths, meeting terms). Stock = reference (topic-based paths). Used as a ranking factor, not a hard filter.
+
+### Requirement 11: AI-Based Candidate Evaluation and Ranking (Phase 2)
+
+**Summary**: GROWI AI evaluates each candidate's suitability using content body, candidate path, and snippet. Ranks by content-destination fit considering relevance and flow/stock alignment. Generates description per suggestion. Falls back to memo-only on failure.
+
+### Requirement 12: Path Proposal Patterns (Phase 2)
+
+**Summary**: Three structural patterns relative to each matching page: (a) parent directory, (b) subdirectory, (c) sibling directory. Sibling pattern generates new directory names at the same hierarchy level as the candidate. AI determines the most appropriate pattern.
+
+### Requirement 13: Client LLM Independence (Phase 2)
+
+**Summary**: Response includes both structured metadata (`informationType`, `type`, `grant`) and natural language (`description`) so any LLM client can use it regardless of reasoning capability. All reasoning-intensive operations are server-side.
+
+**Design Rationale**: MCP clients are powered by varying LLM models. Heavy reasoning is centralized in GROWI AI to prevent quality degradation with less capable clients.

+ 145 - 0
.kiro/specs/suggest-path/research.md

@@ -0,0 +1,145 @@
+# Research & Design Decisions
+
+## Summary
+
+- **Feature**: `suggest-path`
+- **Discovery Scope**: Extension (new endpoint added to existing API infrastructure)
+- **Key Findings**:
+  - GROWI uses a handler factory pattern (`(crowi: Crowi) => RequestHandler[]`) for API routes
+  - The `ai-tools` namespace does not exist yet; closest is `/openai` under `features/openai/`
+  - Grant parent-child constraints are enforced by `page-grant.ts` — GRANT_OWNER children must share the same owner
+  - `searchService.searchKeyword()` accepts keyword string and returns scored results with page metadata
+  - User home path utilities exist in `@growi/core` (`userHomepagePath`, `isUsersHomepage`)
+
+## Research Log
+
+### GROWI API Route Patterns
+
+- **Context**: Need to understand how to add a new route namespace
+- **Sources Consulted**: `apps/app/src/server/routes/apiv3/index.js`, `page/create-page.ts`, `features/openai/server/routes/index.ts`
+- **Findings**:
+  - Three router types: standard, admin, auth. New endpoints go on standard router
+  - Route registration: `router.use('/namespace', require('./namespace')(crowi))` or factory import
+  - Handler factory pattern: exports `(crowi: Crowi) => RequestHandler[]` returning middleware chain
+  - Middleware ordering: `accessTokenParser` → `loginRequiredStrictly` → validators → `apiV3FormValidator` → handler
+  - Response helpers: `res.apiv3(data)` for success, `res.apiv3Err(error, status)` for errors
+  - Feature-based routes use dynamic import pattern (see openai routes)
+- **Implications**: suggest-path follows the handler factory pattern. Route factory in `features/ai-tools/suggest-path/server/routes/apiv3/`, aggregation router in `features/ai-tools/server/routes/apiv3/`
+
+### OpenAI Feature Structure
+
+- **Context**: Understanding existing AI feature patterns for alignment
+- **Sources Consulted**: `features/openai/server/routes/index.ts`, `middlewares/certify-ai-service.ts`
+- **Findings**:
+  - AI routes gate on `aiEnabled` config via `certifyAiService` middleware
+  - Dynamic imports used for route handlers
+  - Dedicated middleware directory for AI-specific checks
+  - Routes organized under `features/openai/` not `routes/apiv3/`
+- **Implications**: suggest-path gates on AI-enabled config via `certifyAiService`. Code lives under `features/ai-tools/suggest-path/` with an aggregation router at `features/ai-tools/server/routes/apiv3/`.
+
+### Grant System Constraints
+
+- **Context**: Need to return accurate grant constraints for suggested paths
+- **Sources Consulted**: `@growi/core` PageGrant enum, `apps/app/src/server/service/page-grant.ts`
+- **Findings**:
+  - PageGrant values: PUBLIC(1), RESTRICTED(2), SPECIFIED(3-deprecated), OWNER(4), USER_GROUP(5)
+  - Parent constrains child: OWNER parent → child must be OWNER by same user; USER_GROUP parent → child cannot be PUBLIC
+  - `calcApplicableGrantData(page, user)` returns allowed grant types for a page
+  - For memo path (`/user/{username}/memo/`), the user homepage `/user/{username}` is GRANT_OWNER(4) by default → memo path grant is fixed at 4
+- **Implications**: Phase 1 memo grant is trivially 4. Phase 2 needs to look up actual parent page grant via Page model
+
+### Search Service Integration
+
+- **Context**: Phase 2 requires keyword-based search for related pages
+- **Sources Consulted**: `apps/app/src/server/service/search.ts`
+- **Findings**:
+  - `searchKeyword(keyword, nqName, user, userGroups, searchOpts)` → `[ISearchResult, delegatorName]`
+  - Results include `_id`, `_score`, `_source`, `_highlight`
+  - Supports `prefix:` queries for path-scoped search
+  - User groups needed for permission-scoped search results
+- **Implications**: Phase 2 uses `searchKeyword` with extracted keywords. Category search uses `prefix:/` to scope to top-level. Need `getUserRelatedGroups()` for permission-correct results.
+
+### User Home Path Utilities
+
+- **Context**: Memo path generation needs user home path
+- **Sources Consulted**: `@growi/core` `page-path-utils/index.ts`
+- **Findings**:
+  - `userHomepagePath(user)` → `/user/{username}`
+  - `isUsersHomepage(path)` → boolean check
+  - `getUsernameByPath(path)` → extract username from path
+- **Implications**: Use `userHomepagePath(req.user)` + `/memo/` for memo suggestion path
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Route under `features/ai-tools/` | Feature-based directory with aggregation router | Clean separation, follows features pattern and `ai-tools` naming | — | **Selected** — aligns with project architecture and independent access control |
+| Route under `features/openai/` | Extend existing AI feature module | Reuses AI infrastructure, minimal setup | Provider-specific name, harder to separate for independent access control | Rejected in review — namespace should be provider-agnostic |
+| Route under `routes/apiv3/page/` | Add to existing page routes | Close to page creation | Cannot gate independently for access control | Rejected in review — yuki requested separation |
+
+## Design Decisions
+
+### Decision: Route Namespace Placement
+
+- **Context**: Endpoint needs independent access control
+- **Alternatives Considered**:
+  1. `/openai/suggest-path` — groups with AI features but provider-specific
+  2. `/page/suggest-path` — close to page creation but cannot gate independently
+  3. `/ai-tools/suggest-path` — new provider-agnostic namespace
+- **Selected Approach**: `/_api/v3/ai-tools/suggest-path` under `features/ai-tools/suggest-path/`
+- **Rationale**: Provider-agnostic, enables independent access control, follows features directory pattern
+- **Trade-offs**: Aggregation router at `features/ai-tools/server/routes/apiv3/` allows future ai-tools features under the same namespace
+
+### Decision: Phase 1 Handler Simplicity
+
+- **Context**: Phase 1 (MVP) only returns memo path — very simple logic
+- **Alternatives Considered**:
+  1. Full service layer from the start (SuggestionService class)
+  2. Inline logic in handler, extract to service when Phase 2 arrives
+- **Selected Approach**: Inline logic in handler for Phase 1, extract to service for Phase 2
+- **Rationale**: Avoid over-engineering. Phase 1 is ~10 lines of logic. Service abstraction added when needed
+- **Trade-offs**: Phase 2 will require refactoring handler → service extraction
+- **Follow-up**: Define service interface in design for Phase 2 readiness
+
+### Decision: GROWI AI Keyword Extraction Approach
+
+- **Context**: Phase 2 needs keyword extraction from content body
+- **Alternatives Considered**:
+  1. New dedicated keyword extraction service
+  2. Extend existing OpenAI feature module
+  3. Client-side keyword extraction (fallback option)
+- **Selected Approach**: Leverage existing `features/openai/` infrastructure for keyword extraction
+- **Rationale**: GROWI already has OpenAI integration. Keyword extraction is a new capability within the existing AI feature
+- **Trade-offs**: Couples suggest-path to OpenAI feature availability. Mitigated by fallback to memo-only response
+- **Follow-up**: Detailed keyword extraction implementation is out of scope for this spec (separate design)
+
+## Risks & Mitigations
+
+- **Large content body performance**: Sending full content for AI keyword extraction may be slow. Mitigation: fallback to memo-only if extraction fails
+- **Search service dependency**: Depends on Elasticsearch being available. Mitigation: graceful degradation — return memo suggestion if search fails
+
+## Post-Implementation Discoveries
+
+### Lesson: Avoid Testability-Motivated DI in Feature Services
+
+- **Context**: Initial Phase 2 implementation used a `GenerateSuggestionsDeps` pattern — a `deps` parameter containing 5 callback functions injected into the orchestrator for testability
+- **Problem**: The pattern was inconsistent with the rest of the codebase (other modules use `vi.mock()` for testing), added route handler boilerplate (10 lines wiring callbacks), and forced unnecessary abstractions like `RetrieveSearchCandidatesOptions`
+- **Resolution**: Removed `deps` pattern; service functions are imported directly. Only `searchService` is passed as a parameter (the sole external dependency that cannot be statically imported). Tests use `vi.mock()` — consistent with `generate-memo-suggestion` and other modules
+- **Guideline**: In this codebase, prefer `vi.mock()` over DI patterns for feature-specific service layers. Reserve DI for true cross-cutting concerns or when the dependency is a runtime-varying service instance (like `searchService`)
+
+### Lesson: Type Propagation from Legacy Code
+
+- **Context**: `searchService.searchKeyword()` in `src/server/service/search.ts` has untyped parameters (legacy JS-to-TS migration), so the suggest-path code initially used `userGroups: unknown` as a safe catch-all
+- **Resolution**: Traced the actual type from `findAllUserGroupIdsRelatedToUser()` which returns `ObjectIdLike[]` (from `@growi/core`), and propagated it through the `SearchService` interface and all service functions
+- **Guideline**: When integrating with legacy untyped services, trace the actual runtime type from the call site rather than defaulting to `unknown`
+
+## References
+
+- [GROWI Search Internals](https://dev.growi.org/69842ea0cb3a20a69b0a1985) — Search feature internal architecture
+- `apps/app/src/server/routes/apiv3/index.js` — Route registration entry point
+- `apps/app/src/server/routes/apiv3/page/create-page.ts` — Reference handler pattern
+- `apps/app/src/features/openai/server/routes/index.ts` — AI feature route pattern
+- `packages/core/src/interfaces/page.ts` — PageGrant enum definition
+- `apps/app/src/server/service/page-grant.ts` — Grant validation logic
+- `apps/app/src/server/service/search.ts` — Search service interface
+- `packages/core/src/utils/page-path-utils/index.ts` — User path utilities

+ 23 - 0
.kiro/specs/suggest-path/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "suggest-path",
+  "created_at": "2026-02-10T12:00:00Z",
+  "updated_at": "2026-03-23T00:00:00Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleanup_completed": true
+}

+ 82 - 0
.kiro/specs/suggest-path/tasks.md

@@ -0,0 +1,82 @@
+# Implementation Plan
+
+## Phase 1 (MVP) — Implemented
+
+- [x] 1. Phase 1 MVP — Shared types, memo path suggestion, and endpoint registration
+- [x] 1.1 Define suggestion types and implement memo path generation
+- [x] 1.2 Register route endpoint with authentication and validation
+- [x] 1.3 Phase 1 integration verification
+
+## Phase 2 — Revised
+
+- [x] 2. (P) Enhance grant resolver for ancestor path traversal
+- [x] 3. (P) Content analysis via GROWI AI (1st AI call)
+- [x] 4. (P) Search candidate retrieval with score threshold filtering
+- [x] 5. (P) AI-based candidate evaluation and path proposal (2nd AI call)
+- [x] 6. (P) Category-based path suggestion (under review — prior implementation retained)
+- [x] 7. Phase 2 revised orchestration and integration
+- [x] 7.1 Rewrite orchestration for revised Phase 2 pipeline
+- [x] 7.2 Phase 2 integration verification
+
+## Post-Implementation Refactoring (from code review)
+
+See `gap-analysis.md` for detailed rationale.
+
+- [x] 8. Simplify service layer abstractions
+- [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
+- [x] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.ts`
+- [x] 8.3 Add JSDoc to `call-llm-for-json.ts`
+- [x] 8.4 Narrow `userGroups: unknown` to `ObjectIdLike[]`
+
+## Requirements Coverage
+
+| Requirement | Task(s) |
+|-------------|---------|
+| 1.1 | 1.2, 1.3, 7.1 |
+| 1.2 | 1.1, 1.3, 7.1 |
+| 1.3 | 1.1, 1.3, 7.1 |
+| 1.4 | 1.2, 1.3 |
+| 2.1 | 1.1, 1.3 |
+| 2.2 | 1.1 |
+| 2.3 | 1.1 |
+| 2.4 | 1.1 |
+| 2.5 | 1.1 |
+| 3.1 | 4, 7.2 |
+| 3.2 | 4, 7.2 |
+| 3.3 | 5, 7.1, 7.2 |
+| 3.4 | 7.1, 7.2 |
+| 3.5 | 4, 7.2 |
+| 4.1 | 6 |
+| 4.2 | 6 |
+| 4.3 | 6 |
+| 4.4 | 6 |
+| 5.1 | 3, 7.2 |
+| 5.2 | 3 |
+| 5.3 | 4, 7.1 |
+| 5.4 | 3, 7.2 |
+| 5.5 | 7.1, 7.2 |
+| 6.1 | 1.1, 7.2 |
+| 6.2 | 1.1 |
+| 6.3 | 5, 7.2 |
+| 7.1 | 2 |
+| 7.2 | 2 |
+| 8.1 | 1.2, 1.3 |
+| 8.2 | 1.2, 1.3 |
+| 8.3 | 1.2, 7.1 |
+| 9.1 | 1.2, 1.3 |
+| 9.2 | 1.2, 7.1 |
+| 10.1 | 5, 7.2 |
+| 10.2 | 5 |
+| 10.3 | 5 |
+| 10.4 | 5 |
+| 11.1 | 5, 7.2 |
+| 11.2 | 5 |
+| 11.3 | 5 |
+| 11.4 | 7.1, 7.2 |
+| 12.1 | 5, 7.2 |
+| 12.2 | 5 |
+| 12.3 | 5 |
+| 12.4 | 5 |
+| 13.1 | 7.1, 7.2 |
+| 13.2 | 7.1, 7.2 |
+| 13.3 | 7.1 |

+ 14 - 8
.kiro/steering/structure.md

@@ -1,14 +1,20 @@
 # Project Structure
 
-@.claude/skills/monorepo-overview/SKILL.md
+See: `.claude/skills/monorepo-overview/SKILL.md` (auto-loaded by Claude Code)
 
 ## cc-sdd Specific Notes
 
-### Specification Storage
-- All specifications are stored in `.kiro/specs/{feature-name}/`
-- Each spec contains: `spec.json`, `requirements.md`, `design.md`, `tasks.md`
+### Server-Client Boundary Enforcement
 
-### Feature Placement
-When implementing new features via `/kiro:spec-impl`:
-- Create feature modules in `src/features/{feature-name}/`
-- Follow the server-client separation pattern documented in the skill above
+In full-stack packages (e.g., `apps/app`), server-side code (`src/server/`, models with mongoose) must NOT be imported from client components. This causes module leakage — server-only dependencies get pulled into the client bundle.
+
+- **Pattern**: If a client component needs functionality from a server module, extract the client-safe logic into a shared utility (`src/utils/` or `src/client/util/`)
+
+For apps/app-specific examples and build tooling details, see `apps/app/.claude/skills/build-optimization/SKILL.md`.
+
+### The positioning of @growi/core.
+
+See: `.claude/skills/monorepo-overview/SKILL.md` — "@growi/core — Domain & Utilities Hub" section
+
+---
+_Updated: 2026-03-24. @growi/core details moved to monorepo-overview SKILL.md (auto-loaded)._

+ 4 - 18
.kiro/steering/tdd.md

@@ -1,22 +1,8 @@
 # Test-Driven Development
 
-@.claude/commands/tdd.md
-@.claude/skills/testing-patterns-with-vitest/SKILL.md
+See: `.claude/commands/tdd.md`, `.claude/skills/learned/essential-test-patterns/SKILL.md` and `.claude/skills/learned/essential-test-design/SKILL.md`
 
-## cc-sdd Integration
+## cc-sdd Specific Notes
 
-### TDD in spec-impl Workflow
-When executing `/kiro:spec-impl`, the TDD cycle is mandatory:
-
-1. **Each task → TDD cycle**: RED → GREEN → REFACTOR
-2. **Tests trace to requirements**: Test names should reference EARS requirement IDs
-3. **Coverage gates completion**: Task is not complete until coverage targets met
-
-### Validation Before Task Completion
-```bash
-# Verify tests pass
-turbo run test --filter {package}
-
-# Check coverage (80% minimum)
-cd {package_dir} && pnpm vitest run --coverage src/utils/page-path-validator.spec.ts
-```
+Currently, there are no additional instructions specific to Kiro.
+If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.

+ 53 - 9
.kiro/steering/tech.md

@@ -1,15 +1,59 @@
 # Technology Stack
 
-@.claude/skills/tech-stack/SKILL.md
+See: `.claude/skills/tech-stack/SKILL.md` (auto-loaded by Claude Code)
 
 ## cc-sdd Specific Notes
 
-### Specification Language
-All spec files (requirements.md, design.md, tasks.md) should be written in English unless explicitly configured otherwise in spec.json.
+### Bundler Strategy (Project-Wide Decision)
 
-### Build Verification
-Before marking tasks complete in `/kiro:spec-impl`, ensure:
-```bash
-turbo run lint --filter @growi/app
-turbo run test --filter @growi/app
-```
+GROWI uses **Turbopack** (Next.js 16 default) for **both development and production builds** (`next build` without flags). Webpack fallback is available via `USE_WEBPACK=1` environment variable for debugging only. All custom webpack loaders/plugins have been migrated to Turbopack equivalents (`turbopack.rules`, `turbopack.resolveAlias`). See `apps/app/.claude/skills/build-optimization/SKILL.md` for details.
+
+### Import Optimization Principles
+
+To prevent module count regression across the monorepo:
+
+- **Subpath imports over barrel imports** — e.g., `import { format } from 'date-fns/format'` instead of `from 'date-fns'`
+- **Lightweight replacements** — prefer small single-purpose packages over large multi-feature libraries
+- **Server-client boundary** — never import server-only code from client modules; extract client-safe utilities if needed
+
+### Turbopack Externalisation Rule (`apps/app/package.json`)
+
+**Any package that is reachable via a static `import` statement in SSR-executed code must be listed under `dependencies`, not `devDependencies`.**
+
+Turbopack externalises such packages to `.next/node_modules/` (symlinks into the pnpm store). `pnpm deploy --prod` only includes `dependencies`; packages in `devDependencies` are absent from the deploy output, causing `ERR_MODULE_NOT_FOUND` at production server startup.
+
+**SSR-executed code** = any module that Turbopack statically traces from a Pages Router page component, `_app.page.tsx`, or a server-side utility — without crossing a `dynamic(() => import(...), { ssr: false })` boundary.
+
+**Making a package devDep-eligible:**
+1. Wrap the consuming component with `dynamic(() => import('...'), { ssr: false })`, **or**
+2. Replace the runtime dependency with a static asset (e.g., extract data to a committed JSON file), **or**
+3. Change the import to a dynamic `import()` inside a `useEffect` (browser-only execution).
+
+**Packages justified to stay in `dependencies`** (SSR-reachable static imports as of v7.5):
+- `react-toastify` — `toastr.ts` static `{ toast }` import reachable from SSR pages; async refactor would break API surface
+- `bootstrap` — still externalised despite `useEffect`-guarded `import()` in `_app.page.tsx`; Turbopack traces call sites statically
+- `diff2html` — still externalised despite `ssr: false` on `RevisionDiff`; static import analysis reaches it
+- `react-dnd`, `react-dnd-html5-backend` — still externalised despite DnD provider wrapped with `ssr: false`
+- `@handsontable/react` — still externalised despite `useEffect` dynamic import in `HandsontableModal`
+- `i18next-http-backend`, `i18next-localstorage-backend`, `react-dropzone` — no direct `src/` imports but appear via transitive imports
+- `@codemirror/state`, `@headless-tree/*`, `@tanstack/react-virtual`, `downshift`, `fastest-levenshtein`, `pretty-bytes`, `react-copy-to-clipboard`, `react-hook-form`, `react-input-autosize`, `simplebar-react` — statically imported in SSR-rendered components
+
+### Production Assembly Pattern
+
+`assemble-prod.sh` produces the release artifact via **workspace-root staging** (not `apps/app/` staging):
+
+```
+pnpm deploy out --prod --legacy   → self-contained out/node_modules/ (pnpm v10)
+rm -rf node_modules
+mv out/node_modules node_modules  → workspace root is now prod-only
+ln -sfn ../../node_modules apps/app/node_modules  → compatibility symlink
+```
+
+The release image includes `node_modules/` at workspace root alongside `apps/app/`. Turbopack's `.next/node_modules/` symlinks (pointing `../../../../node_modules/.pnpm/`) resolve naturally without any sed-based rewriting. `apps/app/node_modules` is a symlink to `../../node_modules` for migration script and Node.js `require()` compatibility.
+
+**pnpm version sensitivity**: `--legacy` produces self-contained symlinks in pnpm v10+. Downgrading below v10 may break the assembly. After running `assemble-prod.sh` locally, run `pnpm install` to restore the development environment.
+
+For apps/app-specific build optimization details (webpack config, null-loader rules, SuperJSON architecture, module count KPI), see `apps/app/.claude/skills/build-optimization/SKILL.md`.
+
+---
+_Updated: 2026-03-17. Turbopack now used for production builds; expanded justified-deps list; added Production Assembly Pattern._

+ 1 - 21
.mcp.json

@@ -1,23 +1,3 @@
 {
-  "mcpServers": {
-    "context7": {
-      "type": "http",
-      "url": "https://mcp.context7.com/mcp"
-    },
-    "serena": {
-      "type": "stdio",
-      "command": "uvx",
-      "args": [
-        "--from",
-        "git+https://github.com/oraios/serena",
-        "serena-mcp-server",
-        "--context",
-        "claude-code",
-        "--project",
-        ".",
-        "--enable-web-dashboard=false"
-      ],
-      "env": {}
-    }
-  }
+  "mcpServers": {}
 }

+ 0 - 2
.npmrc

@@ -1,2 +0,0 @@
-# see: https://pnpm.io/next/npmrc#force-legacy-deploy
-force-legacy-deploy=true

+ 64 - 8
.serena/project.yml

@@ -1,9 +1,3 @@
-# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
-#  * For C, use cpp
-#  * For JavaScript, use typescript
-# Special requirements:
-#  * csharp: Requires the presence of a .sln file in the project folder.
-language: typescript
 
 # whether to use the project's gitignore file to ignore files
 # Added on 2025-04-07
@@ -59,10 +53,72 @@ read_only: false
 #  * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
 #  * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
 #  * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
-excluded_tools: ["check_onboarding_performed", "execute_shell_command", "initial_instructions", "onboarding", "prepare_for_new_conversation", "read_memory", "write_memory", "list_memories", "delete_memory"]
+excluded_tools:
+- "check_onboarding_performed"
+- "execute_shell_command"
+- "initial_instructions"
+- "onboarding"
+- "prepare_for_new_conversation"
+- "read_memory"
+- "write_memory"
+- "list_memories"
+- "delete_memory"
 
 # initial prompt for the project. It will always be given to the LLM upon activating the project
 # (contrary to the memories, which are loaded on demand).
 initial_prompt: ""
-
+# the name by which the project can be referenced within Serena
 project_name: "growi"
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: utf-8
+
+
+# list of languages for which language servers are started; choose from:
+#   al                  bash                clojure             cpp                 csharp
+#   csharp_omnisharp    dart                elixir              elm                 erlang
+#   fortran             fsharp              go                  groovy              haskell
+#   java                julia               kotlin              lua                 markdown
+#   matlab              nix                 pascal              perl                php
+#   powershell          python              python_jedi         r                   rego
+#   ruby                ruby_solargraph     rust                scala               swift
+#   terraform           toml                typescript          typescript_vts      vue
+#   yaml                zig
+#   (This list may be outdated. For the current list, see values of Language enum here:
+#   https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+#   For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+#   - For C, use cpp
+#   - For JavaScript, use typescript
+#   - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+#   Some languages require additional setup/installations.
+#   See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- typescript

+ 3 - 6
AGENTS.md

@@ -4,7 +4,7 @@ GROWI is a team collaboration wiki platform built with Next.js, Express, and Mon
 
 ## Language Policy
 
-**Response Language**: If we detect at the beginning of a conversation that the user's primary language is not English, we will always respond in that language. However, technical terms may remain in English when appropriate.
+**Response Language**: If the user writes in a non-English language at any point in the conversation, always respond in that language from that point onward. This rule takes **absolute priority** over any other language instructions, including skill/command prompts or context documents written in English.
 
 **Code Comments**: When generating source code, all comments and explanations within the code must be written in English, regardless of the conversation language.
 
@@ -24,7 +24,6 @@ Technical information is available in **Claude Code Skills** (`.claude/skills/`)
 |-------|-------------|
 | **monorepo-overview** | Monorepo structure, workspace organization, Changeset versioning |
 | **tech-stack** | Technology stack, pnpm/Turborepo, TypeScript, Biome |
-| **testing-patterns-with-vitest** | Vitest, React Testing Library, vitest-mock-extended |
 
 **Rules** (always applied):
 
@@ -117,8 +116,7 @@ Always execute these checks:
 
 ```bash
 # From workspace root (recommended)
-turbo run lint:typecheck --filter @growi/app
-turbo run lint:biome --filter @growi/app
+turbo run lint --filter @growi/app
 turbo run test --filter @growi/app
 turbo run build --filter @growi/app
 ```
@@ -126,8 +124,7 @@ turbo run build --filter @growi/app
 Or from apps/app directory:
 
 ```bash
-pnpm run lint:typecheck
-pnpm run lint:biome
+pnpm run lint
 pnpm run test
 pnpm run build
 ```

+ 86 - 1
CHANGELOG.md

@@ -1,9 +1,94 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.4.4...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.5.0...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.5.0](https://github.com/growilabs/compare/v7.4.7...v7.5.0) - 2026-04-07
+
+### 💎 Features
+
+* feat(ai): Suggest path to save (#10777) @tomoyuki-t-weseek
+* feat: Audit log bulk export (#10874) @Ryosei-Fukushima
+* feat(page-create-modal): add template help link icon (#10899) @tomoyuki-t-weseek
+* feat: add tooltips to editor toolbar (#10938) @Ryosei-Fukushima
+* feat: Add growi cloud link to audit log settings (#10881) @ryota-t0401
+
+### 🚀 Improvement
+
+* imprv: Staff credit (#10839) @yuki-takei
+* imprv(lsx): Allow spaces in attribute names (#10931) @NJisEverywhere
+* imprv: pre-fill export modal with current filter values (#10944) @NJisEverywhere
+* imprv(presentation): Decouple Marp from GrowiSlides (#10840) @yuki-takei
+* imprv(ui): Implement the improved New button. (#10937) @yuyaiwahori
+
+### 🐛 Bug Fixes
+
+* fix: re-scroll to hash target after lazy-rendered content completes (#10853) @miya
+* fix: Bulk export fails due to S3 upload complete version (#10833) @ryotaro-nagahara
+* fix: Duplicate user data is appearing in the user table at /user/admin (#10940) @miya
+* fix: Deleted users are not displayed in the user list on the user management page (/admin/users) (#10934) @miya
+* fix: Resolve React warnings during page rendering (#10913) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Upgrade Next.js to v16 (#10831) @yuki-takei
+* support(yjs): Migrate collaborative editing transport from y-socket.io to y-websocket (#10889) @yuki-takei
+* support: Upgrade vite v6, vitest v3, and related packages (#10945) @yuki-takei
+* ci(deps-dev): bump vite from 6.4.1 to 6.4.2 (#10960) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Upgrade version-pinned packages and replace escape-string-regexp with RegExp.escape() (#10920) @yuki-takei
+* support: Migrate to Turbopack (#10838) @yuki-takei
+* support: Modernize Dockerfile (#10809) @yuki-takei
+* support: Reclassify deps (#10873) @yuki-takei
+* support(dev): Reduce modules loaded (#10822) @yuki-takei
+
+## [v7.4.7](https://github.com/growilabs/compare/v7.4.6...v7.4.7) - 2026-03-23
+
+### 💎 Features
+
+* feat: Prevent inline mime type sniffing vulnerabilities (#10087) @arvid-e
+
+### 🐛 Bug Fixes
+
+* fix(editor): Disable bracketMatching to prevent IME composition rendering corruption (#10900) @miya
+
+## [v7.4.6](https://github.com/growilabs/compare/v7.4.5...v7.4.6) - 2026-03-10
+
+### 🐛 Bug Fixes
+
+* fix: mobile editor page title display (#10712) @satof3
+* fix: Exclude user page data from search response when user pages are disabled (#10740) @arvid-e
+* fix(search): prevent Downshift from intercepting Home/End keys in search input (#10815) @yuki-takei
+* fix: Openai thread IDOR (#10806) @ryotaro-nagahara
+
+### 🧰 Maintenance
+
+* support(claude): Add SessionStart hook for Claude Code on the web (#10816) @yuki-takei
+
+## [v7.4.5](https://github.com/growilabs/compare/v7.4.4...v7.4.5) - 2026-02-19
+
+### 💎 Features
+
+* feat: Realtime Increment View Count Without Refreshing Pages (#10760) @ryotaro-nagahara
+
+### 🚀 Improvement
+
+* imprv: Unchanged revision (#10770) @yuki-takei
+* imprv: Close the Sidebar in drawer mode when the route changes (#10763) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Use currentPageId for share link page fetching (#10797) @yuki-takei
+* fix: Allow viewing shared pages regardless of page permissions (#10762) @ryotaro-nagahara
+* fix: Bulk export fails due to S3 upload minimal version (#10782) @ryotaro-nagahara
+* fix: Block revisions API from returning info about user pages when user pages are disabled (#10751) @arvid-e
+* fix: OpenAPI spec mismatch for GET /page endpoint response format (#10787) @[copilot-swe-agent[bot]](https://github.com/apps/copilot-swe-agent)
+
+### 🧰 Maintenance
+
+* support: Extract `/page/info` endpoint handler into a dedicated module (#10795) @yuki-takei
+* ci(deps): bump qs from 6.14.1 to 6.14.2 (#10785) @[dependabot[bot]](https://github.com/apps/dependabot)
+
 ## [v7.4.4](https://github.com/growilabs/compare/v7.4.3...v7.4.4) - 2026-01-30
 
 ### 🐛 Bug Fixes

+ 2 - 0
CLAUDE.md

@@ -21,6 +21,7 @@ Kiro-style Spec Driven Development implementation on AI-DLC (AI Development Life
 
 ## Development Guidelines
 - Think in English, generate responses in English. All Markdown content written to project files (e.g., requirements.md, design.md, tasks.md, research.md, validation reports) MUST be written in the target language configured for this specification (see spec.json.language).
+- **Note**: `spec.json.language` controls the language of spec document content only. It does NOT control the conversation response language. The conversation language is governed by the Language Policy in AGENTS.md.
 
 ## Minimal Workflow
 - Phase 0 (optional): `/kiro:steering`, `/kiro:steering-custom`
@@ -33,6 +34,7 @@ Kiro-style Spec Driven Development implementation on AI-DLC (AI Development Life
   - `/kiro:spec-tasks {feature} [-y]`
 - Phase 2 (Implementation): `/kiro:spec-impl {feature} [tasks]`
   - `/kiro:validate-impl {feature}` (optional: after implementation)
+  - `/kiro:spec-cleanup {feature}` (optional: organize specs post-implementation)
 - Progress check: `/kiro:spec-status {feature}` (use anytime)
 
 ## Development Rules

+ 3 - 4
README.md

@@ -81,16 +81,15 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 ## Dependencies
 
-- Node.js v18.x or v20.x
-- npm 6.x
-- pnpm 9.x
+- Node.js v24.x
+- pnpm 10.x
 - [Turborepo](https://turbo.build/repo)
 - MongoDB v6.x or v8.x
 
 ### Optional Dependencies
 
 - Redis 3.x
-- ElasticSearch 7.x or 8.x (needed when using Full-text search)
+- ElasticSearch 7.x or 8.x or 9.x (needed when using Full-text search)
   - **CAUTION: Following plugins are required**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 3 - 4
README_JP.md

@@ -81,16 +81,15 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 ## 依存関係
 
-- Node.js v18.x or v20.x
-- npm 6.x
-- pnpm 9.x
+- Node.js v24.x
+- pnpm 10.x
 - [Turborepo](https://turbo.build/repo)
 - MongoDB v6.x or v8.x
 
 ### オプションの依存関係
 
 - Redis 3.x
-- ElasticSearch 7.x or 8.x (needed when using Full-text search)
+- ElasticSearch 7.x or 8.x or 9.x (needed when using Full-text search)
   - **注意: 次のプラグインが必要です**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 74 - 0
apps/app/.claude/rules/package-dependencies.md

@@ -0,0 +1,74 @@
+# Package Dependency Classification (Turbopack)
+
+## The Rule
+
+> Any package that appears in `apps/app/.next/node_modules/` after a production build MUST be listed under `dependencies`, not `devDependencies`.
+
+Turbopack externalises packages by generating runtime symlinks in `.next/node_modules/`. `pnpm deploy --prod` excludes `devDependencies`, so any externalised package missing from `dependencies` causes `ERR_MODULE_NOT_FOUND` in production.
+
+## How to Classify a New Package
+
+**Step 1 — Build and check:**
+
+```bash
+turbo run build --filter @growi/app
+ls apps/app/.next/node_modules/ | grep <package-name>
+```
+
+- **Found** → `dependencies`
+- **Not found** → `devDependencies` (if runtime code) or `devDependencies` (if build/test only)
+
+**Step 2 — If unsure, check the import site:**
+
+| Import pattern | Classification |
+|---|---|
+| `import foo from 'pkg'` at module level in SSR-executed code | `dependencies` |
+| `import type { Foo } from 'pkg'` only | `devDependencies` (type-erased at build) |
+| `await import('pkg')` inside `useEffect` / event handler | Check `.next/node_modules/` — may still be externalised (see `fix-broken-next-symlinks` skill) |
+| Used only in `*.spec.ts`, build scripts, or CI | `devDependencies` |
+
+## Common Misconceptions
+
+**`dynamic({ ssr: false })` does NOT prevent Turbopack externalisation.**
+It skips HTML rendering for that component but Turbopack still externalises packages found via static import analysis inside the dynamically-loaded file.
+
+**`useEffect`-guarded `import()` does NOT guarantee devDependencies.**
+Bootstrap and i18next backends are loaded this way yet still appear in `.next/node_modules/` due to transitive imports.
+
+## Packages Confirmed as devDependencies (Verified)
+
+These were successfully removed from production artifact by eliminating their SSR import path:
+
+| Package | Technique |
+|---|---|
+| `fslightbox-react` | Replaced static import with `import()` inside `useEffect` in `LightBox.tsx` |
+| `socket.io-client` | Replaced static import with `await import()` inside `useEffect` in `admin/states/socket-io.ts` |
+| `@emoji-mart/data` | Replaced runtime import with bundled static JSON (`emoji-native-lookup.json`) |
+
+## Verifying the Production Artifact
+
+### Level 1 — Externalisation check (30–60 s, local, incremental)
+
+Just want to know if a package gets externalised by Turbopack?
+
+```bash
+turbo run build --filter @growi/app
+ls apps/app/.next/node_modules/ | grep <package-name>
+# Found → dependencies required
+# Not found → devDependencies is safe
+```
+
+Turbopack build is incremental via cache, so subsequent runs after the first are fast.
+
+### Level 2 — CI (`reusable-app-prod.yml`, authoritative)
+
+Trigger via `workflow_dispatch` before merging. Runs two jobs:
+
+1. **`build-prod`**: `turbo run build` → `assemble-prod.sh` → **`check-next-symlinks.sh`** → archives production tarball
+2. **`launch-prod`**: extracts the tarball into a clean isolated directory (no workspace-root `node_modules`), runs `pnpm run server:ci`
+
+`check-next-symlinks.sh` scans every symlink in `.next/node_modules/` and fails the build if any are broken (except `fslightbox-react` which is intentionally broken but harmless). This catches classification errors regardless of which code paths are exercised at runtime.
+
+`server:ci` = `node dist/server/app.js --ci`: the server starts fully (loading all modules), then immediately exits with code 0. If any module fails to load (`ERR_MODULE_NOT_FOUND`), the process exits with code 1, failing the CI job.
+
+This exactly matches Docker production (no workspace fallback). A `build-prod` or `launch-prod` failure definitively means a missing `dependencies` entry.

+ 47 - 2
apps/app/.claude/skills/app-commands/SKILL.md

@@ -8,6 +8,37 @@ user-invocable: false
 
 Commands specific to the main GROWI application. For global commands (turbo, pnpm), see the global `tech-stack` skill.
 
+## Quality Check Commands
+
+**IMPORTANT**: Distinguish between Turborepo tasks and package-specific scripts.
+
+### Turbo Tasks vs Package Scripts
+
+| Task | Turborepo (turbo.json) | Package Script (package.json) |
+|------|------------------------|-------------------------------|
+| `lint` | ✅ Yes | ✅ Yes (runs all lint:\*) |
+| `test` | ✅ Yes | ✅ Yes |
+| `build` | ✅ Yes | ✅ Yes |
+| `lint:typecheck` | ❌ No | ✅ Yes |
+| `lint:biome` | ❌ No | ✅ Yes |
+| `lint:styles` | ❌ No | ✅ Yes |
+
+### Recommended Commands
+
+```bash
+# Run ALL quality checks (uses Turborepo caching)
+turbo run lint --filter @growi/app
+turbo run test --filter @growi/app
+turbo run build --filter @growi/app
+
+# Run INDIVIDUAL lint checks (package-specific scripts, from apps/app directory)
+pnpm run lint:typecheck   # TypeScript only
+pnpm run lint:biome       # Biome only
+pnpm run lint:styles      # Stylelint only
+```
+
+> **Running individual test files**: See the `testing` rule (`.claude/rules/testing.md`).
+
 ## Quick Reference
 
 | Task | Command |
@@ -70,10 +101,12 @@ Generated specs output to `tmp/openapi-spec-apiv3.json`.
 
 ```bash
 # Development mode
-pnpm run dev:pre:styles
+pnpm run dev:pre:styles-commons
+pnpm run dev:pre:styles-components
 
 # Production mode
-pnpm run pre:styles
+pnpm run pre:styles-commons
+pnpm run pre:styles-commons-components
 ```
 
 Pre-builds SCSS styles into CSS bundles using Vite.
@@ -109,6 +142,18 @@ pnpm run version:prerelease
 pnpm run version:preminor
 ```
 
+## Build Measurement
+
+```bash
+# Measure module count KPI (cleans .next, starts next dev, triggers compilation)
+./bin/measure-chunk-stats.sh           # default port 3099
+./bin/measure-chunk-stats.sh 3001      # custom port
+```
+
+Output: `[ChunkModuleStats] initial: N, async-only: N, total: N`
+
+For details on module optimization and baselines, see the `build-optimization` skill.
+
 ## Production
 
 ```bash

+ 1 - 1
apps/app/.claude/skills/app-specific-patterns/SKILL.md

@@ -6,7 +6,7 @@ user-invocable: false
 
 # App Specific Patterns (apps/app)
 
-For general testing patterns, see the global `testing-patterns-with-vitest` skill.
+For general testing patterns, see the global `.claude/skills/learned/essential-test-patterns` and `.claude/skills/learned/essential-test-design` skills.
 
 ## Next.js Pages Router
 

+ 124 - 0
apps/app/.claude/skills/build-optimization/SKILL.md

@@ -0,0 +1,124 @@
+---
+name: build-optimization
+description: GROWI apps/app Turbopack configuration, module optimization, and build measurement tooling. Auto-invoked when working in apps/app.
+user-invocable: false
+---
+
+# Build Optimization (apps/app)
+
+## Next.js Version & Bundler
+
+- **Next.js 16** (`^16.0.0`) with **Turbopack** bundler (default)
+- Build: `next build`; Dev: Express server calls `next({ dev })` which uses Turbopack by default
+- React stays at `^18.2.0` — Pages Router has full React 18 support in v16
+- Webpack has been fully removed (no `webpack()` hook, no `--webpack` flag)
+
+## Turbopack Configuration
+
+### Custom Loader Rules (`turbopack.rules`)
+
+| Rule | Pattern | Condition | Purpose |
+|------|---------|-----------|---------|
+| superjson-ssr-loader | `*.page.ts`, `*.page.tsx` | `{ not: 'browser' }` (server-only) | Auto-wraps `getServerSideProps` with SuperJSON serialization |
+
+- Loaders are registered in `next.config.ts` under `turbopack.rules`
+- `condition: { not: 'browser' }` restricts the loader to server-side compilation only
+- `as: '*.ts'` / `as: '*.tsx'` tells Turbopack to continue processing the transformed output as TypeScript
+
+### Resolve Aliases (`turbopack.resolveAlias`)
+
+4 server-only packages + `fs` are aliased to `./src/lib/empty-module.ts` in browser context:
+
+| Package | Reason |
+|---------|--------|
+| `fs` | Node.js built-in, not available in browser |
+| `mongoose` | MongoDB driver, server-only |
+| `i18next-fs-backend` | File-system i18n loader, server-only |
+| `core-js` | Server-side polyfills |
+
+- Uses conditional `{ browser: './src/lib/empty-module.ts' }` syntax so server-side resolution is unaffected
+- `resolveAlias` requires **relative paths** (e.g., `./src/lib/empty-module.ts`), not absolute paths — absolute paths cause "server relative imports are not implemented yet" errors
+- If a new server-only package leaks into the client bundle, add it to `resolveAlias` with the same pattern
+
+## SuperJSON Serialization Architecture
+
+The `next-superjson` SWC plugin was replaced by a custom loader:
+
+- **Build time**: `superjson-ssr-loader.ts` auto-wraps `getServerSideProps` in `.page.{ts,tsx}` files with `withSuperJSONProps()` via Turbopack `rules`
+- **Runtime (server)**: `withSuperJSONProps()` in `src/pages/utils/superjson-ssr.ts` serializes props via superjson
+- **Runtime (client)**: `_app.page.tsx` calls `deserializeSuperJSONProps()` for centralized deserialization
+- **No per-page changes needed** — new pages automatically get superjson serialization
+- Custom serializers registered in `_app.page.tsx` (ObjectId, PageRevisionWithMeta)
+
+## CSS Modules Turbopack Compatibility
+
+### `:global` Syntax
+
+Turbopack only supports the **function form** `:global(...)`. The block form `:global { ... }` is NOT supported:
+
+```scss
+// WRONG — Turbopack rejects this
+.parent :global {
+  .child { color: red; }
+}
+
+// CORRECT — function form
+.parent {
+  :global(.child) { color: red; }
+}
+```
+
+Nested blocks must also use function form:
+
+```scss
+// WRONG
+.parent :global {
+  .child {
+    .grandchild { }
+  }
+}
+
+// CORRECT
+.parent {
+  :global(.child) {
+    :global(.grandchild) { }
+  }
+}
+```
+
+### Other Turbopack CSS Restrictions
+
+- **Standalone `:local` / `&:local`**: Not supported. Inside `:global(...)`, properties are locally scoped by default — remove `&:local` wrappers
+- **`@extend` with `:global()`**: `@extend .class` fails when target is wrapped in `:global(.class)` — Sass doesn't match them as the same selector. Use shared selector groups (comma-separated selectors) instead
+- **IE CSS hacks**: `*zoom:1`, `*display:inline`, `filter:alpha()` cannot be parsed by Turbopack's CSS parser (lightningcss). Avoid CSS files containing these hacks
+
+### Vendor CSS Imports
+
+Global CSS cannot be imported from files other than `_app.page.tsx` under Turbopack Pages Router. See the `vendor-styles-components` skill for the precompilation system that handles per-component vendor CSS.
+
+## Module Optimization Configuration
+
+- `serverExternalPackages: ['handsontable']` — packages excluded from server-side bundling
+- `optimizePackageImports` — 11 `@growi/*` packages configured (expansion to third-party packages was tested and reverted — it increased dev module count)
+
+## Effective Module Reduction Techniques
+
+Techniques that have proven effective for reducing module count, ordered by typical impact:
+
+| Technique | When to Use |
+|-----------|-------------|
+| `next/dynamic({ ssr: true })` | Heavy rendering pipelines (markdown, code highlighting) that can be deferred to async chunks while preserving SSR |
+| `next/dynamic({ ssr: false })` | Client-only heavy components (e.g., Mermaid diagrams, interactive editors) |
+| Subpath imports | Packages with large barrel exports (e.g., `date-fns/format` instead of `date-fns`) |
+| Deep ESM imports | Packages that re-export multiple engines via barrel (e.g., `react-syntax-highlighter/dist/esm/prism-async-light`) |
+| resolveAlias | Server-only packages leaking into client bundle via transitive imports |
+| Lightweight replacements | Replace large libraries used for a single feature (e.g., `tinykeys` instead of `react-hotkeys`, regex instead of `validator`) |
+
+### Techniques That Did NOT Work
+
+- **Expanding `optimizePackageImports` to third-party packages** — In dev mode, this resolves individual sub-module files instead of barrel, resulting in MORE module entries. Reverted.
+- **Refactoring internal barrel exports** — Internal barrels (`states/`, `features/`) are small and well-scoped; refactoring had no measurable impact.
+
+## i18n HMR
+
+`I18NextHMRPlugin` was removed during the Turbopack migration. Translation file changes require a manual browser refresh. The performance gain from Turbopack (faster Fast Refresh overall) outweighs the loss of i18n-specific HMR. Monitor if `i18next-hmr` adds Turbopack support in the future.

+ 90 - 0
apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md

@@ -0,0 +1,90 @@
+---
+name: fix-broken-next-symlinks
+description: Fix broken symlinks in .next/node_modules/ — diagnose, decide allowlist vs dependencies, and verify
+---
+
+## IMPORTANT
+
+This document is a **mandatory step-by-step procedure**. When fixing broken symlinks, execute every step in order. In particular, verification **always** requires the full 3-command sequence: `build` → `assemble-prod.sh` → `check-next-symlinks.sh`. Never skip `assemble-prod.sh` — the symlink check is only meaningful after production assembly.
+
+## Problem
+
+Turbopack externalizes packages into `.next/node_modules/` as symlinks, even for packages imported only via dynamic `import()` inside `useEffect`. After `assemble-prod.sh` runs `pnpm deploy --prod`, `devDependencies` are excluded, breaking those symlinks. `check-next-symlinks.sh` detects these and fails the build.
+
+## Diagnosis
+
+### Step 1 — Reproduce locally
+
+```bash
+turbo run build --filter @growi/app
+bash apps/app/bin/assemble-prod.sh
+bash apps/app/bin/check-next-symlinks.sh
+```
+
+If the check reports `BROKEN: apps/app/.next/node_modules/<package>-<hash>`, proceed to Step 2.
+
+### Step 2 — Determine the fix
+
+Search all import sites of the broken package:
+
+```bash
+grep -rn "from ['\"]<package-name>['\"]" apps/app/src/
+grep -rn "import(['\"]<package-name>['\"])" apps/app/src/
+```
+
+Apply the decision tree:
+
+```
+Is the package imported ONLY via:
+  - `import type { ... } from 'pkg'`  (erased at compile time)
+  - `await import('pkg')` inside useEffect / event handler  (client-side only, never SSR)
+
+  YES → Add to ALLOWED_BROKEN in check-next-symlinks.sh  (Step 3a)
+  NO  → Move from devDependencies to dependencies          (Step 3b)
+```
+
+### Step 3a — Add to allowlist
+
+Edit `apps/app/bin/check-next-symlinks.sh`:
+
+```bash
+ALLOWED_BROKEN=(
+  fslightbox-react
+  @emoji-mart/data
+  @emoji-mart/react
+  socket.io-client
+  <new-package>          # <-- add here
+)
+```
+
+Use the bare package name (e.g., `socket.io-client`), not the hashed symlink name (`socket.io-client-46e5ba4d4c848156`).
+
+### Step 3b — Move to dependencies
+
+In `apps/app/package.json`, move the package from `devDependencies` to `dependencies`, then run `pnpm install`.
+
+### Step 4 — Verify the fix
+
+Re-run the full sequence:
+
+```bash
+turbo run build --filter @growi/app
+bash apps/app/bin/assemble-prod.sh
+bash apps/app/bin/check-next-symlinks.sh
+```
+
+Expected output: `OK: All apps/app/.next/node_modules symlinks resolve correctly.`
+
+## Example
+
+`socket.io-client` is used in two files:
+- `src/states/socket-io/global-socket.ts` — `import type` + `await import()` inside `useEffect`
+- `src/features/admin/states/socket-io.ts` — `import type` + `import()` inside `useEffect`
+
+Both are client-only dynamic imports → added to `ALLOWED_BROKEN`, stays as `devDependencies`.
+
+## When to Apply
+
+- CI fails at "Check for broken symlinks in .next/node_modules" step
+- `check-next-symlinks.sh` reports `BROKEN: apps/app/.next/node_modules/<package>-<hash>`
+- After adding a new package or changing import patterns in apps/app

+ 302 - 0
apps/app/.claude/skills/learned/page-save-origin-semantics/SKILL.md

@@ -0,0 +1,302 @@
+---
+name: page-save-origin-semantics
+description: Auto-invoked when modifying origin-based conflict detection, revision validation logic, or isUpdatable() method. Explains the two-stage origin check mechanism for conflict detection and its separation from diff detection.
+---
+
+# Page Save Origin Semantics
+
+## Problem
+
+When modifying page save logic, it's easy to accidentally break the carefully designed origin-based conflict detection system. The system uses a two-stage check mechanism (frontend + backend) to determine when revision validation should be enforced vs. bypassed for collaborative editing (Yjs).
+
+**Key Insight**: **Conflict detection (revision check)** and **diff detection (hasDiffToPrev)** serve different purposes and require separate logic.
+
+## Solution
+
+Understanding the two-stage origin check mechanism:
+
+### Stage 1: Frontend Determines revisionId Requirement
+
+```typescript
+// apps/app/src/client/components/PageEditor/PageEditor.tsx:158
+const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
+
+// lines 308-310
+const revisionId = isRevisionIdRequiredForPageUpdate
+  ? currentRevisionId
+  : undefined;
+```
+
+**Logic**: Check the **latest revision's origin** on the page:
+- If `origin === undefined` (legacy/API save) → Send `revisionId`
+- If `origin === "editor"` or `"view"` → Do NOT send `revisionId`
+
+### Stage 2: Backend Determines Conflict Check Behavior
+
+```javascript
+// apps/app/src/server/models/obsolete-page.js:167-172
+const ignoreLatestRevision =
+  origin === Origin.Editor &&
+  (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View);
+
+if (ignoreLatestRevision) {
+  return true;  // Bypass revision check
+}
+
+// Otherwise, enforce strict revision matching
+if (revision != previousRevision) {
+  return false;  // Reject save
+}
+return true;
+```
+
+**Logic**: Check **current request's origin** AND **latest revision's origin**:
+- If `origin === "editor"` AND latest is `"editor"` or `"view"` → Bypass revision check
+- Otherwise → Enforce strict revision ID matching
+
+## Origin Values
+
+Three types of page update methods (called "origin"):
+
+- **`Origin.Editor = "editor"`** - Save from editor mode (collaborative editing via Yjs)
+- **`Origin.View = "view"`** - Save from view mode
+  - Examples: HandsontableModal, DrawioModal editing
+- **`undefined`** - API-based saves or legacy pages
+
+## Origin Strength (強弱)
+
+**Basic Rule**: Page updates require the previous revision ID in the request. If the latest revision doesn't match, the server rejects the request.
+
+**Exception - Editor origin is stronger than View origin**:
+- **UX Goal**: Avoid `Posted param "revisionId" is outdated` errors when multiple members are using the Editor and View changes interrupt them
+- **Special Case**: When the latest revision's origin is View, Editor origin requests can update WITHOUT requiring revision ID
+
+### Origin Strength Matrix
+
+|        | Latest Revision: Editor | Latest Revision: View | Latest Revision: API |
+| ------ | ----------------------- | --------------------- | -------------------- |
+| **Request: Editor** | ⭕️ Bypass revision check | ⭕️ Bypass revision check | ❌ Strict check |
+| **Request: View**   | ❌ Strict check | ❌ Strict check | ❌ Strict check |
+| **Request: API**    | ❌ Strict check | ❌ Strict check | ❌ Strict check |
+
+**Reading the table**:
+- ⭕️ = Revision check bypassed (revisionId not required)
+- ❌ = Strict revision check required (revisionId must match)
+
+## Behavior by Scenario
+
+| Latest Revision Origin | Request Origin | revisionId Sent? | Revision Check | Use Case |
+|------------------------|----------------|------------------|----------------|----------|
+| `editor` or `view` | `editor` | ❌ No | ✅ Bypassed | Normal Editor use (most common) |
+| `undefined` | `editor` | ✅ Yes | ✅ Enforced | Legacy page in Editor |
+| `undefined` | `undefined` (API) | ✅ Yes (required) | ✅ Enforced | API save |
+
+## Example: Server-Side Logic Respecting Origin Semantics
+
+When adding server-side functionality that needs previous revision data:
+
+```typescript
+// ✅ CORRECT: Separate concerns - conflict detection vs. diff detection
+let previousRevision: IRevisionHasId | null = null;
+
+// Priority 1: Use provided revisionId (for conflict detection)
+if (sanitizeRevisionId != null) {
+  previousRevision = await Revision.findById(sanitizeRevisionId);
+}
+
+// Priority 2: Fallback to currentPage.revision (for other purposes like diff detection)
+if (previousRevision == null && currentPage.revision != null) {
+  previousRevision = await Revision.findById(currentPage.revision);
+}
+
+const previousBody = previousRevision?.body ?? null;
+
+// Continue with existing conflict detection logic (unchanged)
+if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
+  // ... return conflict error
+}
+
+// Use previousBody for diff detection or other purposes
+updatedPage = await crowi.pageService.updatePage(
+  currentPage,
+  body,
+  previousBody,  // ← Available regardless of conflict detection logic
+  req.user,
+  options,
+);
+```
+
+```typescript
+// ❌ WRONG: Forcing frontend to always send revisionId
+const revisionId = currentRevisionId;  // Always send, regardless of origin
+// This breaks Yjs collaborative editing semantics!
+```
+
+```typescript
+// ❌ WRONG: Changing backend conflict detection logic
+// Don't modify isUpdatable() unless you fully understand the implications
+// for collaborative editing
+```
+
+## When to Apply
+
+**Always consider this pattern when**:
+- Modifying page save/update API handlers
+- Adding functionality that needs previous revision data
+- Working on conflict detection or revision validation logic
+- Implementing features that interact with page history
+- Debugging save operation issues
+
+**Key Principles**:
+1. **Do NOT modify frontend revisionId logic** unless explicitly required for conflict detection
+2. **Do NOT modify isUpdatable() logic** unless fixing conflict detection bugs
+3. **Separate concerns**: Conflict detection ≠ Other revision-based features (diff detection, history, etc.)
+4. **Server-side fallback**: If you need previous revision data when revisionId is not provided, fetch from `currentPage.revision`
+
+## Detailed Scenario Analysis
+
+### Scenario A: Normal Editor Mode (Most Common Case)
+
+**Latest revision has `origin=editor`**:
+
+1. **Frontend Logic**:
+   - `isRevisionIdRequiredForPageUpdate = false` (latest revision origin is not undefined)
+   - Does NOT send `revisionId` in request
+   - Sends `origin: Origin.Editor`
+
+2. **API Layer**:
+   ```typescript
+   previousRevision = await Revision.findById(undefined);  // → null
+   ```
+   Result: No previousRevision fetched via revisionId
+
+3. **Backend Conflict Check** (`isUpdatable`):
+   ```javascript
+   ignoreLatestRevision =
+     (Origin.Editor === Origin.Editor) &&
+     (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View)
+   // → true (latest revision is editor)
+   return true;  // Bypass revision check
+   ```
+   Result: ✅ Save succeeds without revision validation
+
+4. **Impact on Other Features**:
+   - If you need previousRevision data (e.g., for diff detection), it won't be available unless you implement server-side fallback
+   - This is where `currentPage.revision` fallback becomes necessary
+
+### Scenario B: Legacy Page in Editor Mode
+
+**Latest revision has `origin=undefined`**:
+
+1. **Frontend Logic**:
+   - `isRevisionIdRequiredForPageUpdate = true` (latest revision origin is undefined)
+   - Sends `revisionId` in request
+   - Sends `origin: Origin.Editor`
+
+2. **API Layer**:
+   ```typescript
+   previousRevision = await Revision.findById(sanitizeRevisionId);  // → revision object
+   ```
+   Result: previousRevision fetched successfully
+
+3. **Backend Conflict Check** (`isUpdatable`):
+   ```javascript
+   ignoreLatestRevision =
+     (Origin.Editor === Origin.Editor) &&
+     (latestRevisionOrigin === undefined)
+   // → false (latest revision is undefined, not editor/view)
+
+   // Strict revision check
+   if (revision != sanitizeRevisionId) {
+     return false;  // Reject if mismatch
+   }
+   return true;
+   ```
+   Result: ✅ Save succeeds only if revisionId matches
+
+4. **Impact on Other Features**:
+   - previousRevision data is available
+   - All revision-based features work correctly
+
+### Scenario C: API-Based Save
+
+**Request has `origin=undefined` or omitted**:
+
+1. **Frontend**: Not applicable (API client)
+
+2. **API Layer**:
+   - API client MUST send `revisionId` in request
+   - `previousRevision = await Revision.findById(sanitizeRevisionId)`
+
+3. **Backend Conflict Check** (`isUpdatable`):
+   ```javascript
+   ignoreLatestRevision =
+     (undefined === Origin.Editor) && ...
+   // → false
+
+   // Strict revision check
+   if (revision != sanitizeRevisionId) {
+     return false;
+   }
+   return true;
+   ```
+   Result: Strict validation enforced
+
+## Root Cause: Why This Separation Matters
+
+**Historical Context**: At some point, the frontend stopped sending `previousRevision` (revisionId) for certain scenarios to support Yjs collaborative editing. This broke features that relied on previousRevision data being available.
+
+**The Core Issue**:
+- **Conflict detection** needs to know "Is this save conflicting with another user's changes?" (Answered by revision check)
+- **Diff detection** needs to know "Did the content actually change?" (Answered by comparing body)
+- **Current implementation conflates these**: When conflict detection is bypassed, previousRevision is not fetched, breaking diff detection
+
+**The Solution Pattern**:
+```typescript
+// Separate the two concerns:
+
+// 1. Fetch previousRevision for data purposes (diff detection, history, etc.)
+let previousRevision: IRevisionHasId | null = null;
+if (sanitizeRevisionId != null) {
+  previousRevision = await Revision.findById(sanitizeRevisionId);
+} else if (currentPage.revision != null) {
+  previousRevision = await Revision.findById(currentPage.revision);  // Fallback
+}
+
+// 2. Use previousRevision data for your feature
+const previousBody = previousRevision?.body ?? null;
+
+// 3. Conflict detection happens independently via isUpdatable()
+if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
+  // Return conflict error
+}
+```
+
+## Reference
+
+**Official Documentation**:
+- https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
+
+**Related Files**:
+- Frontend: `apps/app/src/client/components/PageEditor/PageEditor.tsx` (lines 158, 240, 308-310)
+- Backend: `apps/app/src/server/models/obsolete-page.js` (lines 159-182, isUpdatable method)
+- API: `apps/app/src/server/routes/apiv3/page/update-page.ts` (lines 260-282, conflict check)
+- Interface: `packages/core/src/interfaces/revision.ts` (lines 6-11, Origin definition)
+
+## Common Pitfalls
+
+1. **Assuming revisionId is always available**: It's not! Editor mode with recent editor/view saves omits it by design.
+2. **Conflating conflict detection with other features**: They serve different purposes and need separate logic.
+3. **Breaking Yjs collaborative editing**: Forcing revisionId to always be sent breaks the bypass mechanism.
+4. **Ignoring origin values**: The system behavior changes significantly based on origin combinations.
+
+## Lessons Learned
+
+This pattern was identified during the "improve-unchanged-revision" feature implementation, where the initial assumption was that frontend should always send `revisionId` for diff detection. Deep analysis revealed:
+
+- The frontend logic is correct for conflict detection and should NOT be changed
+- Server-side fallback is the correct approach to get previous revision data
+- Two-stage checking is intentional and critical for Yjs collaborative editing
+- Conflict detection and diff detection must be separated
+
+**Key Takeaway**: Always understand the existing architectural patterns before proposing changes. What appears to be a "fix" might actually break carefully designed functionality.

+ 116 - 0
apps/app/.claude/skills/vendor-styles-components/SKILL.md

@@ -0,0 +1,116 @@
+---
+name: vendor-styles-components
+description: Vendor CSS precompilation system for Turbopack compatibility. How to add third-party CSS to components without violating Pages Router global CSS restriction. Auto-invoked when working in apps/app.
+---
+
+# Vendor CSS Precompilation (apps/app)
+
+## Problem
+
+Turbopack (Pages Router) strictly enforces: **global CSS can only be imported from `_app.page.tsx`**. Components cannot `import 'package/style.css'` directly — Turbopack rejects these at compile time.
+
+Centralizing all vendor CSS in `_app` would degrade FCP for pages that don't need those styles.
+
+## Solution: Two-Track Vendor CSS System
+
+### Commons Track (globally shared CSS)
+
+- **File**: `src/styles/vendor.scss`
+- **For**: CSS needed on most pages (e.g., `simplebar-react`)
+- **Mechanism**: Compiled via `vite.vendor-styles-commons.ts` into `src/styles/prebuilt/`
+- **Imported from**: `_app.page.tsx`
+
+### Components Track (component-specific CSS)
+
+- **For**: CSS needed only by specific components
+- **Mechanism**: Vite precompiles `*.vendor-styles.ts` entry points into `*.vendor-styles.prebuilt.ts` using `?inline` CSS import suffix
+- **Output**: Pure JS modules (no CSS imports) — Turbopack sees them as regular JS
+
+## How It Works
+
+1. **Entry point** (`ComponentName.vendor-styles.ts`): imports CSS via Vite `?inline` suffix, which inlines CSS as a string
+2. **Runtime injection**: the entry point creates a `<style>` tag and appends CSS to `document.head`
+3. **Vite prebuild** (`pre:styles-components` Turborepo task): compiles entry points into `*.vendor-styles.prebuilt.ts`
+4. **Component import**: imports the `.prebuilt.ts` file instead of raw CSS
+
+### Entry Point Template
+
+```typescript
+// @ts-nocheck -- Processed by Vite only; ?inline is a Vite-specific import suffix
+import css from 'some-package/dist/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css;
+document.head.appendChild(s);
+```
+
+For multiple CSS sources in one component:
+
+```typescript
+// @ts-nocheck
+import css1 from 'package-a/style.css?inline';
+import css2 from 'package-b/style.css?inline';
+
+const s = document.createElement('style');
+s.textContent = css1 + css2;
+document.head.appendChild(s);
+```
+
+## Current Entry Points
+
+| Entry Point | CSS Sources | Consuming Components |
+|---|---|---|
+| `Renderer.vendor-styles.ts` | `@growi/remark-lsx`, `@growi/remark-attachment-refs`, `katex` | renderer.tsx |
+| `GrowiEditor.vendor-styles.ts` | `@growi/editor` | PageEditor, CommentEditor |
+| `HandsontableModal.vendor-styles.ts` | `handsontable` (non-full variant) | HandsontableModal |
+| `DateRangePicker.vendor-styles.ts` | `react-datepicker` | DateRangePicker |
+| `RevisionDiff.vendor-styles.ts` | `diff2html` | RevisionDiff |
+| `DrawioViewerWithEditButton.vendor-styles.ts` | `@growi/remark-drawio` | DrawioViewerWithEditButton |
+| `ImageCropModal.vendor-styles.ts` | `react-image-crop` | ImageCropModal |
+| `Presentation.vendor-styles.ts` | `@growi/presentation` | Presentation, Slides |
+
+## Adding New Vendor CSS
+
+1. Create `{ComponentName}.vendor-styles.ts` next to the consuming component:
+   ```typescript
+   // @ts-nocheck
+   import css from 'new-package/dist/style.css?inline';
+   const s = document.createElement('style');
+   s.textContent = css;
+   document.head.appendChild(s);
+   ```
+2. In the component, replace `import 'new-package/dist/style.css'` with:
+   ```typescript
+   import './ComponentName.vendor-styles.prebuilt';
+   ```
+3. Run `pnpm run pre:styles-components` (or let Turborepo handle it during `dev`/`build`)
+4. The `.prebuilt.js` file is git-ignored and auto-generated
+
+**Decision guide**: If the CSS is needed on nearly every page, add it to the commons track (`vendor.scss`) instead.
+
+## Font/Asset Handling
+
+When vendor CSS references external assets (e.g., KaTeX `@font-face` with `url(fonts/KaTeX_*.woff2)`):
+
+- Vite emits asset files to `src/assets/` during build
+- The `moveAssetsToPublic` plugin (in `vite.vendor-styles-components.ts`) relocates them to `public/static/fonts/`
+- URL references in prebuilt JS are rewritten from `/assets/` to `/static/fonts/`
+- Fonts are served by the existing `express.static(crowi.publicDir)` middleware
+- Both `public/static/fonts/` and `src/**/*.vendor-styles.prebuilt.ts` are git-ignored
+
+## Build Pipeline Integration
+
+```
+turbo.json tasks:
+  pre:styles-components  →  build (dependency)
+  dev:pre:styles-components  →  dev (dependency)
+
+Inputs:  vite.vendor-styles-components.ts, src/**/*.vendor-styles.ts, package.json
+Outputs: src/**/*.vendor-styles.prebuilt.ts, public/static/fonts/**
+```
+
+## Important Caveats
+
+- **SSR**: CSS is injected via `<style>` tags at runtime — not available during SSR. Most consuming components use `next/dynamic({ ssr: false })`, so FOUC is not a practical concern
+- **`@ts-nocheck`**: Required because `?inline` is a Vite-specific import suffix not understood by TypeScript
+- **handsontable**: Must use `handsontable/dist/handsontable.css` (non-full, non-minified). The "full" variant (`handsontable.full.min.css`) contains IE CSS hacks (`*zoom:1`, `filter:alpha()`) that Turbopack's CSS parser (lightningcss) cannot parse. The "full" variant also includes Pikaday which is unused.

+ 2 - 0
apps/app/.env.development

@@ -4,6 +4,8 @@
 ##
 MIGRATIONS_DIR=src/migrations/
 
+NEXT_TELEMETRY_DISABLED=1
+
 APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760

+ 6 - 0
apps/app/.gitignore

@@ -1,6 +1,8 @@
 # next.js
 /.next/
 /out/
+next-env.d.ts
+next.config.js
 
 # test
 .reg
@@ -9,10 +11,14 @@
 /build/
 /dist/
 /transpiled/
+/config/**/*.js
+/config/**/*.d.ts
+/public/static/fonts
 /public/static/js
 /public/static/styles
 /public/uploads
 /src/styles/prebuilt
+/src/**/*.vendor-styles.prebuilt.*
 /tmp/
 
 # cache

+ 13 - 1
apps/app/AGENTS.md

@@ -29,6 +29,10 @@ pnpm run test                   # Run tests
 
 # Build
 pnpm run build                  # Build for production
+
+# Run Specific Tests
+pnpm vitest run yjs.integ       # Use partial file name
+pnpm vitest run helper.spec     # Vitest auto-finds matching files
 ```
 
 ### Key Directories
@@ -150,8 +154,16 @@ When working in this directory, these skills are automatically loaded:
 - **app-commands** - apps/app specific commands (migrations, OpenAPI, etc.)
 - **app-specific-patterns** - Jotai/SWR/Next.js patterns, testing
 
-Plus all global skills (monorepo-overview, tech-stack, testing-patterns-with-vitest).
+Plus all global skills (monorepo-overview, tech-stack).
 
 ---
 
 For detailed patterns and examples, refer to the Skills in `.claude/skills/`.
+
+## Rules (Always Applied)
+
+The following rules in `.claude/rules/` are always applied when working in this directory:
+
+| Rule | Description |
+|------|-------------|
+| **package-dependencies** | Turbopack dependency classification — when to use `dependencies` vs `devDependencies`, verification procedure |

+ 18 - 0
apps/app/CLAUDE.md

@@ -1 +1,19 @@
 @AGENTS.md
+
+# apps/app Specific Knowledge
+
+## Critical Architectural Patterns
+
+### Page Save Origin Semantics
+
+**IMPORTANT**: When working on page save, update, or revision operations, always consult the **page-save-origin-semantics** skill for understanding the two-stage origin check mechanism.
+
+**Key Concept**: Origin-based conflict detection uses a two-stage check (frontend + backend) to determine when revision validation should be enforced vs. bypassed for Yjs collaborative editing.
+
+**Critical Rule**: **Conflict detection (revision check)** and **other revision-based features (diff detection, history, etc.)** serve different purposes and require separate logic. Do NOT conflate them.
+
+**Documentation**:
+- Skill (auto-invoked): `.claude/skills/learned/page-save-origin-semantics/SKILL.md`
+
+**Common Pitfall**: Assuming `revisionId` is always available or forcing frontend to always send it will break Yjs collaborative editing.
+

+ 30 - 0
apps/app/bin/assemble-prod.sh

@@ -0,0 +1,30 @@
+#!/bin/bash
+# Assemble production artifacts for GROWI app.
+# Run from the workspace root.
+set -euo pipefail
+
+echo "[1/4] Collecting production dependencies..."
+rm -rf out
+pnpm deploy out --prod --legacy --filter @growi/app
+echo "[1/4] Done."
+
+echo "[2/4] Reorganizing node_modules..."
+rm -rf node_modules
+mv out/node_modules node_modules
+rm -rf apps/app/node_modules
+ln -sfn ../../node_modules apps/app/node_modules
+rm -rf out
+echo "[2/4] Done."
+
+echo "[3/4] Removing build cache..."
+rm -rf apps/app/.next/cache
+echo "[3/4] Done."
+
+# Provide a CJS runtime config so the production server can load it without TypeScript.
+# next.config.js takes precedence over next.config.ts in Next.js, so the .ts file
+# is left in place but effectively ignored at runtime.
+echo "[4/4] Installing runtime next.config.js..."
+cp apps/app/next.config.prod.cjs apps/app/next.config.js
+echo "[4/4] Done."
+
+echo "Assembly complete."

+ 44 - 0
apps/app/bin/check-next-symlinks.sh

@@ -0,0 +1,44 @@
+#!/bin/bash
+# Check that all .next/node_modules/ symlinks resolve correctly after assemble-prod.sh.
+# Usage: bash apps/app/bin/check-next-symlinks.sh (from monorepo root)
+set -euo pipefail
+
+NEXT_MODULES="apps/app/.next/node_modules"
+
+# Packages that are intentionally broken symlinks.
+# These are only imported via useEffect + dynamic import() and never accessed during SSR.
+ALLOWED_BROKEN=(
+  fslightbox-react
+  @emoji-mart/data
+  @emoji-mart/react
+  socket.io-client
+)
+
+# Build a grep -v pattern from the allowlist
+grep_args=()
+for pkg in "${ALLOWED_BROKEN[@]}"; do
+  grep_args+=(-e "$pkg")
+done
+
+broken=$(find "$NEXT_MODULES" -maxdepth 2 -type l | while read -r link; do
+  linkdir=$(dirname "$link")
+  target=$(readlink "$link")
+  resolved=$(cd "$linkdir" 2>/dev/null && realpath -m "$target" 2>/dev/null || echo "UNRESOLVABLE")
+  { [ "$resolved" = "UNRESOLVABLE" ] || [ ! -e "$resolved" ]; } && echo "BROKEN: $link"
+done | grep -v "${grep_args[@]}" || true)
+
+if [ -n "$broken" ]; then
+  echo "ERROR: Broken symlinks found in $NEXT_MODULES:"
+  echo "$broken"
+  echo ""
+  echo "Each broken package must be either:"
+  echo "  1. Moved from devDependencies to dependencies in apps/app/package.json"
+  echo "  2. Added to ALLOWED_BROKEN in this script (if only used via useEffect + dynamic import)"
+  echo ""
+  echo "Fix: Follow the step-by-step procedure in apps/app/.claude/skills/learned/fix-broken-next-symlinks/SKILL.md"
+  echo "     You MUST execute every step in order — do NOT skip assemble-prod.sh when verifying."
+  echo "Ref: apps/app/.claude/rules/package-dependencies.md"
+  exit 1
+fi
+
+echo "OK: All $NEXT_MODULES symlinks resolve correctly."

+ 60 - 0
apps/app/bin/measure-chunk-stats.sh

@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Measure ChunkModuleStats (initial / async-only / total) for [[...path]] page.
+# Usage: ./bin/measure-chunk-stats.sh [port]
+set -euo pipefail
+
+PORT="${1:-3099}"
+LOG=$(mktemp /tmp/chunk-stats-XXXXXX.log)
+
+cleanup() {
+  local pids
+  pids=$(lsof -ti :"$PORT" 2>/dev/null || true)
+  if [ -n "$pids" ]; then
+    kill -9 $pids 2>/dev/null || true
+  fi
+  rm -f "$LOG"
+}
+trap cleanup EXIT
+
+# 1. Ensure port is free
+cleanup_pids=$(lsof -ti :"$PORT" 2>/dev/null || true)
+if [ -n "$cleanup_pids" ]; then
+  kill -9 $cleanup_pids 2>/dev/null || true
+  sleep 1
+fi
+
+# 2. Clean .next dev cache (v16 uses .next/dev for isolated dev builds)
+rm -rf "$(dirname "$0")/../.next/dev"
+
+# 3. Start Next.js dev server (--webpack to opt out of Turbopack default in v16)
+cd "$(dirname "$0")/.."
+npx next dev --webpack -p "$PORT" > "$LOG" 2>&1 &
+NEXT_PID=$!
+
+# 4. Wait for server ready
+echo "Waiting for Next.js to start on port $PORT ..."
+for i in $(seq 1 30); do
+  if grep -q "Local:" "$LOG" 2>/dev/null; then
+    break
+  fi
+  sleep 1
+done
+
+# 5. Trigger compilation
+echo "Triggering compilation ..."
+curl -s -o /dev/null http://localhost:"$PORT"/
+
+# 6. Wait for ChunkModuleStats output (non-zero initial)
+echo "Waiting for compilation ..."
+for i in $(seq 1 120); do
+  if grep -qP 'ChunkModuleStats\] initial: [1-9]' "$LOG" 2>/dev/null; then
+    break
+  fi
+  sleep 2
+done
+
+# 7. Print results
+echo ""
+echo "=== Results ==="
+grep -E 'ChunkModuleStats|Compiled.*modules' "$LOG" | grep -v 'initial: 0,' | head -5
+echo ""

+ 17 - 11
apps/app/bin/openapi/definition-apiv3.js

@@ -51,6 +51,18 @@ module.exports = {
         name: 'x-growi-transfer-key',
       },
     },
+    parameters: {
+      MimeTypePathParam: {
+        name: 'mimeType',
+        in: 'path',
+        required: true,
+        description: 'The MIME type to configure.',
+        schema: {
+          type: 'string',
+          example: 'image/png',
+        },
+      },
+    },
   },
   'x-tagGroups': [
     {
@@ -66,21 +78,11 @@ module.exports = {
         'ShareLinks',
         'Users',
         'UserUISettings',
-        '',
       ],
     },
     {
       name: 'User Personal Settings API',
-      tags: [
-        'GeneralSetting',
-        'EditorSetting',
-        'InAppNotificationSettings',
-        '',
-        '',
-        '',
-        '',
-        '',
-      ],
+      tags: ['GeneralSetting', 'EditorSetting', 'InAppNotificationSettings'],
     },
     {
       name: 'System Management API',
@@ -114,6 +116,10 @@ module.exports = {
         'Install',
       ],
     },
+    {
+      name: 'AI API',
+      tags: ['AI Tools'],
+    },
     {
       name: 'Public API',
       tags: ['Healthcheck', 'Statistics', '', '', '', '', '', ''],

+ 1 - 0
apps/app/bin/openapi/generate-spec-apiv3.sh

@@ -12,6 +12,7 @@ swagger-jsdoc \
   -d "${APP_PATH}/bin/openapi/definition-apiv3.js" \
   "${APP_PATH}/src/features/external-user-group/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/templates/server/routes/apiv3/*.ts" \
+  "${APP_PATH}/src/features/ai-tools/**/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/growi-plugin/server/routes/apiv3/**/*.ts" \
   "${APP_PATH}/src/server/routes/apiv3/**/*.{js,ts}" \
   "${APP_PATH}/src/server/routes/login.js" \

+ 42 - 0
apps/app/bin/postbuild-server.ts

@@ -0,0 +1,42 @@
+/**
+ * Post-build script for server compilation.
+ *
+ * tspc compiles both `src/` and `config/` (TypeScript files under config/),
+ * so the output directory (`transpiled/`) mirrors the source tree structure
+ * (e.g. `transpiled/src/`, `transpiled/config/`).
+ *
+ * Setting `rootDir: "src"` and `outDir: "dist"` in tsconfig would eliminate this script,
+ * but that would break once `config/` is included in the compilation.
+ *
+ * This script:
+ * 1. Extracts `transpiled/src/` into `dist/`
+ * 2. Copies compiled `transpiled/config/` files into `config/` so that
+ *    relative imports from `dist/` (e.g. `../../../config/logger/config.dev`)
+ *    resolve correctly at runtime.
+ */
+import { cpSync, existsSync, readdirSync, renameSync, rmSync } from 'node:fs';
+
+const TRANSPILED_DIR = 'transpiled';
+const DIST_DIR = 'dist';
+const SRC_SUBDIR = `${TRANSPILED_DIR}/src`;
+const CONFIG_SUBDIR = `${TRANSPILED_DIR}/config`;
+
+// List transpiled contents for debugging
+// biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
+console.log('Listing files under transpiled:');
+// biome-ignore lint/suspicious/noConsole: This is a build script, console output is expected.
+console.log(readdirSync(TRANSPILED_DIR).join('\n'));
+
+// Remove old dist
+rmSync(DIST_DIR, { recursive: true, force: true });
+
+// Move transpiled/src -> dist
+renameSync(SRC_SUBDIR, DIST_DIR);
+
+// Copy compiled config files to app root config/ so runtime imports resolve
+if (existsSync(CONFIG_SUBDIR)) {
+  cpSync(CONFIG_SUBDIR, 'config', { recursive: true, force: true });
+}
+
+// Remove leftover transpiled directory
+rmSync(TRANSPILED_DIR, { recursive: true, force: true });

+ 5 - 1
apps/app/config/logger/config.dev.js → apps/app/config/logger/config.dev.ts

@@ -1,4 +1,6 @@
-module.exports = {
+import type { LoggerConfig } from '@growi/logger';
+
+const config: LoggerConfig = {
   default: 'info',
 
   // 'express-session': 'debug',
@@ -47,3 +49,5 @@ module.exports = {
   'growi:service:openai': 'debug',
   'growi:middleware:access-token-parser:access-token': 'debug',
 };
+
+export default config;

+ 5 - 1
apps/app/config/logger/config.prod.js → apps/app/config/logger/config.prod.ts

@@ -1,6 +1,10 @@
-module.exports = {
+import type { LoggerConfig } from '@growi/logger';
+
+const config: LoggerConfig = {
   default: 'info',
 
   'growi:routes:login-passport': 'debug',
   'growi:service:PassportService': 'debug',
 };
+
+export default config;

+ 2 - 8
apps/app/config/next-i18next.config.js

@@ -1,5 +1,4 @@
 const isDev = process.env.NODE_ENV === 'development';
-
 // biome-ignore lint/style/useNodejsImportProtocol: ignore
 const path = require('path');
 
@@ -8,8 +7,6 @@ const { isServer } = require('@growi/core/dist/utils');
 
 const { defaultLang } = require('./i18next.config');
 
-const HMRPlugin = isDev ? require('i18next-hmr/plugin').HMRPlugin : undefined;
-
 /** @type {import('next-i18next').UserConfig} */
 module.exports = {
   ...require('./i18next.config').initOptions,
@@ -24,11 +21,8 @@ module.exports = {
 
   use: isDev
     ? isServer()
-      ? [new HMRPlugin({ webpack: { server: true } })]
-      : [
-          require('i18next-chained-backend').default,
-          new HMRPlugin({ webpack: { client: true } }),
-        ]
+      ? []
+      : [require('i18next-chained-backend').default]
     : [],
   backend: {
     backends: isServer()

+ 79 - 51
apps/app/docker/Dockerfile

@@ -1,110 +1,138 @@
-# syntax = docker/dockerfile:1.4
+# syntax=docker/dockerfile:1
 
+ARG NODE_VERSION=24
 ARG OPT_DIR="/opt"
 ARG PNPM_HOME="/root/.local/share/pnpm"
 
 ##
-## base
+## base — DHI dev image with pnpm + turbo
 ##
-FROM node:20-slim AS base
+FROM dhi.io/node:24-debian13-dev AS base
 
 ARG OPT_DIR
 ARG PNPM_HOME
 
 WORKDIR $OPT_DIR
 
-# install tools
+# Install build dependencies
 RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
     --mount=type=cache,target=/var/cache/apt,sharing=locked \
-  apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
+  apt-get update && apt-get install -y --no-install-recommends ca-certificates wget
 
-# install pnpm
-RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
+# Install pnpm (standalone script, no version hardcoding)
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL=/bin/sh sh -
 ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 
-# install turbo
+# Install turbo globally
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
   pnpm add turbo --global
 
 
+##
+## pruner — turbo prune for Docker-optimized monorepo subset
+##
+FROM base AS pruner
+
+ARG OPT_DIR
+
+WORKDIR $OPT_DIR
+
+COPY . .
+
+# Include @growi/pdf-converter because @growi/pdf-converter-client has a turbo
+# task dependency on @growi/pdf-converter#gen:swagger-spec (generates the OpenAPI
+# spec that orval uses to build the client). Without it, turbo cannot resolve
+# the cross-package task dependency in the pruned workspace.
+RUN turbo prune @growi/app @growi/pdf-converter --docker
+
 
 ##
-## builder
+## deps — dependency installation (layer cached when only source changes)
 ##
-FROM base AS builder
+FROM base AS deps
+
+ARG OPT_DIR
+ARG PNPM_HOME
 
 ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 
 WORKDIR $OPT_DIR
 
-COPY . .
+# Copy only package manifests and lockfile for dependency caching
+COPY --from=pruner $OPT_DIR/out/json/ .
 
+# Install build tools and dependencies
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
   pnpm add node-gyp --global
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
-  pnpm install ---frozen-lockfile
+  pnpm install --frozen-lockfile
 
-# build
+
+##
+## builder — build + produce artifacts
+##
+FROM deps AS builder
+
+ARG OPT_DIR
+
+WORKDIR $OPT_DIR
+
+# Copy full source on top of installed dependencies
+COPY --from=pruner $OPT_DIR/out/full/ .
+
+# turbo prune does not include root-level config files in its output.
+# tsconfig.base.json is referenced by most packages via "extends": "../../tsconfig.base.json"
+COPY tsconfig.base.json .
+
+# Build
 RUN turbo run clean
 RUN turbo run build --filter @growi/app
 
-# make artifacts
-RUN pnpm deploy out --prod --filter @growi/app
-RUN rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
-RUN rm -rf apps/app/.next/cache
-RUN tar -zcf /tmp/packages.tar.gz \
-  package.json \
-  apps/app/.next \
-  apps/app/config \
-  apps/app/dist \
-  apps/app/public \
-  apps/app/resource \
-  apps/app/tmp \
-  apps/app/.env.production* \
-  apps/app/next.config.js \
-  apps/app/package.json \
-  apps/app/node_modules
+# Produce artifacts
+RUN bash apps/app/bin/assemble-prod.sh
 
+# Stage artifacts into a clean directory for COPY --from
+RUN mkdir -p /tmp/release/apps/app && \
+  cp package.json /tmp/release/ && \
+  cp -a node_modules /tmp/release/ && \
+  cp -a apps/app/.next apps/app/config apps/app/dist apps/app/public \
+       apps/app/resource apps/app/tmp \
+       apps/app/package.json apps/app/node_modules \
+       apps/app/next.config.js \
+       /tmp/release/apps/app/ && \
+  (cp apps/app/.env.production* /tmp/release/apps/app/ 2>/dev/null || true)
 
 
 ##
-## release
+## release — DHI runtime (no shell, no additional binaries)
 ##
-FROM node:20-slim
-LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
+FROM dhi.io/node:24-debian13 AS release
 
 ARG OPT_DIR
 
 ENV NODE_ENV="production"
-
 ENV appDir="$OPT_DIR/growi"
 
-# Add gosu
-# see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
-RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
-    --mount=type=cache,target=/var/cache/apt,sharing=locked \
-  set -eux; \
-	apt-get update; \
-	apt-get install -y gosu; \
-	rm -rf /var/lib/apt/lists/*; \
-# verify that the binary works
-	gosu nobody true
-
-# extract artifacts as 'node' user
-USER node
+# Copy artifacts from builder (no shell required)
 WORKDIR ${appDir}
-RUN --mount=type=bind,from=builder,source=/tmp/packages.tar.gz,target=/tmp/packages.tar.gz \
-  tar -zxf /tmp/packages.tar.gz -C ${appDir}/
+COPY --from=builder --chown=node:node /tmp/release/ ${appDir}/
 
-COPY --chown=node:node --chmod=700 apps/app/docker/docker-entrypoint.sh /
+# Copy TypeScript entrypoint
+COPY --chown=node:node apps/app/docker/docker-entrypoint.ts /docker-entrypoint.ts
 
+# Switch back to root for entrypoint (it handles privilege drop)
 USER root
 WORKDIR ${appDir}/apps/app
 
+# OCI standard labels
+LABEL org.opencontainers.image.source="https://github.com/weseek/growi"
+LABEL org.opencontainers.image.title="GROWI"
+LABEL org.opencontainers.image.description="Team collaboration wiki using Markdown"
+LABEL org.opencontainers.image.vendor="WESEEK, Inc."
+
 VOLUME /data
 EXPOSE 3000
 
-ENTRYPOINT ["/docker-entrypoint.sh"]
-CMD ["npm run migrate && node -r dotenv-flow/config --expose_gc dist/server/app.js"]
+ENTRYPOINT ["node", "/docker-entrypoint.ts"]

+ 74 - 4
apps/app/docker/Dockerfile.dockerignore

@@ -1,9 +1,79 @@
+# ============================================================
+# Build artifacts and caches
+# ============================================================
 **/node_modules
-**/coverage
-**/Dockerfile
-**/*.dockerignore
-**/.pnpm-store
 **/.next
 **/.turbo
+**/.pnpm-store
+**/coverage
 out
+
+# ============================================================
+# Version control
+# ============================================================
+.git
+
+# ============================================================
+# Docker files (prevent recursive inclusion)
+# ============================================================
+**/Dockerfile
+**/*.dockerignore
+
+# ============================================================
+# Unrelated apps
+# ============================================================
 apps/slackbot-proxy
+
+# ============================================================
+# Test files
+# ============================================================
+**/*.spec.*
+**/*.test.*
+**/test/
+**/__tests__/
+**/playwright/
+
+# ============================================================
+# Documentation (no .md files are needed for build)
+# ============================================================
+**/*.md
+
+# ============================================================
+# Local environment overrides
+# ============================================================
+.env.local
+.env.*.local
+
+# ============================================================
+# IDE and editor settings
+# ============================================================
+.vscode
+.idea
+**/.DS_Store
+
+# ============================================================
+# CI/CD, DevOps, and project management
+# ============================================================
+.changeset
+.devcontainer
+.github
+aws
+bin
+
+# ============================================================
+# Linter, formatter, and tool configs (not needed for build)
+# ============================================================
+**/.editorconfig
+**/.markdownlint.yml
+**/.prettier*
+**/.stylelintrc*
+**/biome.json
+**/lefthook.yml
+
+# ============================================================
+# AI agent configuration
+# ============================================================
+**/.claude
+**/.kiro
+**/.mcp.json
+**/.serena

+ 11 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.4.4`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.4/apps/app/docker/Dockerfile)
+* [`7.5.0`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.5.0/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 
@@ -72,6 +72,16 @@ See [GROWI Docs: Admin Guide](https://docs.growi.org/en/admin-guide/) ([en](http
 
 - [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/admin-cookbook/env-vars.html)
 
+#### V8 Memory Management
+
+| Variable | Type | Default | Description |
+|----------|------|---------|-------------|
+| `V8_MAX_HEAP_SIZE` | int (MB) | (unset) | Explicitly specify the `--max-heap-size` value for Node.js |
+| `V8_OPTIMIZE_FOR_SIZE` | `"true"` / (unset) | (unset) | Enable the `--optimize-for-size` V8 flag to reduce memory usage |
+| `V8_LITE_MODE` | `"true"` / (unset) | (unset) | Enable the `--lite-mode` V8 flag to reduce memory usage at the cost of performance |
+
+**Heap size fallback behavior**: When `V8_MAX_HEAP_SIZE` is not set, the entrypoint automatically detects the container's memory limit via cgroup (v2/v1) and sets the heap size to 60% of the limit. If no cgroup limit is detected, V8's default heap behavior is used.
+
 
 Issues
 ------

Some files were not shown because too many files changed in this diff