Bladeren bron

Merge pull request #11228 from growilabs/master

Release v7.5.5
mergify[bot] 2 dagen geleden
bovenliggende
commit
8381fe3ca6
100 gewijzigde bestanden met toevoegingen van 2740 en 761 verwijderingen
  1. 0 287
      .claude/commands/tdd.md
  2. 0 66
      .claude/rules/lsp.md
  3. 46 0
      .claude/rules/mongodb-regex.md
  4. 39 1
      .claude/rules/testing.md
  5. 0 27
      .claude/settings.json
  6. 0 0
      .claude/skills/essential-test-design/SKILL.md
  7. 72 1
      .claude/skills/essential-test-patterns/SKILL.md
  8. 1 1
      .devcontainer/app/postCreateCommand.sh
  9. 0 5
      .devcontainer/pdf-converter/postCreateCommand.sh
  10. 1 1
      .github/workflows/ci-app-prod.yml
  11. 84 15
      .github/workflows/ci-app.yml
  12. 129 35
      .github/workflows/reusable-app-prod.yml
  13. 118 0
      .kiro/specs/access-token-parser/brief.md
  14. 285 0
      .kiro/specs/access-token-parser/design.md
  15. 117 0
      .kiro/specs/access-token-parser/requirements.md
  16. 163 0
      .kiro/specs/access-token-parser/research.md
  17. 22 0
      .kiro/specs/access-token-parser/spec.json
  18. 57 0
      .kiro/specs/access-token-parser/tasks.md
  19. 8 1
      .kiro/steering/tdd.md
  20. 2 2
      AGENTS.md
  21. 1 1
      apps/app/.claude/skills/app-specific-patterns/SKILL.md
  22. 2 0
      apps/app/.env.test
  23. 7 0
      apps/app/bin/openapi/definition-apiv1.js
  24. 7 0
      apps/app/bin/openapi/definition-apiv3.js
  25. 9 8
      apps/app/package.json
  26. 15 0
      apps/app/playwright/10-installer/install.spec.ts
  27. 77 0
      apps/app/playwright/23-editor/grant-preload-race.spec.ts
  28. 1 1
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx
  29. 1 1
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  30. 1 1
      apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx
  31. 56 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.spec.tsx
  32. 31 28
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx
  33. 7 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx
  34. 1 1
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx
  35. 6 7
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  36. 12 3
      apps/app/src/client/services/renderer/renderer.tsx
  37. 1 0
      apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts
  38. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  39. 4 2
      apps/app/src/features/openai/server/services/openai.ts
  40. 151 3
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  41. 22 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  42. 21 0
      apps/app/src/pages/general-page/configuration-props.ts
  43. 1 0
      apps/app/src/pages/general-page/index.ts
  44. 2 2
      apps/app/src/pages/share/[[...path]]/server-side-props.ts
  45. 6 0
      apps/app/src/server/crowi/express-init.js
  46. 29 28
      apps/app/src/server/crowi/index.ts
  47. 38 0
      apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts
  48. 3 8
      apps/app/src/server/middlewares/access-token-parser/access-token.ts
  49. 28 0
      apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts
  50. 3 10
      apps/app/src/server/middlewares/access-token-parser/api-token.ts
  51. 135 0
      apps/app/src/server/middlewares/access-token-parser/extract-access-token.spec.ts
  52. 31 0
      apps/app/src/server/middlewares/access-token-parser/extract-access-token.ts
  53. 21 0
      apps/app/src/server/middlewares/deny-uploads-direct-access.spec.ts
  54. 25 0
      apps/app/src/server/middlewares/deny-uploads-direct-access.ts
  55. 2 1
      apps/app/src/server/models/obsolete-page.js
  56. 8 7
      apps/app/src/server/models/page.ts
  57. 1 0
      apps/app/src/server/routes/apiv3/activity.ts
  58. 3 0
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  59. 6 0
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  60. 2 0
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  61. 4 0
      apps/app/src/server/routes/apiv3/import.ts
  62. 4 0
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  63. 4 0
      apps/app/src/server/routes/apiv3/page-listing.ts
  64. 2 1
      apps/app/src/server/routes/apiv3/search.js
  65. 1 0
      apps/app/src/server/routes/apiv3/user-activities.ts
  66. 2 1
      apps/app/src/server/routes/apiv3/users.js
  67. 59 0
      apps/app/src/server/service/attachment.spec.ts
  68. 15 1
      apps/app/src/server/service/attachment.ts
  69. 1 3
      apps/app/src/server/service/customize.ts
  70. 1 1
      apps/app/src/server/service/installer.ts
  71. 8 0
      apps/app/src/server/service/interfaces/search.ts
  72. 16 6
      apps/app/src/server/service/page-grant.ts
  73. 125 0
      apps/app/src/server/service/page/grant-preserve-on-update.integ.ts
  74. 12 5
      apps/app/src/server/service/page/index.ts
  75. 7 0
      apps/app/src/server/service/page/page-service.ts
  76. 82 0
      apps/app/src/server/service/search-delegator/elasticsearch.integ.ts
  77. 46 36
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  78. 0 121
      apps/app/src/server/service/search-delegator/mappings/mappings-es9-for-ci.ts
  79. 5 0
      apps/app/src/server/service/search-query.spec.ts
  80. 23 13
      apps/app/src/server/service/search.ts
  81. 1 1
      apps/app/src/server/service/search/search-service.integ.ts
  82. 1 0
      apps/app/src/states/ui/editor/index.ts
  83. 82 0
      apps/app/src/states/ui/editor/selected-grant.spec.ts
  84. 50 6
      apps/app/src/states/ui/editor/selected-grant.ts
  85. 90 0
      apps/app/src/states/ui/editor/use-sync-selected-grant.spec.tsx
  86. 33 0
      apps/app/src/states/ui/editor/use-sync-selected-grant.ts
  87. 14 0
      apps/app/test/setup/elasticsearch.ts
  88. 12 0
      apps/app/turbo.json
  89. 1 0
      apps/app/vitest.workspace.mts
  90. 2 2
      apps/slackbot-proxy/package.json
  91. 3 3
      package.json
  92. 8 0
      packages/core/CHANGELOG.md
  93. 1 1
      packages/core/package.json
  94. 61 0
      packages/core/src/utils/escape-string-for-regex.spec.ts
  95. 22 0
      packages/core/src/utils/escape-string-for-regex.ts
  96. 1 0
      packages/core/src/utils/index.ts
  97. 12 2
      packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts
  98. 5 1
      packages/core/src/utils/page-path-utils/generate-children-regexp.ts
  99. 1 1
      packages/editor/package.json
  100. 1 1
      packages/pdf-converter-client/package.json

+ 0 - 287
.claude/commands/tdd.md

@@ -1,287 +0,0 @@
----
-name: tdd
-description: Enforce test-driven development workflow. Scaffold interfaces, generate tests FIRST, then implement minimal code to pass. Ensure 80%+ coverage.
----
-
-# TDD Command
-
-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
-2. **Generate Tests First** - Write failing tests (RED)
-3. **Implement Minimal Code** - Write just enough to pass (GREEN)
-4. **Refactor** - Improve code while keeping tests green (REFACTOR)
-5. **Verify Coverage** - Ensure 80%+ test coverage
-
-## When to Use
-
-Use `/tdd` when:
-- Implementing new features
-- Adding new functions/components
-- Fixing bugs (write test that reproduces bug first)
-- Refactoring existing code
-- Building critical business logic
-
-## TDD Cycle
-
-```
-RED → GREEN → REFACTOR → REPEAT
-
-RED:      Write a failing test
-GREEN:    Write minimal code to pass
-REFACTOR: Improve code, keep tests passing
-REPEAT:   Next feature/scenario
-```
-
-## Example Usage
-
-```
-User: /tdd I need a function to validate page paths
-
-Agent:
-```
-
-# TDD Session: Page Path Validator
-
-## Step 1: Define Interface (SCAFFOLD)
-
-```typescript
-// src/utils/page-path-validator.ts
-export interface ValidationResult {
-  isValid: boolean;
-  errorMessage?: string;
-}
-
-export function validatePagePath(path: string): ValidationResult {
-  throw new Error('Not implemented');
-}
-```
-
-## 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', () => {
-  it('should return valid for correct path', () => {
-    const result = validatePagePath('/user/docs/page');
-    expect(result.isValid).toBe(true);
-  });
-
-  it('should reject path without leading slash', () => {
-    const result = validatePagePath('user/docs');
-    expect(result.isValid).toBe(false);
-    expect(result.errorMessage).toBeDefined();
-  });
-
-  it('should reject empty path', () => {
-    const result = validatePagePath('');
-    expect(result.isValid).toBe(false);
-  });
-});
-```
-
-## 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
-
-FAIL src/utils/page-path-validator.spec.ts
-  ✕ should return valid for correct path
-    Error: Not implemented
-```
-
-**✅ 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) {
-    return { isValid: false, errorMessage: 'Path cannot be empty' };
-  }
-  if (!path.startsWith('/')) {
-    return { isValid: false, errorMessage: 'Path must start with /' };
-  }
-  return { isValid: true };
-}
-```
-
-## 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:**
-- ✅ Write the test FIRST, before any implementation
-- ✅ Run tests and verify they FAIL before implementing
-- ✅ Write minimal code to make tests pass
-- ✅ Refactor only after tests are green
-- ✅ Add edge cases and error scenarios
-- ✅ Aim for 80%+ coverage (100% for critical code)
-- ✅ Use `vitest-mock-extended` for type-safe mocks
-
-**DON'T:**
-- ❌ Write implementation before tests
-- ❌ Skip running tests after each change
-- ❌ Write too much code at once
-- ❌ Ignore failing tests
-- ❌ Test implementation details (test behavior)
-- ❌ Mock everything (prefer integration tests)
-
-## Test Types to Include
-
-**Unit Tests** (`*.spec.ts`):
-- Happy path scenarios
-- Edge cases (empty, null, max values)
-- Error conditions
-- Boundary values
-
-**Integration Tests** (`*.integ.ts`):
-- API endpoints
-- Database operations
-- External service calls
-
-**Component Tests** (`*.spec.tsx`):
-- React components with hooks
-- User interactions
-- Jotai state integration
-
-## Coverage Requirements
-
-- **80% minimum** for all code
-- **100% required** for:
-  - Authentication/authorization logic
-  - Security-critical code
-  - Core business logic (page operations, permissions)
-  - Data validation utilities
-
-## Important Notes
-
-**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
-
-**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
-
-**This is a team development standard. Violations are not acceptable.**
-
-## Related Skills
-
-This command uses patterns from:
-- **growi-testing-patterns** - Vitest, React Testing Library, vitest-mock-extended

+ 0 - 66
.claude/rules/lsp.md

@@ -1,66 +0,0 @@
-# LSP Usage
-
-The `LSP` tool provides TypeScript-aware code intelligence. Prefer it over `grep`/`find` for symbol-level queries.
-
-## Tool availability (read before concluding LSP is unavailable)
-
-The way `LSP` is exposed differs between the main session and sub-agents. Check which context you are in before concluding it is unavailable.
-
-**Main session (this file is in your system prompt):**
-`LSP` is registered as a **deferred tool** — its schema is not loaded at session start, so it will NOT appear in the initial top-level tool list. It instead shows up by name inside the session-start `<system-reminder>` listing deferred tools.
-
-Do not conclude LSP is unavailable just because it isn't in the initial tool list. To use it:
-
-1. Confirm `LSP` appears in the deferred-tool list in the session-start system-reminder.
-2. Load its schema with `ToolSearch` using `query: "select:LSP"`.
-3. After that, call `LSP` like any other tool.
-
-Only if `LSP` is missing from the deferred-tool list AND `ToolSearch` with `select:LSP` returns no match should you treat LSP as disabled and fall back to `grep`.
-
-**Sub-agents (Explore, general-purpose, etc.):**
-`LSP` is provided directly in the initial tool list — no `ToolSearch` step needed. `ToolSearch` itself is not available in sub-agents. Just call `LSP` as a normal tool.
-
-Note: `.claude/rules/` files are NOT injected into sub-agent system prompts. A sub-agent will not know the guidance in this file unless the parent includes it in the `Agent` prompt. When delegating symbol-level research (definition lookup, caller search, type inspection) to a sub-agent, restate the key rules inline — at minimum: "prefer LSP over grep for TypeScript symbol queries; use `incomingCalls` for callers, `goToDefinition` for definitions".
-
-## Auto-start behavior
-
-The `typescript-language-server` starts automatically when the LSP tool is first invoked — no manual startup or health check is needed. If the server isn't installed, the tool returns an error; in that case fall back to `grep`.
-
-In the devcontainer, `typescript-language-server` is pre-installed globally via `postCreateCommand.sh`. It auto-detects and uses the workspace's `node_modules/typescript` at runtime.
-
-## When to use LSP (not grep)
-
-| Task | Preferred LSP operation |
-|------|------------------------|
-| Find where a function/class/type is defined | `goToDefinition` |
-| Find all call sites **including imports** | `findReferences` (see caveat below) |
-| Find which functions call a given function | `incomingCalls` ← prefer this over `findReferences` for callers |
-| Check a variable's type or JSDoc | `hover` |
-| List all exports in a file | `documentSymbol` |
-| Find what implements an interface | `goToImplementation` |
-| Trace what a function calls | `outgoingCalls` |
-
-## Decision rule
-
-- **Use LSP** when the query is about a *symbol* (function, class, type, variable) — LSP understands TypeScript semantics and won't false-match string occurrences or comments.
-- **Use grep** when searching for string literals, comments, config values, or when LSP returns no results (e.g., generated code, `.js` files without types).
-
-## `findReferences` — lazy-loading caveat
-
-TypeScript LSP loads files **on demand**. On a cold server (first query after devcontainer start), calling `findReferences` from the *definition file* may return only the definition itself because consumer files haven't been loaded yet.
-
-**Mitigation strategies (in order of preference):**
-
-1. **Prefer `incomingCalls`** over `findReferences` when you want callers. It correctly resolves cross-file call sites even on a cold server.
-2. If you need `findReferences` with full results, call it from a **known call site** (not the definition). After any file in the consumer chain is queried, the server loads it and subsequent `findReferences` calls return complete results.
-3. As a last resort, run a `hover` on an import statement in the consumer file first to warm up that file, then retry `findReferences` from the definition.
-
-## Required: line + character
-
-LSP operations require `line` and `character` (both 1-based). Read the file first to identify the exact position of the symbol, then call LSP.
-
-```
-# Example: symbol starts at col 14 on line 85
-export const useCollaborativeEditorMode = (
-             ^--- character 14
-```

+ 46 - 0
.claude/rules/mongodb-regex.md

@@ -0,0 +1,46 @@
+# MongoDB Regex Escaping
+
+## RegExp.escape() must not be used for MongoDB-bound regex patterns
+
+Node.js 24's built-in `RegExp.escape()` escapes non-ASCII whitespace (code points
+≥ U+0100, e.g. U+3000 IDEOGRAPHIC SPACE) into `\uXXXX` form. MongoDB's PCRE2 engine
+does **not** support `\u`, so such a pattern throws:
+
+```
+Regular expression is invalid: PCRE2 does not support \L, \l, \N{name}, \U, or \u
+  code: 51091
+```
+
+This breaks page creation, v5 page migration, page listing, etc. for any path that
+contains those characters. (`escape-string-regexp`, used before the v7.5.0 refactor,
+passed non-ASCII characters through literally and did not have this problem.)
+
+## The Rule
+
+When a regex is sent to **MongoDB** — used as a `$regex` value, or wrapped in
+`new RegExp(...)` and assigned to a query field (`path`, `name`, …) in a Mongoose
+`find` / `updateMany` / `aggregate` / `count` / `bulkWrite` — escape the dynamic part
+with **`escapeStringForMongoRegex()`** from `@growi/core/dist/utils`, never `RegExp.escape()`.
+
+`escapeStringForMongoRegex()` escapes only regex metacharacters and passes every other
+character through literally (equivalent to `escape-string-regexp` v5), so its output
+never contains `\u` and is safe for PCRE2.
+
+```typescript
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
+
+// ❌ WRONG — pattern goes to MongoDB
+Page.find({ path: new RegExp(`^${RegExp.escape(path)}`) });
+
+// ✅ CORRECT
+Page.find({ path: new RegExp(`^${escapeStringForMongoRegex(path)}`) });
+```
+
+## Exception: in-process JS regex is fine
+
+`RegExp.escape()` is acceptable for regexes evaluated **in-process by V8** — i.e.
+`.test()` / `.replace()` / `.match()` on local strings that are never sent to MongoDB.
+V8 interprets `\uXXXX` correctly, so there is no need to change those call sites.
+
+See `escapeStringForMongoRegex` (`packages/core/src/utils/escape-string-for-regex.ts`)
+and issue #11235 for background.

+ 39 - 1
.claude/rules/testing.md

@@ -35,4 +35,42 @@ pnpm vitest run yjs.integ --repeat=10
 turbo run test --filter @growi/app
 ```
 
-For testing patterns (mocking, assertions, structure), see the `.claude/skills/learned/essential-test-patterns` skill.
+## Essential Test Skills (MANDATORY)
+
+Whenever you **write** a test OR **review** a change that adds/modifies tests, you
+MUST consult both skills first — they are not optional background reading:
+
+| Skill | Apply it to |
+|-------|-------------|
+| **essential-test-design** (`.claude/skills/essential-test-design/SKILL.md`) | *What* to assert — test the observable contract, not the mechanism. Catches brittle implementation-spies and assertion-free "it didn't throw" tests. |
+| **essential-test-patterns** (`.claude/skills/essential-test-patterns/SKILL.md`) | *How* to build the test — Vitest globals, RTL, Jotai scopes, type-safe mocking, module mocking strategy. |
+
+This applies in every context, including review-time skills (`kiro-review`,
+`kiro-validate-impl`, `kiro-verify-completion`): a test diff is not "good" until it
+has been checked against essential-test-design (contract) and essential-test-patterns
+(mechanics).
+
+## Type-Safe Mocks — avoid type assertions (`as any`, `as unknown as T`, `as T`)
+
+Mocking an interface/class? Use `mock<T>()` / `mock<T>({ ...overrides })` from
+`vitest-mock-extended` (already a dependency). It returns a real `T` (no cast),
+type-checks the overrides against the type, and auto-stubs everything you don't
+specify — so it is both **type-safe** and **shorter** than a hand-built object.
+
+Every assertion form defeats the type checker in some way and lets the mock drift
+out of sync with the real type silently — prefer `mock<T>()` over all of them:
+
+```typescript
+// ❌ all of these escape type checking — the mock can rot as Crowi changes
+const crowi = { searchService: { searchKeyword: vi.fn() } } as unknown as Crowi;
+const crowi = { searchService: { searchKeyword: vi.fn() } } as Crowi;
+const crowi = { searchService: { searchKeyword: vi.fn() } } as any;
+
+// ✅ type-checked against Crowi, auto-stubs the rest
+const crowi = mock<Crowi>({ searchService: { searchKeyword: vi.fn() } });
+```
+
+A type assertion in a test is only acceptable when removing it would cost more than
+it saves (no type exists for the target, or one field needs real behavior). For the
+full tolerance framework (4 tiers) and the `mock<T>` patterns, see the **Type-Safe
+Mocking** section of the `essential-test-patterns` skill.

+ 0 - 27
.claude/settings.json

@@ -1,31 +1,6 @@
 {
   "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__plugin_context7_*",
-      "WebSearch",
-      "WebFetch"
     ]
   },
   "enableAllProjectMcpServers": true,
@@ -54,8 +29,6 @@
     ]
   },
   "enabledPlugins": {
-    "context7@claude-plugins-official": true,
-    "typescript-lsp@claude-plugins-official": true,
     "figma@claude-plugins-official": true,
     "mcp-client-skills@growi-mcp-tools": true
   }

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


+ 72 - 1
.claude/skills/learned/essential-test-patterns/SKILL.md → .claude/skills/essential-test-patterns/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: essential-test-patterns
-description: GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended.
+description: GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended (type-safe mocking, avoid type assertions). Auto-invoked when writing or reviewing tests.
 ---
 
 # GROWI Testing Patterns
@@ -116,6 +116,77 @@ mockProps.onSubmit?.mockImplementation((value) => {
 - ✅ **Deep mocking**: Automatically mocks nested objects
 - ✅ **Vitest integration**: Works seamlessly with `vi.fn()`
 
+### `mock<T>()` vs `mockDeep<T>()` — and `mock<T>({ ...overrides })`
+
+```typescript
+import { mock, mockDeep } from 'vitest-mock-extended';
+
+// Auto-stub everything, override just the members the test touches.
+// The override object is type-checked against Crowi (PartialDeep<Crowi>),
+// and the return value IS a Crowi — no cast needed.
+const crowi = mock<Crowi>({
+  searchService: { searchKeyword: vi.fn() },
+});
+
+// mockDeep<T>() recursively proxies nested access (foo.bar.baz.method()
+// all return mocks lazily). Use when the code-under-test reaches deep into
+// nested members you don't want to enumerate.
+const configManager = mockDeep<IConfigManagerForApp>();
+```
+
+- `mock<T>(overrides?)` — shallow auto-stub + a typed partial override. The common
+  case for service/Crowi-style dependencies.
+- `mockDeep<T>()` — recursive proxy for arbitrarily deep nested access.
+
+### Avoid type assertions for mocks — `as any`, `as unknown as T`, `as T`
+
+All three assertion forms **disable the type checker** for the mock, just in
+different ways, so prefer `mock<T>()` over every one of them:
+
+- `as unknown as Crowi` — the usual escape hatch; compiles even when the real type
+  drifts (a method is renamed or removed), leaving the mock silently wrong, and lets
+  you assign members that do not exist.
+- `as Crowi` — same problem when it compiles; TypeScript only blocks it when the
+  object structurally clashes, which then pushes people to `as unknown as` or `as any`.
+- `as any` — the broadest hole: erases the type entirely, so *nothing* about the
+  mock is checked and IDE autocomplete dies too.
+
+`mock<T>({ ...overrides })` is both safer (the override is checked, so drift is a
+compile error) and shorter (no need to hand-build the object).
+
+```typescript
+// ❌ WRONG — every one of these escapes type checking and survives API drift
+const crowi = { searchService: { searchKeyword: vi.fn() } } as unknown as Crowi;
+const crowi = { searchService: { searchKeyword: vi.fn() } } as Crowi;
+const crowi = { searchService: { searchKeyword: vi.fn() } } as any;
+
+// ✅ CORRECT — type-checked, auto-stubs the rest, returns a real Crowi
+const crowi = mock<Crowi>({
+  searchService: { searchKeyword: vi.fn() },
+});
+```
+
+Anti-pattern to watch for: hand-writing a `Pick<T, ...>` shim type plus a builder
+function and *still* casting at the call site. That is more code than `mock<T>()`
+and is not type-safe — the worst of both. Replace it with `mock<T>({ ... })`.
+
+### Tolerance framework: when a type assertion is acceptable
+
+The deciding question is the cost: **how many lines (and how much clarity) does
+removing the assertion cost?**
+
+| Tier | Situation | Rule |
+|------|-----------|------|
+| **1 — Avoid (cost ≤ 0)** | Mocking an interface/class. `mock<T>()` is 1 line, type-safe, *shorter* than the manual object. | **No assertion.** Use `mock<T>()`. No exceptions. |
+| **2 — Localize (small cost)** | One field needs *real* behavior `mock<T>` can't give (e.g. a working `EventEmitter` whose listeners actually fire), and its type doesn't quite match. | Allowed, but **confine the cast to that one field**: `mock<Crowi>({ events: { page: realEmitter as unknown as PageEvent } })` — never cast the whole object. |
+| **3 — Allow + comment (large cost)** | No type exists for the target (untyped JS module, untyped third-party lib). `mock<T>` can't be built. | A 1-line cast is fine — **writing a dozens-of-lines shim just to delete it is overkill**. Leave a `// WHY:` comment (e.g. `// PageEvent is a JS file typed as 'any' in Crowi`). |
+| **4 — Forbidden** | A hand-built/`Pick<>` partial object that ends in a cast anyway. | Replace with `mock<T>()`. There is no reason to keep it. |
+
+Rule of thumb: if deleting the cast costs **≤ 0 lines**, it's mandatory (Tier 1);
+if it can be **localized to one field**, do that (Tier 2); if the **type itself is
+missing** and avoidance would cost dozens of lines, a commented 1-line cast is fine
+(Tier 3); a shim that still casts is never fine (Tier 4).
+
 ## React Testing Library Patterns
 
 ### Basic Component Test

+ 1 - 1
.devcontainer/app/postCreateCommand.sh

@@ -26,7 +26,7 @@ mkdir -p "$PNPM_HOME"
 # (overlay FS) and the workspace (bind mount) are on different filesystems.
 pnpm config set store-dir /workspace/.pnpm-store
 
-pnpm install --global turbo typescript-language-server typescript
+pnpm install --global turbo
 
 # Install dependencies
 turbo run bootstrap

+ 0 - 5
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -20,11 +20,6 @@ pnpm i -g pnpm
 # Install turbo
 pnpm install turbo --global
 
-# Install typescript-language-server for Claude Code LSP plugin
-# Use `npm -g` (not `pnpm --global`) so the binary lands in nvm's node bin, which is on the default PATH.
-# pnpm's global bin requires PNPM_HOME from ~/.bashrc, which the Claude Code extension's shell doesn't source.
-npm install -g typescript-language-server typescript
-
 # Install dependencies
 turbo run bootstrap
 

+ 1 - 1
.github/workflows/ci-app-prod.yml

@@ -52,7 +52,7 @@ jobs:
   #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
   test-prod-node24:
-    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
+    uses: ./.github/workflows/reusable-app-prod.yml
     if: |
       ( github.event_name == 'push'
         || github.base_ref == 'master'

+ 84 - 15
.github/workflows/ci-app.yml

@@ -47,7 +47,7 @@ jobs:
         node-version: [24.x]
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v6
 
@@ -102,16 +102,18 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v6
 
-      - uses: actions/setup-node@v6
+      - &setup-node-step
+        uses: actions/setup-node@v6
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'pnpm'
 
-      - name: Cache/Restore dist
+      - &cache-restore-dist-step
+        name: Cache/Restore dist
         uses: actions/cache@v4
         with:
           path: |
@@ -122,24 +124,22 @@ jobs:
           restore-keys: |
             dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Install dependencies
+      - &install-dependencies-step
+        name: Install dependencies
         run: |
           pnpm add turbo --global
           pnpm install --frozen-lockfile
 
-      - name: Test
+      - name: Test (app - unit & component)
         run: |
-          turbo run test --filter=@growi/app --filter=./packages/* --env-mode=loose
+          turbo run test:unit test:components --filter=@growi/app --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
-      - name: Upload coverage report as artifact
-        uses: actions/upload-artifact@v4
-        with:
-          name: coverage-mongo${{ matrix.mongodb-version }}
-          path: |
-            apps/app/coverage
-            packages/remark-growi-directive/coverage
+      - name: Test (packages)
+        run: |
+          turbo run test --filter=./packages/* --env-mode=loose
+
 
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
@@ -152,6 +152,75 @@ jobs:
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+  ci-app-test-integration:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [24.x]
+        mongodb-version: ['8.0']
+        elasticsearch-version:
+          - env: 8
+            stack-version: '8.19.16'
+          - env: 9
+            stack-version: '9.3.3'
+
+    # [MEMO] Elasticsearch is started in a step to install plugins before starting it.
+    services:
+      mongodb:
+        image: mongo:${{ matrix.mongodb-version }}
+        ports:
+          - 27017/tcp
+
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: pnpm/action-setup@v6
+
+      - *setup-node-step
+
+      - *cache-restore-dist-step
+
+      - *install-dependencies-step
+
+      - name: Start Elasticsearch with plugins
+        uses: elastic/elastic-github-actions/elasticsearch@master
+        with:
+          stack-version: ${{ matrix.elasticsearch-version.stack-version }}
+          plugins: |
+            analysis-kuromoji
+            analysis-icu
+          security-enabled: false
+
+      - name: Wait for Elasticsearch to be ready
+        run: |
+          curl \
+            --no-progress-meter \
+            -X GET \
+            --retry 60 \
+            --retry-delay 1 \
+            --retry-connrefused \
+            http://localhost:9200/_cluster/health?wait_for_status=green
+
+      - name: Test
+        run: |
+          turbo run test:integ --filter=@growi/app --env-mode=loose
+        env:
+          MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
+          VITE_ELASTICSEARCH_URI: http://localhost:9200/growi
+          VITE_ELASTICSEARCH_VERSION: ${{ matrix.elasticsearch-version.env }}
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - test-es (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
   ci-app-launch-dev:
     runs-on: ubuntu-latest
 
@@ -167,7 +236,7 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v6
 

+ 129 - 35
.github/workflows/reusable-app-prod.yml

@@ -127,14 +127,6 @@ jobs:
         image: mongo:${{ matrix.mongodb-version }}
         ports:
         - 27017/tcp
-      elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
-        ports:
-        - 9200/tcp
-        env:
-          discovery.type: single-node
-          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
-          xpack.security.enabled: false
 
     steps:
     - uses: actions/setup-node@v6
@@ -153,6 +145,25 @@ jobs:
     # Run after extraction so pnpm/action-setup@v6 can read packageManager from package.json
     - uses: pnpm/action-setup@v6
 
+    - name: Start Elasticsearch with plugins
+      uses: elastic/elastic-github-actions/elasticsearch@master
+      with:
+        stack-version: 9.3.3
+        plugins: |
+          analysis-kuromoji
+          analysis-icu
+        security-enabled: false
+
+    - name: Wait for Elasticsearch to be ready
+      run: |
+        curl \
+          --no-progress-meter \
+          -X GET \
+          --retry 60 \
+          --retry-delay 1 \
+          --retry-connrefused \
+          http://localhost:9200/_cluster/health?wait_for_status=green
+
     - name: pnpm run server:ci
       working-directory: ./apps/app
       run: |
@@ -160,7 +171,7 @@ jobs:
         pnpm run server:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
-        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+        ELASTICSEARCH_URI: http://localhost:9200/growi
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -173,18 +184,48 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+  prime-playwright-cache:
+    if: |
+      github.event_name == 'workflow_dispatch' ||
+      (!inputs.skip-e2e-test && startsWith(github.head_ref, 'mergify/merge-queue/'))
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: pnpm/action-setup@v6
+
+    - uses: actions/setup-node@v6
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'pnpm'
+
+    - name: Install dependencies
+      run: |
+        pnpm install --frozen-lockfile
+
+    - name: Cache Playwright browsers
+      id: playwright-cache
+      uses: actions/cache@v4
+      with:
+        path: ~/.cache/ms-playwright
+        key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
+
+    - name: Install Playwright browsers
+      if: steps.playwright-cache.outputs.cache-hit != 'true'
+      run: |
+        pnpm playwright install
+
+
   run-playwright:
-    needs: [build-prod]
+    needs: [build-prod, prime-playwright-cache]
 
     if: |
       github.event_name == 'workflow_dispatch' ||
       (!inputs.skip-e2e-test && startsWith(github.head_ref, 'mergify/merge-queue/'))
 
     runs-on: ubuntu-latest
-    container:
-      # Match the Playwright version
-      # https://github.com/microsoft/playwright/issues/20010
-      image: mcr.microsoft.com/playwright:v1.58.2-jammy
 
     # Playwright spawns `pnpm run server` inside the extracted prod dir via
     # GROWI_WEBSERVER_COMMAND. That dir lacks pnpm-workspace.yaml and packages/*,
@@ -204,14 +245,6 @@ jobs:
         image: mongo:${{ matrix.mongodb-version }}
         ports:
         - 27017/tcp
-      elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
-        ports:
-        - 9200/tcp
-        env:
-          discovery.type: single-node
-          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
-          xpack.security.enabled: false
 
     steps:
     - uses: actions/checkout@v4
@@ -227,9 +260,40 @@ jobs:
       run: |
         pnpm install --frozen-lockfile
 
-    - name: Install Playwright browsers
+    # Browsers are pre-populated by `prime-playwright-cache`. Always a cache hit;
+    # restore-keys provides a partial fallback if the keyed cache was evicted.
+    - name: Restore Playwright browser cache
+      uses: actions/cache/restore@v4
+      with:
+        path: ~/.cache/ms-playwright
+        key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
+        restore-keys: |
+          playwright-${{ runner.os }}-
+
+    # `--with-deps` installs apt system libraries and re-downloads any browser
+    # that wasn't in the restored cache. With a primed cache, this only runs apt.
+    - name: Install Playwright system deps and any missing browsers
       run: |
-        pnpm playwright install --with-deps ${{ matrix.browser }}
+        pnpm playwright install --with-deps
+
+    - name: Start Elasticsearch with plugins
+      uses: elastic/elastic-github-actions/elasticsearch@master
+      with:
+        stack-version: 9.3.3
+        plugins: |
+          analysis-kuromoji
+          analysis-icu
+        security-enabled: false
+
+    - name: Wait for Elasticsearch to be ready
+      run: |
+        curl \
+          --no-progress-meter \
+          -X GET \
+          --retry 60 \
+          --retry-delay 1 \
+          --retry-connrefused \
+          http://localhost:9200/_cluster/health?wait_for_status=green
 
     - name: Download production files artifact
       uses: actions/download-artifact@v4
@@ -245,17 +309,31 @@ jobs:
       run: |
         cat apps/app/config/ci/.env.local.for-ci >> /tmp/growi-prod/apps/app/.env.production.local
 
+    # The installer suite is not sharded, so run it once per mongodb-version
+    # (shard 1/2 only) rather than redundantly in every chromium shard job.
     - name: Playwright Run (--project=chromium/installer)
-      if: ${{ matrix.browser == 'chromium' }}
+      if: ${{ matrix.browser == 'chromium' && matrix.shard == '1/2' }}
       working-directory: ./apps/app
       run: |
         pnpm playwright test --project=chromium/installer
       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://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-playwright-installer
+        ELASTICSEARCH_URI: http://localhost:9200/growi
+
+    # Each `playwright test` run clears blob-report/ when it writes its report
+    # (the blob reporter empties its output dir), so move this run's blob out to a
+    # staging dir before the next run wipes it. Prefixing the mongodb-version keeps
+    # filenames unique once report-playwright flattens every artifact together.
+    - name: Stash installer blob report
+      if: ${{ always() && matrix.browser == 'chromium' && matrix.shard == '1/2' }}
+      run: |
+        mkdir -p apps/app/all-blobs
+        for f in apps/app/blob-report/*.zip; do
+          [ -e "$f" ] || continue
+          mv "$f" "apps/app/all-blobs/mongo${{ matrix.mongodb-version }}-$(basename "$f")"
+        done
 
     - name: Copy dotenv file for automatic installation
       run: |
@@ -267,10 +345,18 @@ jobs:
         pnpm playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}
       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://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-playwright
+        ELASTICSEARCH_URI: http://localhost:9200/growi
+
+    - name: Stash main blob report
+      if: always()
+      run: |
+        mkdir -p apps/app/all-blobs
+        for f in apps/app/blob-report/*.zip; do
+          [ -e "$f" ] || continue
+          mv "$f" "apps/app/all-blobs/mongo${{ matrix.mongodb-version }}-$(basename "$f")"
+        done
 
     - name: Copy dotenv file for automatic installation with allowing guest mode
       run: |
@@ -282,10 +368,18 @@ jobs:
         pnpm playwright test --project=${{ matrix.browser }}/guest-mode --shard=${{ matrix.shard }}
       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://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-playwright-guest-mode
+        ELASTICSEARCH_URI: http://localhost:9200/growi
+
+    - name: Stash guest-mode blob report
+      if: always()
+      run: |
+        mkdir -p apps/app/all-blobs
+        for f in apps/app/blob-report/*.zip; do
+          [ -e "$f" ] || continue
+          mv "$f" "apps/app/all-blobs/mongo${{ matrix.mongodb-version }}-$(basename "$f")"
+        done
 
     - name: Generate shard ID
       id: shard-id
@@ -299,7 +393,7 @@ jobs:
       if: always()
       with:
         name: blob-report-${{ matrix.browser }}-mongo${{ matrix.mongodb-version }}-${{ steps.shard-id.outputs.shard_id }}
-        path: ./apps/app/blob-report
+        path: ./apps/app/all-blobs
         retention-days: 30
 
     - name: Slack Notification

+ 118 - 0
.kiro/specs/access-token-parser/brief.md

@@ -0,0 +1,118 @@
+# Brief: access-token-parser
+
+## Problem
+
+GROWI's API authentication currently accepts an access token in only two ways: as a
+Bearer token in the `Authorization` header, or as an `access_token` query parameter
+(see https://docs.growi.org/en/api/rest-v3.html).
+
+When the `Authorization` header is already consumed by something else (e.g. Basic
+authentication on a reverse proxy), callers are forced to fall back to the query
+parameter. Putting the token in a GET query string is insecure — it leaks into URLs,
+server logs, browser history, and referrers
+(see https://owasp.org/www-community/vulnerabilities/Information_exposure_through_query_strings_in_url).
+
+There is also no spec governing the `access-token-parser` middleware, so future changes
+have no requirements/design baseline to maintain against (cc-sdd).
+
+## Current State
+
+- The `access-token-parser` middleware is **already implemented** at
+  `apps/app/src/server/middlewares/access-token-parser/`:
+  - `index.ts` — `accessTokenParser(scopes, opts)` orchestrator; runs
+    `parserForAccessToken(scopes)` and, when `opts.acceptLegacy`, `parserForApiToken`.
+  - `access-token.ts` — `parserForAccessToken`: scope-checked AccessToken model lookup.
+  - `api-token.ts` — `parserForApiToken`: legacy `User.apiToken` lookup.
+  - `extract-bearer-token.ts` — pulls the Bearer token from `Authorization`.
+  - Co-located `*.integ.ts` integration tests.
+- Token extraction order today (both parsers):
+  `bearerToken ?? req.query.access_token ?? req.body.access_token`.
+  **There is no header source between Bearer and query.**
+- An **open upstream PR (#10443, branch `support/api-token-header`, base `master`,
+  +114/-6, 13 files)** by ryu-sato adds exactly the missing `X-GROWI-ACCESS-TOKEN`
+  header support. This PR is the salvage source for the current deliverable.
+- **No `access-token-parser` spec exists yet.**
+
+## Desired Outcome
+
+- API callers can authenticate by sending the token in the `x-growi-access-token`
+  request header, with priority directly after the `Authorization` Bearer token and
+  before the query/body sources — for both the scoped AccessToken path and the legacy
+  api-token path.
+- The header auth method is advertised in the OpenAPI definitions (apiv1 + apiv3) as a
+  new `accessTokenHeaderAuth` security scheme and applied to the relevant routes.
+- A spec (this one) exists so all future `access-token-parser` changes can be maintained
+  via cc-sdd (requirements → design → tasks → impl).
+
+## Approach
+
+Salvage PR #10443 onto a fresh branch cut from `master` (NOT from the current
+`imprv/x-access-token-header` branch, which carries unrelated MongoDB-regex work), then
+open a new PR to `master`. The technical change is small and well understood:
+
+1. Insert `?? req.headers['x-growi-access-token']` between the Bearer token and the
+   query/body sources in both `access-token.ts` and `api-token.ts`.
+2. Add the `accessTokenHeaderAuth` (`type: apiKey`, `in: header`,
+   `name: x-growi-access-token`) security scheme to `bin/openapi/definition-apiv1.js`
+   and `definition-apiv3.js`, and to the top-level `security` array.
+3. Add `- accessTokenHeaderAuth: []` to the per-route OpenAPI `security` blocks.
+4. Add integration tests covering the header path for both parsers.
+
+**Salvage hygiene**: copy only the *meaningful* changes. PR #10443 also contains
+incidental `import` reordering (the `SCOPE` import moved) caused by its older base —
+do not carry that noise. Re-verify the route list against current `master`, since routes
+added/changed since the PR was opened may also need the `accessTokenHeaderAuth: []` line
+for consistency.
+
+**Workflow: hybrid.** Lock intent in brief + requirements first; implement the salvage
+and open the PR; then finalize design/tasks so the spec stands as the maintenance
+baseline that matches what shipped.
+
+## Scope
+
+- **In**:
+  - `x-growi-access-token` header as a token source in `parserForAccessToken` and
+    `parserForApiToken`, with correct priority ordering.
+  - `accessTokenHeaderAuth` OpenAPI security scheme (apiv1 + apiv3) and its application
+    to the affected routes.
+  - Integration tests for the header path.
+  - A spec baseline (requirements/design/tasks) for the access-token-parser middleware,
+    centered on the header feature with minimal surrounding context.
+- **Out**:
+  - Redesigning the scope model, the AccessToken model, or the legacy api-token mechanism.
+  - Client/SDK or docs-site changes beyond the in-repo OpenAPI definitions.
+  - Deprecating or removing the existing query/body token sources.
+  - Broad brownfield documentation of the entire middleware (kept minimal by decision).
+
+## Boundary Candidates
+
+- Token-source extraction & priority ordering (the parser logic).
+- OpenAPI security-scheme declaration and per-route application.
+- Test coverage for the new header path.
+
+## Out of Boundary
+
+- Authentication/authorization scope semantics (owned by the AccessToken model + SCOPE
+  definitions in `@growi/core`).
+- Reverse-proxy / Basic-auth configuration (the motivating environment, not this code).
+
+## Upstream / Downstream
+
+- **Upstream**: `@growi/core` interfaces (`Scope`, `AccessTokenParser*`, `IUserHasId`),
+  the `AccessToken` Mongoose model, `serializeUserSecurely`. Salvage source: PR #10443.
+- **Downstream**: every apiv3 route guarded by `accessTokenParser`; OpenAPI consumers
+  reading the generated security schemes.
+
+## Existing Spec Touchpoints
+
+- **Extends**: none (new spec).
+- **Adjacent**: none of the existing specs (auto-scroll, editor-keymaps, oauth2-email-support,
+  suggest-path, …) overlap this middleware.
+
+## Constraints
+
+- Branch from `master`, not the current `imprv/x-access-token-header` branch.
+- TDD per repo policy: header-path integration tests precede/accompany the logic change.
+- All in-repo code comments and spec documents in English (`spec.json.language: en`).
+- Header name is matched case-insensitively by Express via `req.headers['x-growi-access-token']`.
+- Final PR targets `master`; use `gh` CLI for all GitHub operations.

+ 285 - 0
.kiro/specs/access-token-parser/design.md

@@ -0,0 +1,285 @@
+# Design Document
+
+## Overview
+
+**Purpose**: Add `X-GROWI-ACCESS-TOKEN` as a request-header source for API authentication,
+so callers whose `Authorization` header is already consumed (e.g. Basic auth on a reverse
+proxy) can authenticate without exposing the token in a URL query string.
+
+**Users**: GROWI REST API consumers (apiv1 / apiv3), and API-spec readers who rely on the
+generated OpenAPI security schemes.
+
+**Impact**: Extends the existing `access-token-parser` middleware. The header becomes an
+additional token source positioned directly after the `Authorization` Bearer token and
+before the `access_token` query/body parameters, for both the scope-based access-token
+path and the legacy api-token path. Token validation, scope checks, and read-only checks
+are unchanged and remain source-agnostic. This spec also establishes the cc-sdd
+maintenance baseline for the middleware.
+
+### Goals
+- Accept the access token from the `X-GROWI-ACCESS-TOKEN` header on both parser paths.
+- Guarantee a single, consistent token-source precedence across both parsers.
+- Advertise the new method in the apiv1 and apiv3 OpenAPI definitions.
+- Leave all existing token sources and authorization behavior unchanged.
+
+### Non-Goals
+- Changing the scope model, the `AccessToken` storage model, or the legacy api-token
+  mechanism.
+- Removing or deprecating the Bearer, query, or body token sources.
+- Client/SDK changes or docs-site updates beyond the in-repo OpenAPI definitions.
+- Adding configuration or a feature flag to toggle the header (out of requirements scope).
+
+## Boundary Commitments
+
+### This Spec Owns
+- The token-source resolution order used by the access-token-parser middleware, including
+  the new `X-GROWI-ACCESS-TOKEN` header position (`Bearer ?? header ?? query ?? body`).
+- The canonical header-name constant `X_GROWI_ACCESS_TOKEN_HEADER_NAME`.
+- The `accessTokenHeaderAuth` OpenAPI security-scheme declaration and its application to
+  every apiv3 route that already advertises `bearer` + `accessTokenInQuery`.
+
+### Out of Boundary
+- Token validity, scope sufficiency, and read-only enforcement — owned by the `AccessToken`
+  model and `SCOPE` definitions in `@growi/core`; reused unchanged.
+- Downstream per-route authorization (rejecting unauthenticated requests).
+- Bearer extraction semantics (`extract-bearer-token.ts`) — unchanged.
+
+### Allowed Dependencies
+- `@growi/core/dist/interfaces/server` (`AccessTokenParserReq`), `AccessToken` model,
+  `serializeUserSecurely`, the existing `extractBearerToken` helper.
+- Express request typing for header/query/body access.
+
+### Revalidation Triggers
+- A change to the token-source precedence order.
+- A rename or value change of `X_GROWI_ACCESS_TOKEN_HEADER_NAME`.
+- A change to the `extractAccessToken` return contract (`string | null`).
+- Adding/removing an `accessTokenInQuery` route without mirroring `accessTokenHeaderAuth`.
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The middleware (`apps/app/src/server/middlewares/access-token-parser/`) is an Express
+adapter with two parser functions orchestrated by `index.ts`:
+
+- `parserForAccessToken(scopes)` — scope-checked lookup via `AccessToken.findUserIdByToken`.
+- `parserForApiToken` — legacy `User.apiToken` lookup, run only when `opts.acceptLegacy`.
+
+Both currently duplicate the same token-source chain:
+`extractBearerToken(req.headers.authorization) ?? req.query.access_token ?? req.body.access_token`,
+followed by an identical `typeof !== 'string'` guard. This duplication is the seam where
+the two paths could drift — directly relevant to requirement 3 (consistent precedence).
+
+**Precedent**: `apps/app/src/server/service/g2g-transfer.ts` defines
+`export const X_GROWI_TRANSFER_KEY_HEADER_NAME = 'x-growi-transfer-key'` (a TS constant)
+while the OpenAPI `.js` definitions hardcode the literal. This design follows the same
+split.
+
+### Architecture Pattern & Boundary Map
+
+Pure-function extraction (per coding-style): the duplicated token-source chain is lifted
+into a single `extractAccessToken` helper that both parsers call. This makes the precedence
+the single source of truth and removes the drift risk.
+
+```mermaid
+graph TB
+    Req[Incoming request] --> Extract[extractAccessToken]
+    Bearer[extract-bearer-token] --> Extract
+    Extract --> AT[parserForAccessToken]
+    Extract --> APIT[parserForApiToken]
+    AT --> Model[AccessToken model + scope check]
+    APIT --> User[User.findUserByApiToken]
+    Model --> ReqUser[req.user via serializeUserSecurely]
+    User --> ReqUser
+```
+
+**Key decisions**:
+- `extractAccessToken` owns the precedence `Bearer ?? header ?? query ?? body` and the
+  string-type guard; both parsers depend on it (dependency direction:
+  `extract-bearer-token` → `extract-access-token` → parsers → `index`).
+- Header name lives in a TS constant used by the parser side; OpenAPI `.js` files keep the
+  literal `x-growi-access-token`, consistent with the g2g precedent.
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Backend / Services | Express (existing) | Header/query/body access via `AccessTokenParserReq` | Header keys are lowercased by Express → case-insensitive (1.3) |
+| API spec | swagger-jsdoc OpenAPI defs (existing `bin/openapi/*.js`) | Declare + apply `accessTokenHeaderAuth` | Literal header name, no new deps |
+
+No new runtime dependencies.
+
+## File Structure Plan
+
+### Directory Structure
+```
+apps/app/src/server/middlewares/access-token-parser/
+├── extract-access-token.ts        # NEW: X_GROWI_ACCESS_TOKEN_HEADER_NAME + extractAccessToken()
+├── extract-access-token.spec.ts   # NEW: unit tests for precedence + guard
+├── extract-bearer-token.ts        # unchanged
+├── access-token.ts                # MODIFIED: use extractAccessToken()
+├── api-token.ts                   # MODIFIED: use extractAccessToken()
+├── access-token.integ.ts          # MODIFIED: + header-path test (1.x)
+├── api-token.integ.ts             # MODIFIED: + header-path test (2.1)
+└── index.ts                       # unchanged
+```
+
+### Modified Files
+- `apps/app/src/server/middlewares/access-token-parser/access-token.ts` — replace the
+  inline token chain with `extractAccessToken(req)`.
+- `apps/app/src/server/middlewares/access-token-parser/api-token.ts` — same replacement.
+- `apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts` — add a
+  header-path success test (salvaged from PR #10443).
+- `apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts` — add a
+  header-path success test (salvaged from PR #10443).
+- `apps/app/bin/openapi/definition-apiv1.js` — add `accessTokenHeaderAuth` to
+  `components.securitySchemes` and to the top-level `security` array.
+- `apps/app/bin/openapi/definition-apiv3.js` — same as apiv1.
+- The 9 apiv3 route files carrying `accessTokenInQuery` — add `- accessTokenHeaderAuth: []`
+  after every `- accessTokenInQuery: []` block (26 sites total). Authoritative set:
+  `activity.ts` (1), `user-activities.ts` (1), `bookmark-folder.ts` (6), `import.ts` (4),
+  `in-app-notification.ts` (4), `page-listing.ts` (4), `g2g-transfer.ts` (2),
+  `app-settings/index.ts` (3), and
+  `features/ai-tools/suggest-path/server/routes/apiv3/index.ts` (1). **Drift note**: PR
+  #10443 targeted `app-settings.js` (now `app-settings/index.ts`), omitted
+  `user-activities.ts`, and predated the `features/` suggest-path route; do not apply the
+  PR's route hunks verbatim — drive edits off a full-tree sweep of current-master
+  `accessTokenInQuery` sites (`grep -rn accessTokenInQuery apps/app/src`).
+
+## Components and Interfaces
+
+| Component | Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|-------|--------|--------------|------------------|-----------|
+| `extractAccessToken` | middleware/util | Resolve token from all sources by precedence | 1.1–1.3, 2.1, 3.1–3.4 | `extractBearerToken` (P0) | Service |
+| `parserForAccessToken` | middleware | Scoped access-token auth | 1.1, 1.2, 4.1–4.3 | `extractAccessToken` (P0), `AccessToken` (P0) | Service |
+| `parserForApiToken` | middleware | Legacy api-token auth | 2.1, 2.2, 4.1 | `extractAccessToken` (P0), `User` (P0) | Service |
+| OpenAPI definitions | api-spec | Advertise header auth method | 5.1–5.3 | swagger-jsdoc (P1) | API |
+
+### Middleware
+
+#### extractAccessToken
+
+| Field | Detail |
+|-------|--------|
+| Intent | Single source of truth for token-source precedence and the string guard |
+| Requirements | 1.1, 1.2, 1.3, 2.1, 3.1, 3.2, 3.3, 3.4 |
+
+**Responsibilities & Constraints**
+- Resolve the token in order: Bearer → `X-GROWI-ACCESS-TOKEN` header → `access_token`
+  query → `access_token` body.
+- A non-string (e.g. array-valued, from a duplicated header) `X-GROWI-ACCESS-TOKEN` value
+  is **skipped** so resolution falls through to the remaining sources, rather than
+  short-circuiting (3.4). Implement by coercing a non-string header to `undefined` before
+  the precedence chain so `??` continues past it.
+- Return `null` when no string-typed source resolves; otherwise return the string token.
+- Does not validate the token; resolution only.
+
+**Dependencies**
+- Outbound: `extractBearerToken` — Bearer parsing (P0).
+
+**Contracts**: Service [x]
+
+##### Service Interface
+```typescript
+export const X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token';
+
+export const extractAccessToken = (req: AccessTokenParserReq): string | null;
+```
+- Preconditions: `req` is an Express request (`AccessTokenParserReq`).
+- Postconditions: returns a string token, or `null` when no string-typed source resolves.
+- Invariants: precedence order is `Bearer ?? header ?? query ?? body`; Bearer always wins
+  when present (3.1); a non-string header is skipped so resolution falls through (3.4).
+
+#### parserForAccessToken / parserForApiToken (modified)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Reuse `extractAccessToken`; keep validation/authorization unchanged |
+| Requirements | 1.1, 1.2, 2.1, 2.2, 4.1, 4.2, 4.3 |
+
+**Responsibilities & Constraints**
+- Replace the inline `bearer ?? query ?? body` + `typeof` guard with
+  `const accessToken = extractAccessToken(req); if (accessToken == null) return;`.
+- All downstream behavior (scope check, read-only rejection, `serializeUserSecurely`,
+  legacy `acceptLegacy` gating in `index.ts`) is unchanged → satisfies 4.1–4.3 and 2.2 by
+  reuse, and 3.3 (non-regression) because the resolved value for header-absent requests is
+  identical to today.
+
+**Contracts**: Service [x] (signatures unchanged: `(req, res) => Promise<void>`)
+
+### API Spec
+
+#### OpenAPI security scheme
+
+**Contracts**: API [x]
+
+| Field | Value |
+|-------|-------|
+| Scheme name | `accessTokenHeaderAuth` |
+| `type` | `apiKey` |
+| `in` | `header` |
+| `name` | `x-growi-access-token` |
+
+- Declared in `components.securitySchemes` of both `definition-apiv1.js` and
+  `definition-apiv3.js`, added to the top-level `security` array (5.1).
+- Applied per-route as `- accessTokenHeaderAuth: []` alongside existing `bearer` and
+  `accessTokenInQuery` entries; existing schemes retained (5.2, 5.3).
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1 | Header auth, scoped path, no Bearer | extractAccessToken, parserForAccessToken | `extractAccessToken` | Extract→AT |
+| 1.2 | Header token with sufficient scope authenticates | parserForAccessToken | scope check (reused) | AT→Model |
+| 1.3 | Header name case-insensitive | extractAccessToken | Express lowercased key | — |
+| 2.1 | Header auth, legacy path, no Bearer | extractAccessToken, parserForApiToken | `extractAccessToken` | Extract→APIT |
+| 2.2 | Legacy header ignored when acceptLegacy off | index.ts gating (reused) | `accessTokenParser` opts | — |
+| 3.1 | Bearer wins over header | extractAccessToken | precedence invariant | — |
+| 3.2 | Header wins over query/body | extractAccessToken | precedence invariant | — |
+| 3.3 | No header → unchanged resolution | extractAccessToken, both parsers | precedence invariant | — |
+| 3.4 | Non-string header ignored | extractAccessToken | string guard | — |
+| 4.1 | Invalid header token → unauthenticated | parserForAccessToken, parserForApiToken | validation (reused) | — |
+| 4.2 | Insufficient scope → unauthenticated | parserForAccessToken | scope check (reused) | — |
+| 4.3 | Read-only user → unauthenticated | parserForAccessToken | readOnly check (reused) | — |
+| 5.1 | Declare accessTokenHeaderAuth scheme | OpenAPI definitions | API contract | — |
+| 5.2 | Apply scheme to advertising routes | route security blocks | API contract | — |
+| 5.3 | Retain existing schemes | OpenAPI definitions | API contract | — |
+
+## Error Handling
+
+The middleware does not throw on authentication failure: when no valid token resolves, it
+leaves `req.user` unset and calls `next()`, delegating rejection to downstream route
+authorization (existing behavior, preserved for the header source — 4.1). `extractAccessToken`
+never throws; non-string sources resolve to `null` (3.4). Token-source values continue to
+be logged only as truncated prefixes/suffixes (existing `api-token.ts` behavior) — the raw
+header value must not be logged.
+
+## Testing Strategy
+
+### Unit Tests (`extract-access-token.spec.ts`, new)
+- Returns Bearer token when both Bearer and `x-growi-access-token` header are present (3.1).
+- Returns header token when no Bearer but header + query are present (3.2).
+- Returns query/body token (in order) when no Bearer and no header present (3.3).
+- Returns `null` when `x-growi-access-token` is an array / non-string (3.4).
+- Resolves header regardless of letter casing of the key (1.3).
+
+### Integration Tests (salvaged + extended)
+- `access-token.integ.ts`: valid scoped token in `x-growi-access-token` header with a
+  satisfying scope authenticates the owner (1.1, 1.2).
+- `api-token.integ.ts`: valid legacy api-token in `x-growi-access-token` header
+  authenticates the owner (2.1).
+- (Reused coverage) existing invalid-token / insufficient-scope / read-only tests confirm
+  4.1–4.3 still hold via the shared resolution path.
+
+### OpenAPI Verification
+- Regenerate apiv1/apiv3 specs and confirm `accessTokenHeaderAuth` appears in
+  `securitySchemes` and on each previously `accessTokenInQuery`-advertising route; added
+  `accessTokenHeaderAuth: []` line count equals the `accessTokenInQuery` count (25).
+
+## Security Considerations
+
+- The header source is held to the **same** validation as all other sources (4.1–4.3); it
+  introduces no new bypass — the only change is *where* the token string is read from.
+- Motivation is to avoid tokens in URLs/query strings (information exposure via query
+  strings); the header method does not appear in URLs, logs-by-default, or referrers.
+- The raw token from the header must never be logged in full (mirror existing truncation).

+ 117 - 0
.kiro/specs/access-token-parser/requirements.md

@@ -0,0 +1,117 @@
+# Requirements Document
+
+## Introduction
+
+GROWI's API authentication accepts an access token only as a Bearer token in the
+`Authorization` header or as an `access_token` query/body parameter. When the
+`Authorization` header is already consumed (for example, Basic authentication on a
+reverse proxy), callers must fall back to the query parameter, which leaks the token into
+URLs, server logs, browser history, and referrers.
+
+This feature adds a dedicated request header, `X-GROWI-ACCESS-TOKEN`, as an additional
+token source for the Access Token Parser, covering both the scope-based access-token path
+and the legacy api-token path, and advertises it in the OpenAPI definitions. It also
+establishes this spec as the cc-sdd maintenance baseline for the Access Token Parser
+middleware. The salvage source is upstream PR #10443.
+
+## Boundary Context
+
+- **In scope**:
+  - Accepting the access token from the `X-GROWI-ACCESS-TOKEN` request header for both
+    the scope-based access-token path and the legacy api-token path.
+  - The priority of the header source relative to the existing Bearer, query, and body
+    sources.
+  - Declaring an `accessTokenHeaderAuth` security scheme in the apiv1 and apiv3 OpenAPI
+    definitions and applying it to the routes that already advertise the Bearer and
+    query token methods.
+- **Out of scope**:
+  - Changing the scope-evaluation model, the access-token storage model, or the legacy
+    api-token mechanism.
+  - Removing or deprecating the existing Bearer, query, or body token sources.
+  - Client/SDK changes or documentation-site updates beyond the in-repo OpenAPI
+    definitions.
+- **Adjacent expectations**:
+  - Authorization decisions (scope sufficiency, read-only restriction, token validity)
+    remain owned by the existing access-token validation; this feature only adds a new
+    place to read the token from and does not relax those checks.
+  - Downstream route authorization continues to reject unauthenticated requests; the
+    parser only attaches the authenticated user when a valid token is found.
+
+## Requirements
+
+### Requirement 1: Header token acceptance on the scope-based access-token path
+
+**Objective:** As an API caller whose `Authorization` header is already in use, I want to
+supply my scoped access token in the `X-GROWI-ACCESS-TOKEN` header, so that I can
+authenticate without exposing the token in the URL.
+
+#### Acceptance Criteria
+1. When a request carries a valid scoped access token in the `X-GROWI-ACCESS-TOKEN`
+   header and no Bearer token is present, the Access Token Parser shall authenticate the
+   request as the token's owner.
+2. When the access token supplied in the `X-GROWI-ACCESS-TOKEN` header grants a scope that
+   satisfies the route's required scope, the Access Token Parser shall attach the
+   authenticated user to the request.
+3. The Access Token Parser shall treat the `X-GROWI-ACCESS-TOKEN` header name
+   case-insensitively, accepting it regardless of the letter casing used by the client.
+
+### Requirement 2: Header token acceptance on the legacy api-token path
+
+**Objective:** As an API caller using a legacy api-token on a route that still accepts it,
+I want to supply that token in the `X-GROWI-ACCESS-TOKEN` header, so that I get the same
+header-based option as scoped tokens.
+
+#### Acceptance Criteria
+1. Where a route enables legacy api-token acceptance, when a request carries a valid
+   legacy api-token in the `X-GROWI-ACCESS-TOKEN` header and no Bearer token is present,
+   the Access Token Parser shall authenticate the request as the token's owner.
+2. Where a route does not enable legacy api-token acceptance, the Access Token Parser
+   shall not authenticate a request solely on the basis of a legacy api-token presented
+   in the `X-GROWI-ACCESS-TOKEN` header.
+
+### Requirement 3: Token source priority and non-regression of existing sources
+
+**Objective:** As an API caller, I want predictable precedence among the token sources, so
+that adding the header does not change the behavior of requests that already work.
+
+#### Acceptance Criteria
+1. When a request carries both a Bearer token in the `Authorization` header and a token in
+   the `X-GROWI-ACCESS-TOKEN` header, the Access Token Parser shall use the Bearer token.
+2. When a request carries a token in the `X-GROWI-ACCESS-TOKEN` header and also in the
+   `access_token` query or body parameter, and no Bearer token is present, the Access
+   Token Parser shall use the `X-GROWI-ACCESS-TOKEN` header value.
+3. When a request carries no `X-GROWI-ACCESS-TOKEN` header, the Access Token Parser shall
+   continue to resolve the token from the Bearer, query, and body sources exactly as
+   before this feature.
+4. If the `X-GROWI-ACCESS-TOKEN` header is present but is not a single string value, the
+   Access Token Parser shall ignore it and fall back to the remaining token sources.
+
+### Requirement 4: Invalid or insufficient header token handling
+
+**Objective:** As a security-conscious operator, I want header-supplied tokens to be held
+to the same validation as other sources, so that the new header cannot bypass any check.
+
+#### Acceptance Criteria
+1. If the token supplied in the `X-GROWI-ACCESS-TOKEN` header is invalid, expired, or
+   unknown, the Access Token Parser shall leave the request unauthenticated and allow
+   downstream authorization to reject it.
+2. If the scoped access token supplied in the `X-GROWI-ACCESS-TOKEN` header lacks a scope
+   sufficient for the route, the Access Token Parser shall leave the request
+   unauthenticated.
+3. If the access token supplied in the `X-GROWI-ACCESS-TOKEN` header belongs to a
+   read-only user on a path that rejects read-only users, the Access Token Parser shall
+   leave the request unauthenticated.
+
+### Requirement 5: OpenAPI advertisement of the header authentication method
+
+**Objective:** As an API consumer reading the OpenAPI specification, I want the header
+authentication method to be documented, so that I know `X-GROWI-ACCESS-TOKEN` is a
+supported way to authenticate.
+
+#### Acceptance Criteria
+1. The apiv1 and apiv3 OpenAPI definitions shall declare an `accessTokenHeaderAuth`
+   security scheme that authenticates via the `x-growi-access-token` request header.
+2. Where a route already advertises the Bearer and query token methods, the OpenAPI
+   definition shall also advertise the `accessTokenHeaderAuth` method for that route.
+3. The OpenAPI definitions shall retain the existing `bearer` and `accessTokenInQuery`
+   security methods alongside the new `accessTokenHeaderAuth` method.

+ 163 - 0
.kiro/specs/access-token-parser/research.md

@@ -0,0 +1,163 @@
+# Gap Analysis: access-token-parser (X-GROWI-ACCESS-TOKEN header)
+
+_Date: 2026-05-29 · Salvage source: PR #10443 (`support/api-token-header` → `master`)_
+
+## 1. Current State
+
+The middleware already exists at
+`apps/app/src/server/middlewares/access-token-parser/`:
+
+| File | Role | Token extraction today |
+|------|------|------------------------|
+| `index.ts` | `accessTokenParser(scopes, opts)` orchestrator; runs `parserForAccessToken`, then `parserForApiToken` when `opts.acceptLegacy` | — |
+| `access-token.ts` | scope-checked `AccessToken` lookup | `bearer ?? query ?? body` |
+| `api-token.ts` | legacy `User.apiToken` lookup | `bearer ?? query ?? body` |
+| `extract-bearer-token.ts` | pulls Bearer from `Authorization` | (unchanged by this work) |
+| `*.integ.ts` | co-located integration tests | — |
+
+- `AccessTokenParserReq` (`packages/core/src/interfaces/server/access-token-parser.ts`)
+  extends Express `Request`, so `req.headers['x-growi-access-token']` is typed as
+  `string | string[] | undefined` — the existing `typeof accessToken !== 'string'` guard
+  already covers the array case.
+- **Header-name convention exists**: g2g-transfer declares
+  `export const X_GROWI_TRANSFER_KEY_HEADER_NAME = 'x-growi-transfer-key'`
+  (`apps/app/src/server/service/g2g-transfer.ts:40`) and references it in the OpenAPI
+  definition. This is the precedent for a shared header-name constant.
+- No reference to `x-growi-access-token` exists anywhere yet.
+- OpenAPI security schemes (`bearer`, `accessTokenInQuery`) are declared in
+  `apps/app/bin/openapi/definition-apiv1.js` and `definition-apiv3.js`.
+
+## 2. Requirement-to-Asset Map
+
+| Requirement | Existing asset | Gap | Tag |
+|-------------|----------------|-----|-----|
+| R1 header on scoped path | `access-token.ts` token resolution | insert header source between Bearer and query | Missing |
+| R2 header on legacy path | `api-token.ts` token resolution | insert header source between Bearer and query | Missing |
+| R3 priority / non-regression | both parsers' `??` chain | order header after Bearer, before query/body; array guard already present | Missing (partial reuse) |
+| R4 invalid/insufficient handling | `AccessToken.findUserIdByToken`, scope check, `readOnly` check | none — validation is source-agnostic; reused as-is | Reuse |
+| R5 OpenAPI advertisement | apiv1/apiv3 definitions + per-route `security` blocks | add `accessTokenHeaderAuth` scheme + add `- accessTokenHeaderAuth: []` to every route block | Missing |
+
+## 3. Salvage Drift vs current master (CRITICAL)
+
+PR #10443 was authored on an older tree. The route-file portion has drifted; **do not
+apply the patch verbatim**:
+
+- **`app-settings.js` → `app-settings/index.ts`**: the PR edits `apps/app/src/server/routes/apiv3/app-settings.js`,
+  but master has refactored it to `app-settings/index.ts` (3 `accessTokenInQuery` blocks).
+  The PR hunk will not apply.
+- **`user-activities.ts` missed**: master's `user-activities.ts` (1 block) carries
+  `accessTokenInQuery` but is **not** in PR #10443. Verbatim salvage would leave it
+  inconsistent.
+- The PR also carries incidental `import` reordering noise (the `SCOPE` import moved) from
+  its older base — exclude it.
+
+**Authoritative route set in current master** — 8 files, 25 `accessTokenInQuery` blocks,
+each needs an `accessTokenHeaderAuth: []` sibling:
+
+| File | blocks |
+|------|--------|
+| `activity.ts` | 1 |
+| `user-activities.ts` | 1 |
+| `bookmark-folder.ts` | 6 |
+| `import.ts` | 4 |
+| `in-app-notification.ts` | 4 |
+| `page-listing.ts` | 4 |
+| `g2g-transfer.ts` | 2 |
+| `app-settings/index.ts` | 3 |
+
+**Robust salvage method**: instead of applying the PR patch, add
+`- accessTokenHeaderAuth: []` immediately after **every** `- accessTokenInQuery: []`
+occurrence in current master. This is drift-proof and self-verifying (count of added
+lines must equal 25). Carry the logic + test changes from the PR (they apply cleanly),
+discard its route-file hunks.
+
+## 4. Implementation Approach Options
+
+### Option A — Verbatim PR salvage (apply #10443 patch)
+- ✅ Fastest mechanically.
+- ❌ Breaks on `app-settings` path drift; ❌ misses `user-activities.ts`; ❌ imports noise.
+- **Rejected** — produces an inconsistent, non-applying result.
+
+### Option B — Extend in place, drift-corrected (recommended)
+- Logic: add `?? req.headers['x-growi-access-token']` between Bearer and query in both
+  `access-token.ts` and `api-token.ts`.
+- OpenAPI: declare `accessTokenHeaderAuth` in both definition files; add
+  `- accessTokenHeaderAuth: []` after every `accessTokenInQuery` block across the 8
+  current-master route files (25 sites).
+- Tests: port the two header-path integration tests from the PR.
+- ✅ Drift-proof, consistent, minimal new surface; reuses all validation.
+- ❌ Manual care across 25 sites (mitigated by the "after every accessTokenInQuery" rule).
+
+### Option C — Extract a shared header-name constant + Option B
+- In addition to Option B, define `X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token'`
+  (mirroring `X_GROWI_TRANSFER_KEY_HEADER_NAME`) and reference it from both parsers and the
+  OpenAPI `name` fields, removing the magic string.
+- ✅ Single source of truth, aligns with coding-style (no magic strings) and the existing
+  g2g convention; reduces drift risk for future changes.
+- ❌ Slightly larger diff than the raw PR; OpenAPI `.js` files use literal strings today, so
+  the constant may only be cleanly shared on the parser side unless the definition files
+  import it. **Decision for design phase**: whether to thread the constant into the
+  OpenAPI definitions or keep the literal there.
+
+## 5. Effort & Risk
+
+- **Effort: S (1–3 days)** — established pattern, one-line logic change ×2, mechanical
+  OpenAPI edits ×25, two integration tests.
+- **Risk: Low** — validation/authorization reused unchanged; header is purely additive and
+  guarded; existing sources untouched (R3 non-regression). Main risk is **coverage
+  completeness** of the 25 OpenAPI sites, mitigated by the count check.
+
+## 6. Recommendations for Design Phase
+
+- **Preferred approach**: Option B, with Option C's constant as a recommended refinement.
+- **Key decisions to settle in design**:
+  1. Whether to introduce `X_GROWI_ACCESS_TOKEN_HEADER_NAME` and whether the OpenAPI
+     definition `.js` files reference it or keep the literal.
+  2. Confirm the priority order `Bearer ?? header ?? query ?? body` for both parsers.
+  3. Confirm the OpenAPI route set = the 8 current-master files (not the PR's 7), explicitly
+     including `user-activities.ts` and `app-settings/index.ts`.
+- **Research items**: none outstanding — the change is well understood and self-contained.
+- **Branch reminder**: cut from `master`, not the current `imprv/x-access-token-header`
+  branch (which carries unrelated MongoDB-regex work).
+
+---
+
+## Design Synthesis Outcomes (design phase)
+
+**Generalization** — R1 (scoped path) and R2 (legacy path) are the same problem: read the
+token from one more source at the same precedence position. Both parsers duplicate the
+`bearer ?? query ?? body` chain + `typeof` guard. Decision: extract a pure
+`extractAccessToken(req): string | null` helper (new `extract-access-token.ts`) owning the
+precedence `Bearer ?? header ?? query ?? body`. This makes precedence the single source of
+truth (directly serves R3's cross-parser consistency) and removes the drift seam. Aligns
+with coding-style "Pure Function Extraction" and the recorded feedback on single source of
+truth / drift prevention.
+
+**Build vs Adopt** — Header name: adopt the existing `X_GROWI_TRANSFER_KEY_HEADER_NAME`
+precedent (g2g-transfer.ts) → define `X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token'`
+in the parser TS module. Express natively lowercases header keys → case-insensitive (R1.3),
+no library needed. OpenAPI `.js` definitions keep the literal string (CommonJS build
+scripts), mirroring how g2g keeps the literal in OpenAPI while the constant lives in TS.
+
+**Simplification** — No config/feature-flag for the header (out of requirements). Do not
+modify `extract-bearer-token.ts`. Centralize the `typeof !== 'string'` guard inside
+`extractAccessToken` so both parsers collapse to
+`const accessToken = extractAccessToken(req); if (accessToken == null) return;`. Parser
+signatures and all validation/authorization remain unchanged (reuse → R4, R2.2, R3.3).
+
+**Route-edit method (drift-proof)** — Drive OpenAPI route edits off current-master
+`accessTokenInQuery` sites (8 files / 25 blocks), NOT the PR #10443 file list. Add
+`- accessTokenHeaderAuth: []` after each. Self-check: added line count == 25.
+
+---
+
+## Coverage Correction (task 3.2 implementation)
+
+The gap-analysis route sweep grepped only `apps/app/src/server/routes` and undercounted.
+A full-tree sweep (`grep -rn accessTokenInQuery apps/app/src`) finds **26** sites across
+**9** files — the 8 originally listed plus
+`apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts` (1) in the
+`features/` tree. Requirement 5.2 ("every route advertising the query method also
+advertises the header method") requires this 9th file, so task 3.2's scope was extended
+to 26 sites. Lesson: sweep `apps/app/src` (including `features/`), not just
+`server/routes/apiv3`, when enumerating OpenAPI security blocks.

+ 22 - 0
.kiro/specs/access-token-parser/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "access-token-parser",
+  "created_at": "2026-05-29T13:26:57Z",
+  "updated_at": "2026-05-29T14:45: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
+}

+ 57 - 0
.kiro/specs/access-token-parser/tasks.md

@@ -0,0 +1,57 @@
+# Implementation Plan
+
+> Salvage source: PR #10443. Branch from `master` (NOT the current
+> `imprv/x-access-token-header` branch, which carries unrelated MongoDB-regex work).
+> Test-first per repo TDD policy.
+
+- [x] 1. Foundation: shared token-source extraction utility
+- [x] 1.1 Create the shared token-source extractor with unit tests (test-first)
+  - Write failing unit tests first, covering: precedence Bearer > `X-GROWI-ACCESS-TOKEN` header > query > body; non-string / array-valued header is ignored; header key resolves case-insensitively
+  - Define the canonical header-name constant and implement the pure extractor that returns the resolved token string or null
+  - Observable: a new unit test file passes, exercising every precedence, guard, and casing case; the no-header case resolves exactly to the prior Bearer/query/body result
+  - _Requirements: 1.3, 3.1, 3.2, 3.3, 3.4_
+  - _Boundary: extractAccessToken_
+
+- [x] 2. Core: parser integration with header support
+- [x] 2.1 (P) Route the scoped access-token parser through the shared extractor
+  - Replace the inline token chain and type guard with the shared extractor; leave scope check, read-only rejection, and user serialization unchanged
+  - Add an integration test: a valid scoped token supplied in the `X-GROWI-ACCESS-TOKEN` header with a satisfying scope authenticates the token owner
+  - Observable: the access-token integration suite passes including the new header test, and the existing invalid-token / insufficient-scope / read-only tests remain green
+  - _Requirements: 1.1, 1.2, 4.1, 4.2, 4.3_
+  - _Boundary: parserForAccessToken_
+  - _Depends: 1.1_
+- [x] 2.2 (P) Route the legacy api-token parser through the shared extractor
+  - Replace the inline token chain and type guard with the shared extractor
+  - Add an integration test: a valid legacy api-token supplied in the `X-GROWI-ACCESS-TOKEN` header authenticates the owner; confirm the `acceptLegacy` gating is unchanged (legacy token ignored when the route does not opt in)
+  - Observable: the api-token integration suite passes including the new header test
+  - _Requirements: 2.1, 2.2, 4.1_
+  - _Boundary: parserForApiToken_
+  - _Depends: 1.1_
+
+- [x] 3. Integration: OpenAPI advertisement of the header method
+- [x] 3.1 (P) Declare the `accessTokenHeaderAuth` security scheme in the apiv1 and apiv3 definitions
+  - Add an `apiKey` / `in: header` / `name: x-growi-access-token` scheme to the security schemes and to the top-level security array in both definition files
+  - Independent of tasks 2.1/2.2 (separate boundary, no shared files), so it may run concurrently with the parser work
+  - Observable: both definition files contain the new scheme while retaining the existing `bearer` and `accessTokenInQuery` schemes
+  - _Requirements: 5.1, 5.3_
+  - _Boundary: OpenAPI definitions_
+- [x] 3.2 Apply the header auth method to every advertising route
+  - Add an `accessTokenHeaderAuth` entry after every `accessTokenInQuery` block across the 9 current-master route files (26 sites): activity, user-activities, bookmark-folder, import, in-app-notification, page-listing, g2g-transfer, app-settings index, and features/ai-tools/suggest-path. Do not apply PR #10443's route hunks verbatim — drive edits off a full-tree sweep (`grep -rn accessTokenInQuery apps/app/src`) to absorb the `app-settings` path drift, the missing `user-activities`, and the `features/` suggest-path route
+  - Observable: the number of added `accessTokenHeaderAuth` lines equals 26, and every route that advertises `accessTokenInQuery` also advertises `accessTokenHeaderAuth`
+  - _Requirements: 5.2_
+  - _Boundary: apiv3 route security blocks_
+  - _Depends: 3.1_
+
+- [x] 4. Validation: regression and spec verification
+- [x] 4.1 Verify OpenAPI regeneration and run end-to-end quality gates
+  - Regenerate the apiv1/apiv3 specs and confirm `accessTokenHeaderAuth` appears in the schemes and on each route that previously advertised `accessTokenInQuery`
+  - Run lint, the full access-token-parser test suite, and the build for the app package
+  - Confirm non-regression: requests with no `X-GROWI-ACCESS-TOKEN` header resolve identically to pre-change behavior
+  - Observable: lint/typecheck/tests green and regenerated specs consistent (0 query-ops missing the header method; added-line count check = 26). NOTE: the full app build is blocked by a pre-existing, unrelated devcontainer dependency-hoisting issue (`@lezer/*`, `styled-jsx` in the client bundle) — see Implementation Notes; verify the production build in CI.
+  - _Requirements: 3.3, 5.1, 5.2, 5.3_
+  - _Depends: 2.1, 2.2, 3.2_
+
+## Implementation Notes
+- Req 3.4 semantics: a non-string `X-GROWI-ACCESS-TOKEN` value (duplicated header → array) is coerced to `undefined` before the `??` chain so resolution falls through to query/body, per requirements.md 3.4. design.md was corrected to match (the initial "centralized guard at end" wording implied short-circuit-to-null).
+- OpenAPI route coverage: enumerate `accessTokenInQuery` with a FULL-tree sweep (`grep -rn accessTokenInQuery apps/app/src`), not just `server/routes/apiv3` — the `features/` tree holds the suggest-path route (26 sites / 9 files, not 25 / 8).
+- Task 4.1 build gate: `turbo run build --filter @growi/app` FAILS in this devcontainer on a PRE-EXISTING, unrelated client-bundle dependency-hoisting issue — Turbopack cannot resolve `@lezer/common`, `@lezer/lr` (transitive deps of `@codemirror/lang-python`/`lang-yaml`) and `styled-jsx` (import trace: ConflictDiffModal → editor → codemirror; none touched by this server-only change). `pnpm install --frozen-lockfile` reports "Already up to date", so the state is lockfile-defined and independent of this feature. Verified green for this change: 23/23 access-token-parser tests, `lint:typecheck` (exit 0), `lint:openapi:apiv1`+`apiv3` (1 passing/0 failing, 0 query-ops missing the header method), biome on changed files (only the pre-existing `res`-unused warning). The production build should be confirmed in CI (`reusable-app-prod.yml`), where the dependency environment is correct.

+ 8 - 1
.kiro/steering/tdd.md

@@ -1,6 +1,13 @@
 # Test-Driven Development
 
-See: `.claude/commands/tdd.md`, `.claude/skills/learned/essential-test-patterns/SKILL.md` and `.claude/skills/learned/essential-test-design/SKILL.md`
+The RED → GREEN → REFACTOR enforcement workflow lives in the `kiro-impl` skill
+(`.claude/skills/kiro-impl/SKILL.md`), which gates every task on a captured
+failing-test (`RED_PHASE_OUTPUT`) before implementation.
+
+For how to *write* the tests well, see `.claude/skills/essential-test-design/SKILL.md`
+(test the contract, not the mechanism) and `.claude/skills/essential-test-patterns/SKILL.md`
+(Vitest / RTL / type-safe mocking). The `testing` rule (`.claude/rules/testing.md`)
+is always loaded and points to both.
 
 ## cc-sdd Specific Notes
 

+ 2 - 2
AGENTS.md

@@ -27,6 +27,7 @@ GROWI is a team collaboration wiki platform using Markdown, featuring hierarchic
 | **github-cli** | **CRITICAL**: gh CLI auth required; stop immediately if unauthenticated |
 
 | **testing** | Test commands, pnpm vitest usage |
+| **mongodb-regex** | `RegExp.escape()` breaks MongoDB PCRE2 for non-ASCII whitespace; use `escapeStringForMongoRegex` for query-bound patterns |
 
 ### On-Demand Skills
 
@@ -41,7 +42,6 @@ GROWI is a team collaboration wiki platform using Markdown, featuring hierarchic
 
 | Command | Description |
 |---------|-------------|
-| **/tdd** | Test-driven development workflow |
 | **/learn** | Extract reusable patterns from sessions |
 
 **apps/app Skills** (load via Skill tool when working in apps/app):
@@ -90,7 +90,7 @@ growi/
     ├── rules/              # Always loaded into every session
     ├── skills/             # Load on demand via Skill tool
     ├── agents/             # Specialized subagents
-    └── commands/           # User-invocable commands (/tdd, /learn)
+    └── commands/           # User-invocable commands (/learn)
 ```
 
 ## Development Guidelines

+ 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 `.claude/skills/learned/essential-test-patterns` and `.claude/skills/learned/essential-test-design` skills.
+For general testing patterns, see the global `.claude/skills/essential-test-patterns` and `.claude/skills/essential-test-design` skills.
 
 ## Next.js Pages Router
 

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

@@ -7,3 +7,5 @@
 ##
 VITE_MONGOMS_VERSION="6.0.9"
 # VITE_MONGOMS_DEBUG=1
+
+VITE_ELASTICSEARCH_URI="http://elasticsearch:9200/growi"

+ 7 - 0
apps/app/bin/openapi/definition-apiv1.js

@@ -24,6 +24,7 @@ module.exports = {
   security: [
     {
       bearer: [],
+      accessTokenHeaderAuth: [],
       accessTokenInQuery: [],
     },
   ],
@@ -34,6 +35,12 @@ module.exports = {
         scheme: 'bearer',
         description: 'Access token generated by each GROWI users',
       },
+      accessTokenHeaderAuth: {
+        type: 'apiKey',
+        in: 'header',
+        name: 'x-growi-access-token',
+        description: 'Access token generated by each GROWI users',
+      },
       accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',

+ 7 - 0
apps/app/bin/openapi/definition-apiv3.js

@@ -24,6 +24,7 @@ module.exports = {
   security: [
     {
       bearer: [],
+      accessTokenHeaderAuth: [],
       accessTokenInQuery: [],
     },
   ],
@@ -34,6 +35,12 @@ module.exports = {
         scheme: 'bearer',
         description: 'Access token generated by each GROWI users',
       },
+      accessTokenHeaderAuth: {
+        type: 'apiKey',
+        in: 'header',
+        name: 'x-growi-access-token',
+        description: 'Access token generated by each GROWI users',
+      },
       accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',

+ 9 - 8
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.5.4",
+  "version": "7.5.5-RC.0",
   "license": "MIT",
   "private": true,
   "scripts": {
@@ -42,10 +42,12 @@
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
     "test": "vitest run",
+    "test:unit": "vitest run --project=app-unit",
+    "test:integ": "vitest run --project=app-integration",
+    "test:components": "vitest run --project=app-components",
     "test:coverage": "run-p test:coverage:* test:integ",
     "test:coverage:unit": "COLUMNS=200 vitest run --coverage --project=app-unit",
     "test:coverage:components": "COLUMNS=200 vitest run --coverage --project=app-components",
-    "test:integ": "vitest run --project=app-integration",
     "//// misc": "",
     "console": "npm run repl",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
@@ -73,7 +75,7 @@
     "@azure/openai": "^2.0.0",
     "@azure/storage-blob": "^12.16.0",
     "@codemirror/autocomplete": "^6.18.4",
-    "@codemirror/commands": "^6.8.0",
+    "@codemirror/commands": "^6.10.3",
     "@codemirror/lang-markdown": "^6.3.2",
     "@codemirror/language": "^6.12.1",
     "@codemirror/language-data": "^6.5.1",
@@ -129,7 +131,7 @@
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^1.15.0",
+    "axios": "^1.16.0",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
@@ -175,7 +177,7 @@
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "jotai": "^2.12.3",
-    "js-cookie": "^3.0.5",
+    "js-cookie": "^3.0.7",
     "js-tiktoken": "^1.0.15",
     "js-yaml": "^4.1.1",
     "jsonrepair": "^3.12.0",
@@ -187,7 +189,7 @@
     "mdast-util-from-markdown": "^2.0.1",
     "mdast-util-gfm-table": "^2.0.0",
     "mdast-util-wiki-link": "^0.1.2",
-    "mermaid": "^11.10.0",
+    "mermaid": "^11.15.0",
     "method-override": "^3.0.0",
     "micromark-extension-gfm-table": "^2.1.0",
     "micromark-extension-wiki-link": "^0.0.4",
@@ -223,7 +225,7 @@
     "pathe": "^2.0.3",
     "pretty-bytes": "^6.1.1",
     "prop-types": "^15.8.1",
-    "qs": "^6.14.2",
+    "qs": "^6.15.2",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
@@ -278,7 +280,6 @@
     "ts-node": "^10.9.2",
     "tsconfig-paths": "^4.2.0",
     "tslib": "^2.8.0",
-    "uglifycss": "^0.0.29",
     "uid-safe": "^2.1.5",
     "umzug": "^3.8.2",
     "unified": "^11.0.0",

+ 15 - 0
apps/app/playwright/10-installer/install.spec.ts

@@ -52,4 +52,19 @@ test('Installer', async ({ page }) => {
 
   await page.waitForURL('/', { timeout: 20000 });
   await expect(page).toHaveTitle(/\/ - GROWI/);
+
+  // Verify / page has content from welcome.md
+  await expect(
+    page.getByRole('heading', { level: 1, name: /Welcome to GROWI/ }),
+  ).toBeVisible();
+
+  // Verify /Sandbox page was created with content from sandbox.md
+  await page.goto('/Sandbox');
+  await expect(page).toHaveTitle(/Sandbox/);
+  await expect(
+    page.getByRole('heading', {
+      level: 1,
+      name: /Welcome to the GROWI Sandbox/,
+    }),
+  ).toBeVisible();
 });

+ 77 - 0
apps/app/playwright/23-editor/grant-preload-race.spec.ts

@@ -0,0 +1,77 @@
+import { expect, test } from '@playwright/test';
+
+import { appendTextToEditorUntilContains } from '../utils/AppendTextToEditorUntilContains';
+
+/**
+ * Regression test for the pre-load race in issue #11272.
+ *
+ * When the editor opens, the current page's grant is fetched asynchronously
+ * (GET /_api/v3/page/grant-data) and synced into selectedGrantAtom. Until that
+ * resolves, selectedGrant is null. Saving in that window must NOT change the
+ * page's grant — otherwise a restricted page is silently published.
+ *
+ * This drives the real cross-stack behavior:
+ *   1. create a GRANT_OWNER ("only me") page,
+ *   2. hold the grant-data response so the editor opens with selectedGrant still null,
+ *   3. edit and save immediately (a real save to the DB),
+ *   4. read the page's grant back and assert it is still GRANT_OWNER.
+ *
+ * page.request (APIRequestContext) is not subject to page.route, so the setup and
+ * verification calls bypass the hold that only affects the browser's fetch.
+ */
+
+const GRANT_DATA_ROUTE = '**/_api/v3/page/grant-data**';
+const GRANT_OWNER = 4; // PageGrant.GRANT_OWNER
+
+const readGrant = async (
+  request: import('@playwright/test').APIRequestContext,
+  pageId: string,
+): Promise<number> => {
+  const res = await request.get('/_api/v3/page/grant-data', {
+    params: { pageId },
+  });
+  expect(res.ok()).toBeTruthy();
+  return (await res.json()).grantData.currentPageGrant.grant;
+};
+
+test('keeps an owner-only grant when saving before the grant loads (#11272)', async ({
+  page,
+}) => {
+  const pagePath = `/grant-preload-race-${Date.now()}`;
+
+  // 1. Create an "only me" (GRANT_OWNER) page.
+  const createRes = await page.request.post('/_api/v3/page', {
+    data: { path: pagePath, body: 'initial body', grant: GRANT_OWNER },
+  });
+  expect(createRes.ok()).toBeTruthy();
+  const createdPageId: string = (await createRes.json()).page._id;
+  expect(await readGrant(page.request, createdPageId)).toBe(GRANT_OWNER);
+
+  // 2. Block the browser's grant-data fetch so the editor opens with
+  //    selectedGrant still unresolved (null) — the pre-load window. Aborting is a
+  //    deterministic stand-in for "not loaded yet". page.request (used below for
+  //    verification) is an APIRequestContext and is NOT affected by page.route.
+  await page.route(GRANT_DATA_ROUTE, async (route) => {
+    if (route.request().method() === 'GET') {
+      await route.abort();
+      return;
+    }
+    await route.continue();
+  });
+
+  await page.goto(pagePath);
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // 3. Edit and save immediately, while selectedGrant is still null.
+  await appendTextToEditorUntilContains(page, 'edited before grant loaded');
+  const updateResponse = page.waitForResponse(
+    (res) =>
+      res.url().includes('/_api/v3/page') && res.request().method() === 'PUT',
+  );
+  await page.getByTestId('save-page-btn').click();
+  expect((await updateResponse).ok()).toBeTruthy();
+
+  // 4. The stored grant must still be owner-only (not published).
+  expect(await readGrant(page.request, createdPageId)).toBe(GRANT_OWNER);
+});

+ 1 - 1
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx

@@ -112,7 +112,7 @@ const DescendantsPageListModalSubstance = ({
 
   const buttons = useMemo(
     () => (
-      <span className="me-3">
+      <span className="ms-auto me-3">
         <ExpandOrContractButton
           isWindowExpanded={isWindowExpanded}
           expandWindow={expandWindow}

+ 1 - 1
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -118,7 +118,7 @@ const PageAccessoriesModalSubstance = ({
 
   const buttons = useMemo(
     () => (
-      <span className="me-3">
+      <span className="ms-auto me-3">
         <ExpandOrContractButton
           isWindowExpanded={isWindowExpanded}
           expandWindow={expandWindow}

+ 1 - 1
apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx

@@ -100,7 +100,7 @@ const ConflictDiffModalSubstance = (
 
   const headerButtons = useMemo(
     () => (
-      <div className="d-flex align-items-center">
+      <div className="d-flex align-items-center ms-auto">
         <button
           type="button"
           className="btn"

+ 56 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.spec.tsx

@@ -0,0 +1,56 @@
+import type { ReactNode } from 'react';
+import { PageGrant } from '@growi/core';
+import { act, render, renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+
+import type { IPageSelectedGrant } from '~/interfaces/page';
+import { useSelectedGrant } from '~/states/ui/editor';
+
+import { GrantSelector } from './GrantSelector';
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({ t: (key: string) => key }),
+}));
+vi.mock('~/states/global', () => ({ useCurrentUser: vi.fn(() => undefined) }));
+vi.mock('~/states/page', () => ({ useCurrentPageId: vi.fn(() => 'page1') }));
+vi.mock('~/stores/page', () => ({
+  useSWRxCurrentGrantData: vi.fn(() => ({ data: undefined })),
+}));
+
+const renderGrantSelector = (
+  seed?: (set: (grant: IPageSelectedGrant | null) => void) => void,
+) => {
+  const store = createStore();
+  const wrapper = ({ children }: { children: ReactNode }) => (
+    <Provider store={store}>{children}</Provider>
+  );
+
+  if (seed != null) {
+    const { result } = renderHook(() => useSelectedGrant(), { wrapper });
+    act(() => seed(result.current[1]));
+  }
+
+  return render(<GrantSelector />, { wrapper });
+};
+
+describe('GrantSelector', () => {
+  // Before the current page's grant is loaded, selectedGrant is null. Showing the
+  // default "Public" option would mislead the user; show a loading state instead.
+  // See: https://github.com/growilabs/growi/issues/11272
+  it('shows a loading state while the grant is not yet resolved (null)', () => {
+    const { queryByTestId } = renderGrantSelector();
+
+    expect(queryByTestId('grw-grant-selector-loading')).not.toBeNull();
+    // ...and the selector dropdown is not shown yet (no misleading "Public").
+    expect(queryByTestId('grw-grant-selector-dropdown-menu')).toBeNull();
+  });
+
+  it('shows the grant selector once the grant is available', () => {
+    const { queryByTestId } = renderGrantSelector((set) =>
+      set({ grant: PageGrant.GRANT_OWNER }),
+    );
+
+    expect(queryByTestId('grw-grant-selector-loading')).toBeNull();
+    expect(queryByTestId('grw-grant-selector-dropdown-menu')).not.toBeNull();
+  });
+});

+ 31 - 28
apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx

@@ -1,10 +1,4 @@
-import React, {
-  type JSX,
-  type ReactNode,
-  useCallback,
-  useEffect,
-  useState,
-} from 'react';
+import React, { type JSX, type ReactNode, useCallback, useState } from 'react';
 import { GroupType, getIdForRef, PageGrant } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -22,7 +16,7 @@ import type { UserRelatedGroupsData } from '~/interfaces/page';
 import { UserGroupPageGrantStatus } from '~/interfaces/page';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentPageId } from '~/states/page';
-import { useSelectedGrant } from '~/states/ui/editor';
+import { toSelectedGrant, useSelectedGrant } from '~/states/ui/editor';
 import { useSWRxCurrentGrantData } from '~/stores/page';
 
 const AVAILABLE_GRANTS = [
@@ -79,26 +73,14 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const currentPageGrantData = grantData?.grantData.currentPageGrant;
   const groupGrantData = currentPageGrantData?.groupGrantData;
 
+  // Re-apply the current page grant when the user (re)opens the group selection,
+  // so the modal reflects the groups currently granted to the page.
+  // Initial sync of selectedGrantAtom is owned by useSyncSelectedGrantWithCurrentPage
+  // (called from the always-mounted SavePageControls) — see issue #11272.
   const applyCurrentPageGrantToSelectedGrant = useCallback(() => {
-    const currentPageGrant = grantData?.grantData.currentPageGrant;
-    if (currentPageGrant == null) return;
-
-    const userRelatedGrantedGroups =
-      currentPageGrant.groupGrantData?.userRelatedGroups
-        .filter((group) => group.status === UserGroupPageGrantStatus.isGranted)
-        ?.map((group) => {
-          return { item: group.id, type: group.type };
-        }) ?? [];
-    setSelectedGrant({
-      grant: currentPageGrant.grant,
-      userRelatedGrantedGroups,
-    });
-  }, [grantData?.grantData.currentPageGrant, setSelectedGrant]);
-
-  // sync grant data
-  useEffect(() => {
-    applyCurrentPageGrantToSelectedGrant();
-  }, [applyCurrentPageGrantToSelectedGrant]);
+    if (currentPageGrantData == null) return;
+    setSelectedGrant(toSelectedGrant(currentPageGrantData));
+  }, [currentPageGrantData, setSelectedGrant]);
 
   const showSelectGroupModal = useCallback(() => {
     setIsSelectGroupModalShown(true);
@@ -159,6 +141,27 @@ export const GrantSelector = (props: Props): JSX.Element => {
    * Render grant selector DOM.
    */
   const renderGrantSelector = useCallback(() => {
+    // Until the current page grant is loaded, selectedGrant is null. Show a loading
+    // state instead of defaulting the toggle to "Public", which would mislead the
+    // user about the page's actual visibility. See issue #11272.
+    if (selectedGrant == null) {
+      return (
+        <div
+          className="grw-grant-selector mb-0"
+          data-testid="grw-grant-selector"
+        >
+          <button
+            type="button"
+            className="btn btn-outline-secondary btn-sm w-100 d-flex justify-content-center align-items-center"
+            disabled
+            data-testid="grw-grant-selector-loading"
+          >
+            <LoadingSpinner />
+          </button>
+        </div>
+      );
+    }
+
     let dropdownToggleBtnColor: string | undefined;
     let dropdownToggleLabelElm: ReactNode | undefined;
 
@@ -377,7 +380,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     return (
       <button
         type="button"
-        className="btn border-0 text-muted"
+        className="btn border-0 text-muted ms-auto"
         onClick={() => setIsSelectGroupModalShown(false)}
       >
         <span className="material-symbols-outlined">close</span>

+ 7 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx

@@ -32,6 +32,7 @@ import {
   useEditorMode,
   useIsSlackEnabled,
   useSelectedGrant,
+  useSyncSelectedGrantWithCurrentPage,
   useWaitingSaveProcessing,
 } from '~/states/ui/editor';
 import { useSWRxSlackChannels } from '~/stores/editor';
@@ -219,6 +220,12 @@ export const SavePageControls = (): JSX.Element | null => {
   const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] =
     useState<boolean>(false);
 
+  // Initialize selectedGrantAtom from the current page's grant here, because
+  // SavePageControls is always mounted while editing. GrantSelector — which used
+  // to own this — is rendered inside a closed Modal on mobile and never mounts.
+  // See: https://github.com/growilabs/growi/issues/11272
+  useSyncSelectedGrantWithCurrentPage();
+
   // DO NOT dependent on slackChannelsData directly: https://github.com/growilabs/growi/pull/7332
   const slackChannelsDataString = slackChannelsData?.toString();
   useEffect(() => {

+ 1 - 1
apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx

@@ -489,7 +489,7 @@ const HandsontableModalSubstance = (
   });
 
   const closeButton = (
-    <span>
+    <span className="ms-auto">
       <ExpandOrContractButton
         isWindowExpanded={isWindowExpanded}
         contractWindow={contractWindow}

+ 6 - 7
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -47,6 +47,7 @@ import {
 } from '~/states/server-configurations';
 import {
   EditorMode,
+  toPageUpdateGrantParams,
   useCurrentIndentSize,
   useCurrentIndentSizeActions,
   useEditingMarkdown,
@@ -223,11 +224,8 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
 
   const save: Save = useCallback(
     async (revisionId, markdown, opts, onConflict) => {
-      if (pageId == null || selectedGrant == null) {
-        logger.error(
-          { pageId, selectedGrant },
-          'Some materials to save are invalid',
-        );
+      if (pageId == null) {
+        logger.error({ pageId }, 'Some materials to save are invalid');
         throw new Error('Some materials to save are invalid');
       }
 
@@ -239,9 +237,10 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
           revisionId,
           wip: opts?.wip,
           body: markdown ?? '',
-          grant: selectedGrant?.grant,
           origin: Origin.Editor,
-          userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
+          // Omits grant when none is selected (null) so the server preserves the
+          // page's existing grant instead of overwriting it — see issue https://github.com/growilabs/growi/issues/11272.
+          ...toPageUpdateGrantParams(selectedGrant),
           ...(opts ?? {}),
         });
 

+ 12 - 3
apps/app/src/client/services/renderer/renderer.tsx

@@ -112,7 +112,10 @@ export const generateViewOptions = (
       lsxGrowiDirective.rehypePlugin,
       { pagePath, isSharedPage: config.isSharedPage },
     ],
-    [refsGrowiDirective.rehypePlugin, { pagePath }],
+    [
+      refsGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     rehypeSanitizePlugin,
     katex,
     [relocateToc.rehypePluginStore, { storeTocNode }],
@@ -238,7 +241,10 @@ export const generateSimpleViewOptions = (
       lsxGrowiDirective.rehypePlugin,
       { pagePath, isSharedPage: config.isSharedPage },
     ],
-    [refsGrowiDirective.rehypePlugin, { pagePath }],
+    [
+      refsGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
     katex,
@@ -342,7 +348,10 @@ export const generatePreviewOptions = (
       lsxGrowiDirective.rehypePlugin,
       { pagePath, isSharedPage: config.isSharedPage },
     ],
-    [refsGrowiDirective.rehypePlugin, { pagePath }],
+    [
+      refsGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,
     katex,

+ 1 - 0
apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts

@@ -99,6 +99,7 @@ const validator = [
  *     security:
  *       - bearer: []
  *       - accessTokenInQuery: []
+ *       - accessTokenHeaderAuth: []
  *     requestBody:
  *       required: true
  *       content:

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx

@@ -29,7 +29,7 @@ export const AiAssistantManagementHeader = (props: Props): JSX.Element => {
     <ModalHeader
       tag="h4"
       close={
-        <button type="button" className="btn p-0" onClick={close}>
+        <button type="button" className="btn p-0 ms-auto" onClick={close}>
           <span className="material-symbols-outlined">close</span>
         </button>
       }

+ 4 - 2
apps/app/src/features/openai/server/services/openai.ts

@@ -11,7 +11,7 @@ import {
   isPopulated,
   PageGrant,
 } from '@growi/core';
-import { deepEquals } from '@growi/core/dist/utils';
+import { deepEquals, escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
@@ -77,7 +77,9 @@ const convertPathPatternsToRegExp = (
   return pagePathPatterns.map((pagePathPattern) => {
     if (isGlobPatternPath(pagePathPattern)) {
       const trimedPagePathPattern = pagePathPattern.replace('/*', '');
-      const escapedPagePathPattern = RegExp.escape(trimedPagePathPattern);
+      const escapedPagePathPattern = escapeStringForMongoRegex(
+        trimedPagePathPattern,
+      );
       // https://regex101.com/r/x5KIZL/1
       return new RegExp(`^${escapedPagePathPattern}($|/)`);
     }

+ 151 - 3
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts

@@ -1,4 +1,5 @@
 import mongoose from 'mongoose';
+import { mock } from 'vitest-mock-extended';
 
 import type Crowi from '~/server/crowi';
 import { configManager } from '~/server/service/config-manager';
@@ -36,9 +37,15 @@ vi.mock('./page-bulk-export-job-cron', () => {
 });
 
 describe('PageBulkExportJobCleanUpCronService', () => {
-  const crowi = {} as Crowi;
-  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
-  let user;
+  const removeAttachmentMock = vi.fn(() => Promise.resolve());
+  const crowi = mock<Crowi>({
+    attachmentService: {
+      removeAttachment: removeAttachmentMock,
+    },
+  });
+  let user: mongoose.HydratedDocument<
+    mongoose.InferSchemaType<typeof userSchema>
+  >;
 
   beforeAll(async () => {
     await configManager.loadConfigs();
@@ -52,6 +59,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
 
   beforeEach(async () => {
     await PageBulkExportJob.deleteMany();
+    removeAttachmentMock.mockClear();
   });
 
   describe('deleteExpiredExportJobs', () => {
@@ -177,6 +185,146 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     });
   });
 
+  // Regression coverage for the race condition that left zombie job records
+  // when multiple expired jobs shared a single attachment (the duplicate-reuse
+  // path re-binds an existing attachment to a fresh job). Without the dedup,
+  // the concurrent cleanup loop calls removeAttachment per-sibling and the
+  // loser of the race throws "Attachment not found", which silently drops its
+  // job record out of the deleteMany() set.
+  describe('deleteDownloadExpiredExportJobs (shared attachment)', () => {
+    const sharedAttachmentId = new mongoose.Types.ObjectId();
+    const otherAttachmentId = new mongoose.Types.ObjectId();
+
+    beforeEach(async () => {
+      await configManager.updateConfig(
+        'app:bulkExportDownloadExpirationSeconds',
+        86400,
+      ); // 1 day
+    });
+
+    test('should call removeAttachment exactly once when multiple expired jobs share the same attachment', async () => {
+      // arrange: two expired jobs pointing at the same attachment (the
+      // duplicate-reuse path produces this shape)
+      const expiredAt = new Date(Date.now() - 86400 * 1000 - 1);
+      await PageBulkExportJob.insertMany([
+        {
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: expiredAt,
+          attachment: sharedAttachmentId,
+        },
+        {
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: expiredAt,
+          attachment: sharedAttachmentId,
+        },
+      ]);
+
+      // act
+      await pageBulkExportJobCleanUpCronService?.deleteDownloadExpiredExportJobs();
+
+      // assert: only one removeAttachment call for the shared attachment, and
+      // both job records are gone (no zombie left behind)
+      expect(removeAttachmentMock).toHaveBeenCalledTimes(1);
+      expect(removeAttachmentMock).toHaveBeenCalledWith(sharedAttachmentId);
+      expect(await PageBulkExportJob.find()).toHaveLength(0);
+    });
+
+    test('should still remove distinct attachments once each when expired jobs reference different attachments', async () => {
+      // arrange: ensure the dedup does not over-merge across distinct attachments
+      const expiredAt = new Date(Date.now() - 86400 * 1000 - 1);
+      await PageBulkExportJob.insertMany([
+        {
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: expiredAt,
+          attachment: sharedAttachmentId,
+        },
+        {
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: expiredAt,
+          attachment: otherAttachmentId,
+        },
+      ]);
+
+      // act
+      await pageBulkExportJobCleanUpCronService?.deleteDownloadExpiredExportJobs();
+
+      // assert
+      expect(removeAttachmentMock).toHaveBeenCalledTimes(2);
+      expect(removeAttachmentMock).toHaveBeenCalledWith(sharedAttachmentId);
+      expect(removeAttachmentMock).toHaveBeenCalledWith(otherAttachmentId);
+      expect(await PageBulkExportJob.find()).toHaveLength(0);
+    });
+
+    test('should not call removeAttachment when an unexpired sibling job still references the attachment', async () => {
+      // arrange: one expired + one unexpired sharing the same attachment.
+      // The unexpired sibling protects the attachment from deletion.
+      await PageBulkExportJob.insertMany([
+        {
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: new Date(Date.now() - 86400 * 1000 - 1),
+          attachment: sharedAttachmentId,
+        },
+        {
+          user,
+          page: new mongoose.Types.ObjectId(),
+          format: PageBulkExportFormat.md,
+          status: PageBulkExportJobStatus.completed,
+          completedAt: new Date(Date.now()),
+          attachment: sharedAttachmentId,
+        },
+      ]);
+
+      // act
+      await pageBulkExportJobCleanUpCronService?.deleteDownloadExpiredExportJobs();
+
+      // assert: attachment retained, only the expired job is gone
+      expect(removeAttachmentMock).not.toHaveBeenCalled();
+      const remaining = await PageBulkExportJob.find();
+      expect(remaining).toHaveLength(1);
+      expect(remaining[0].attachment?.toString()).toBe(
+        sharedAttachmentId.toString(),
+      );
+    });
+
+    test('should delete an expired job whose removeAttachment resolves as a no-op (zombie with dangling attachment ref)', async () => {
+      // arrange: simulate the real removeAttachment idempotent contract — when
+      // the attachment metadata doc is already gone, the call resolves without
+      // throwing. The job record must still be deleteMany()-d.
+      const zombieAttachmentId = new mongoose.Types.ObjectId();
+      await PageBulkExportJob.create({
+        user,
+        page: new mongoose.Types.ObjectId(),
+        format: PageBulkExportFormat.md,
+        status: PageBulkExportJobStatus.completed,
+        completedAt: new Date(Date.now() - 86400 * 1000 - 1),
+        attachment: zombieAttachmentId,
+      });
+
+      // act
+      await pageBulkExportJobCleanUpCronService?.deleteDownloadExpiredExportJobs();
+
+      // assert
+      expect(removeAttachmentMock).toHaveBeenCalledTimes(1);
+      expect(removeAttachmentMock).toHaveBeenCalledWith(zombieAttachmentId);
+      expect(await PageBulkExportJob.find()).toHaveLength(0);
+    });
+  });
+
   describe('deleteFailedExportJobs', () => {
     // arrange
     const jobId1 = new mongoose.Types.ObjectId();

+ 22 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts

@@ -85,9 +85,31 @@ class PageBulkExportJobCleanUpCronService extends CronService {
       completedAt: { $lt: thresholdDate },
     });
 
+    // Pick one "owner" job per shared attachment. When multiple jobs in this
+    // batch reference the same attachment (e.g. the duplicate-reuse path that
+    // re-binds an existing attachment to a new job), only the owner is allowed
+    // to call removeAttachment. Without this, the concurrent cleanup loop below
+    // would issue removeAttachment for the same attachment from each sibling,
+    // and the loser of the race would throw "Attachment not found" / GridFS
+    // "File not found", leaving its job record undeleted as a permanent zombie.
+    const attachmentOwnerJobIds = new Map<string, string>();
+    for (const job of downloadExpiredExportJobs) {
+      const attachmentKey = job.attachment?.toString();
+      if (attachmentKey == null) continue;
+      if (!attachmentOwnerJobIds.has(attachmentKey)) {
+        attachmentOwnerJobIds.set(attachmentKey, job._id.toString());
+      }
+    }
+
     const cleanUp = async (job: PageBulkExportJobDocument) => {
       await pageBulkExportJobCronService?.cleanUpExportJobResources(job);
 
+      const attachmentKey = job.attachment?.toString();
+      if (attachmentKey == null) return;
+      const isAttachmentOwner =
+        attachmentOwnerJobIds.get(attachmentKey) === job._id.toString();
+      if (!isAttachmentOwner) return;
+
       const hasSameAttachmentAndDownloadNotExpired =
         await PageBulkExportJob.findOne({
           attachment: job.attachment,

+ 21 - 0
apps/app/src/pages/general-page/configuration-props.ts

@@ -125,3 +125,24 @@ export const getServerSideGeneralPageProps: GetServerSideProps<
     },
   };
 };
+
+export const getServerSideShareLinkRendererConfigProps: GetServerSideProps<
+  RendererConfigProps
+> = async (context: GetServerSidePropsContext) => {
+  const result = await getServerSideRendererConfigProps(context);
+
+  if ('props' in result) {
+    const props = await result.props;
+    return {
+      props: {
+        ...props,
+        rendererConfig: {
+          ...props.rendererConfig,
+          isSharedPage: true,
+        },
+      },
+    };
+  }
+
+  return result;
+};

+ 1 - 0
apps/app/src/pages/general-page/index.ts

@@ -1,6 +1,7 @@
 export {
   getServerSideGeneralPageProps,
   getServerSideRendererConfigProps,
+  getServerSideShareLinkRendererConfigProps,
 } from './configuration-props';
 export { isValidGeneralPageInitialProps } from './type-guards';
 export type * from './types';

+ 2 - 2
apps/app/src/pages/share/[[...path]]/server-side-props.ts

@@ -13,7 +13,7 @@ import {
 } from '../../common-props';
 import {
   getServerSideGeneralPageProps,
-  getServerSideRendererConfigProps,
+  getServerSideShareLinkRendererConfigProps,
   isValidGeneralPageInitialProps,
 } from '../../general-page';
 import { addActivity } from '../../utils/activity';
@@ -58,7 +58,7 @@ export async function getServerSidePropsForInitial(
     getServerSideCommonEachProps(context),
     getServerSideCommonInitialProps(context),
     getServerSideGeneralPageProps(context),
-    getServerSideRendererConfigProps(context),
+    getServerSideShareLinkRendererConfigProps(context),
     getServerSideI18nProps(context, ['translation']),
     getPageDataForInitial(context),
   ]);

+ 6 - 0
apps/app/src/server/crowi/express-init.js

@@ -10,6 +10,7 @@ import {
 } from '../../features/growi-plugin/server/consts';
 import loggerFactory from '../../utils/logger';
 import CertifyOrigin from '../middlewares/certify-origin';
+import { denyUploadsDirectAccess } from '../middlewares/deny-uploads-direct-access';
 import registerSafeRedirectFactory from '../middlewares/safe-redirect';
 
 const logger = loggerFactory('growi:crowi:express-init');
@@ -92,6 +93,11 @@ module.exports = (crowi, app) => {
   app.set('port', crowi.port);
 
   const staticOption = crowi.node_env === 'production' ? { maxAge: '30d' } : {};
+  // Deny direct access to uploaded files (publicDir/uploads/**) BEFORE static
+  // serving. Uploads must be served only via the /attachment and /download
+  // routes, which apply authorization, Content-Disposition and CSP headers.
+  // see: src/server/middlewares/deny-uploads-direct-access.ts
+  app.use('/uploads', denyUploadsDirectAccess);
   app.use(express.static(crowi.publicDir, staticOption));
   app.use(
     '/static/preset-themes',

+ 29 - 28
apps/app/src/server/crowi/index.ts

@@ -349,10 +349,10 @@ class Crowi {
     // mongoUri = mongodb://user:password@host/dbname
     const mongoUri = getMongoUri();
 
-    return mongoose.connect(mongoUri, mongoOptions);
+    return await mongoose.connect(mongoUri, mongoOptions);
   }
 
-  async setupSessionConfig(): Promise<void> {
+  setupSessionConfig(): void {
     const session = require('express-session');
     const sessionMaxAge =
       this.configManager.getConfig('security:sessionMaxAge') || 2592000000; // default: 30days
@@ -407,10 +407,10 @@ class Crowi {
 
   async setupConfigManager(): Promise<void> {
     this.configManager = configManagerSingletonInstance;
-    return this.configManager.loadConfigs();
+    return await this.configManager.loadConfigs();
   }
 
-  async setupS2sMessagingService(): Promise<void> {
+  setupS2sMessagingService(): void {
     const s2sMessagingService = require('../service/s2s-messaging')(this);
     if (s2sMessagingService != null) {
       s2sMessagingService.subscribe();
@@ -422,7 +422,7 @@ class Crowi {
     }
   }
 
-  async setupSocketIoService(): Promise<void> {
+  setupSocketIoService(): void {
     this.socketIoService = new SocketIoService(this);
   }
 
@@ -482,12 +482,12 @@ class Crowi {
     this.passportService.setupSerializer();
     // setup strategies
     try {
-      this.passportService.setupStrategyById('local');
-      this.passportService.setupStrategyById('ldap');
-      this.passportService.setupStrategyById('saml');
-      this.passportService.setupStrategyById('oidc');
-      this.passportService.setupStrategyById('google');
-      this.passportService.setupStrategyById('github');
+      await this.passportService.setupStrategyById('local');
+      await this.passportService.setupStrategyById('ldap');
+      await this.passportService.setupStrategyById('saml');
+      await this.passportService.setupStrategyById('oidc');
+      await this.passportService.setupStrategyById('google');
+      await this.passportService.setupStrategyById('github');
     } catch (err) {
       logger.error(err);
     }
@@ -499,10 +499,10 @@ class Crowi {
   }
 
   async setupSearcher(): Promise<void> {
-    this.searchService = new SearchService(this);
+    this.searchService = await SearchService.create(this);
   }
 
-  async setupMailer(): Promise<void> {
+  setupMailer(): void {
     const MailService = require('~/server/service/mail').default;
     this.mailService = new MailService(this);
 
@@ -660,6 +660,7 @@ class Crowi {
         await mongoose.disconnect();
         return;
       },
+      // biome-ignore lint/suspicious/useAwait: onShutdown should be async
       onShutdown: async () => {
         logger.info('Cleanup finished, server is shutting down');
       },
@@ -707,7 +708,7 @@ class Crowi {
   /**
    * setup UserNotificationService
    */
-  async setUpUserNotification(): Promise<void> {
+  setUpUserNotification(): void {
     if (this.userNotificationService == null) {
       this.userNotificationService = new UserNotificationService(this);
     }
@@ -716,7 +717,7 @@ class Crowi {
   /**
    * setup AclService
    */
-  async setUpAcl(): Promise<void> {
+  setUpAcl(): void {
     this.aclService = aclServiceSingletonInstance;
   }
 
@@ -741,7 +742,7 @@ class Crowi {
   /**
    * setup AppService
    */
-  async setUpApp(): Promise<void> {
+  setUpApp(): void {
     if (this.appService == null) {
       this.appService = new AppService(this);
 
@@ -756,7 +757,7 @@ class Crowi {
   /**
    * setup FileUploadService
    */
-  async setUpFileUpload(isForceUpdate = false): Promise<void> {
+  setUpFileUpload(isForceUpdate = false): void {
     if (this.fileUploadService == null || isForceUpdate) {
       this.fileUploadService = getUploader(this);
     }
@@ -765,7 +766,7 @@ class Crowi {
   /**
    * setup FileUploaderSwitchService
    */
-  async setUpFileUploaderSwitchService(): Promise<void> {
+  setUpFileUploaderSwitchService(): void {
     const FileUploaderSwitchService = require('../service/file-uploader-switch');
     this.fileUploaderSwitchService = new FileUploaderSwitchService(this);
     // add as a message handler
@@ -784,7 +785,7 @@ class Crowi {
   /**
    * setup AttachmentService
    */
-  async setupAttachmentService(): Promise<void> {
+  setupAttachmentService(): void {
     if (this.attachmentService == null) {
       this.attachmentService = new AttachmentService(this);
     }
@@ -793,21 +794,21 @@ class Crowi {
   async setupUserGroupService(): Promise<void> {
     if (this.userGroupService == null) {
       this.userGroupService = new UserGroupService(this);
-      return this.userGroupService.init();
+      return await this.userGroupService.init();
     }
   }
 
-  async setUpGrowiBridge(): Promise<void> {
+  setUpGrowiBridge(): void {
     if (this.growiBridgeService == null) {
       this.growiBridgeService = new GrowiBridgeService(this);
     }
   }
 
-  async setupExport(): Promise<void> {
+  setupExport(): void {
     instanciateExportService(this);
   }
 
-  async setupImport(): Promise<void> {
+  setupImport(): void {
     initializeImportService(this);
   }
 
@@ -833,7 +834,7 @@ class Crowi {
     this.pageOperationService = instanciatePageOperationService(this);
   }
 
-  async setupInAppNotificationService(): Promise<void> {
+  setupInAppNotificationService(): void {
     if (this.inAppNotificationService == null) {
       this.inAppNotificationService = new InAppNotificationService(this);
     }
@@ -846,13 +847,13 @@ class Crowi {
     }
   }
 
-  async setupCommentService(): Promise<void> {
+  setupCommentService(): void {
     if (this.commentService == null) {
       this.commentService = new CommentService(this);
     }
   }
 
-  async setupSyncPageStatusService(): Promise<void> {
+  setupSyncPageStatusService(): void {
     if (this.syncPageStatusService == null) {
       this.syncPageStatusService = new SyncPageStatusService(
         this,
@@ -867,7 +868,7 @@ class Crowi {
     }
   }
 
-  async setupSlackIntegrationService(): Promise<void> {
+  setupSlackIntegrationService(): void {
     if (this.slackIntegrationService == null) {
       this.slackIntegrationService = new SlackIntegrationService(this);
     }
@@ -878,7 +879,7 @@ class Crowi {
     }
   }
 
-  async setupG2GTransferService(): Promise<void> {
+  setupG2GTransferService(): void {
     if (this.g2gTransferPusherService == null) {
       this.g2gTransferPusherService = new G2GTransferPusherService(this);
     }

+ 38 - 0
apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts

@@ -183,6 +183,44 @@ describe('access-token-parser middleware for access token with scopes', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
+  it('should authenticate with token supplied in X-GROWI-ACCESS-TOKEN header', async () => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with a wildcard (parent) scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER_SETTINGS.ALL],
+    );
+
+    // act - supply the token via the X-GROWI-ACCESS-TOKEN header (Express lowercases keys),
+    // and require a narrower scope to also exercise scope satisfaction
+    reqMock.headers['x-growi-access-token'] = token;
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(
+      reqMock,
+      resMock,
+    );
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+  });
+
   it('should authenticate with wildcard scope', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({

+ 3 - 8
apps/app/src/server/middlewares/access-token-parser/access-token.ts

@@ -6,7 +6,7 @@ import type { Response } from 'express';
 import { AccessToken } from '~/server/models/access-token';
 import loggerFactory from '~/utils/logger';
 
-import { extractBearerToken } from './extract-bearer-token';
+import { extractAccessToken } from './extract-access-token';
 
 const logger = loggerFactory(
   'growi:middleware:access-token-parser:access-token',
@@ -14,13 +14,8 @@ const logger = loggerFactory(
 
 export const parserForAccessToken = (scopes: Scope[]) => {
   return async (req: AccessTokenParserReq, res: Response): Promise<void> => {
-    // Extract token from Authorization header first
-    // It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
-    const bearerToken = extractBearerToken(req.headers.authorization);
-
-    const accessToken =
-      bearerToken ?? req.query.access_token ?? req.body.access_token;
-    if (accessToken == null || typeof accessToken !== 'string') {
+    const accessToken = extractAccessToken(req);
+    if (accessToken == null) {
       return;
     }
     if (scopes == null || scopes.length === 0) {

+ 28 - 0
apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts

@@ -150,6 +150,34 @@ describe('access-token-parser middleware', () => {
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
   });
 
+  it('should set req.user with a valid api token in the X-GROWI-ACCESS-TOKEN header', async () => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+      apiToken: faker.internet.password(),
+    });
+
+    // act
+    reqMock.headers['x-growi-access-token'] = targetUser.apiToken;
+    await parserForApiToken(reqMock, resMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+  });
+
   it('should ignore non-Bearer Authorization header', async () => {
     // arrange
     const reqMock = mock<AccessTokenParserReq>({

+ 3 - 10
apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -7,7 +7,7 @@ import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
-import { extractBearerToken } from './extract-bearer-token';
+import { extractAccessToken } from './extract-access-token';
 
 const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
 
@@ -15,15 +15,8 @@ export const parserForApiToken = async (
   req: AccessTokenParserReq,
   res: Response,
 ): Promise<void> => {
-  // Extract token from Authorization header first
-  // It is more efficient to call it only once in "AccessTokenParser," which is the caller of the method
-  const bearerToken = extractBearerToken(req.headers.authorization);
-
-  // Try all possible token sources in order of priority
-  const accessToken =
-    bearerToken ?? req.query.access_token ?? req.body.access_token;
-
-  if (accessToken == null || typeof accessToken !== 'string') {
+  const accessToken = extractAccessToken(req);
+  if (accessToken == null) {
     return;
   }
 

+ 135 - 0
apps/app/src/server/middlewares/access-token-parser/extract-access-token.spec.ts

@@ -0,0 +1,135 @@
+import type { IncomingHttpHeaders } from 'node:http';
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
+
+import {
+  extractAccessToken,
+  X_GROWI_ACCESS_TOKEN_HEADER_NAME,
+} from './extract-access-token';
+
+// Build a minimal request shaped like the real Express request: only explicitly-set
+// properties exist, so unset sources are genuinely `undefined` (unlike a deep auto-mock,
+// which would stub every accessed path and break the `??` precedence chain).
+const buildReq = (parts: {
+  headers?: IncomingHttpHeaders;
+  query?: { access_token?: string };
+  body?: { access_token?: string };
+}): AccessTokenParserReq =>
+  ({
+    headers: parts.headers ?? {},
+    query: parts.query ?? {},
+    body: parts.body ?? {},
+  }) as AccessTokenParserReq;
+
+describe('extractAccessToken', () => {
+  it('returns the Bearer token when present, even if other sources exist (3.1)', () => {
+    // arrange
+    const req = buildReq({
+      headers: {
+        authorization: 'Bearer bearer-token',
+        [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: 'header-token',
+      },
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('bearer-token');
+  });
+
+  it('returns the header token when no Bearer is present (3.2)', () => {
+    // arrange
+    const req = buildReq({
+      headers: { [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: 'header-token' },
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('header-token');
+  });
+
+  it('returns the query token when no Bearer and no header (3.3)', () => {
+    // arrange
+    const req = buildReq({
+      headers: {},
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('query-token');
+  });
+
+  it('returns the body token when only the body has it (3.3)', () => {
+    // arrange
+    const req = buildReq({
+      headers: {},
+      query: {},
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('body-token');
+  });
+
+  it('ignores an array-valued (non-string) header and falls through to query (3.4)', () => {
+    // arrange
+    // Express represents repeated headers as string[]; a non-string header value must be
+    // skipped so resolution continues to the remaining sources rather than failing.
+    const req = buildReq({
+      headers: { [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: ['a', 'b'] },
+      query: { access_token: 'query-token' },
+      body: {},
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('query-token');
+  });
+
+  it('returns null when the only source is an array-valued header (3.4)', () => {
+    // arrange
+    const req = buildReq({
+      headers: { [X_GROWI_ACCESS_TOKEN_HEADER_NAME]: ['a', 'b'] },
+      query: {},
+      body: {},
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBeNull();
+  });
+
+  it('returns null when no string-typed source is present (3.4)', () => {
+    // arrange
+    const req = buildReq({ headers: {}, query: {}, body: {} });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBeNull();
+  });
+
+  it('resolves the header case-insensitively via the lowercase constant (1.3)', () => {
+    // arrange
+    // Express lowercases incoming header keys, so the canonical constant is lowercase;
+    // indexing by it resolves a header regardless of the sender's casing.
+    const req = buildReq({
+      headers: { 'x-growi-access-token': 'header-token' },
+      query: {},
+      body: {},
+    });
+
+    // act / assert
+    expect(X_GROWI_ACCESS_TOKEN_HEADER_NAME).toBe('x-growi-access-token');
+    expect(extractAccessToken(req)).toBe('header-token');
+  });
+
+  it('matches the prior Bearer/query/body precedence when no header is present', () => {
+    // arrange
+    const req = buildReq({
+      headers: { authorization: 'Bearer bearer-token' },
+      query: { access_token: 'query-token' },
+      body: { access_token: 'body-token' },
+    });
+
+    // act / assert
+    expect(extractAccessToken(req)).toBe('bearer-token');
+  });
+});

+ 31 - 0
apps/app/src/server/middlewares/access-token-parser/extract-access-token.ts

@@ -0,0 +1,31 @@
+import type { AccessTokenParserReq } from '@growi/core/dist/interfaces/server';
+
+import { extractBearerToken } from './extract-bearer-token';
+
+// Canonical header name for passing an access token outside the Authorization header.
+// Express lowercases incoming header keys, so indexing by this lowercase constant
+// resolves the header case-insensitively. Mirrors X_GROWI_TRANSFER_KEY_HEADER_NAME.
+export const X_GROWI_ACCESS_TOKEN_HEADER_NAME = 'x-growi-access-token';
+
+/**
+ * Resolve the access token from a request using the single source-of-truth precedence:
+ * Bearer > X-GROWI-ACCESS-TOKEN header > access_token query > access_token body.
+ *
+ * A non-string X-GROWI-ACCESS-TOKEN value (e.g. an array from a duplicated header) is
+ * coerced to `undefined` before the precedence chain, so resolution falls through to the
+ * remaining sources instead of short-circuiting (3.4). `null` is returned only when no
+ * string-typed source resolves.
+ */
+export const extractAccessToken = (
+  req: AccessTokenParserReq,
+): string | null => {
+  const headerToken = req.headers[X_GROWI_ACCESS_TOKEN_HEADER_NAME];
+
+  const token =
+    extractBearerToken(req.headers.authorization) ??
+    (typeof headerToken === 'string' ? headerToken : undefined) ??
+    req.query.access_token ??
+    req.body.access_token;
+
+  return typeof token === 'string' ? token : null;
+};

+ 21 - 0
apps/app/src/server/middlewares/deny-uploads-direct-access.spec.ts

@@ -0,0 +1,21 @@
+import type { Request, Response } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import { denyUploadsDirectAccess } from './deny-uploads-direct-access';
+
+describe('denyUploadsDirectAccess', () => {
+  test('responds with 403 Forbidden', () => {
+    const req = mock<Request>();
+    req.originalUrl = '/uploads/attachment/evil.html';
+
+    const res = mock<Response>();
+    // res.status(...) returns `this` (Response) in Express, enabling the
+    // status().send() chain. Mirror that so the chained send() can be asserted.
+    res.status.mockReturnValue(res);
+
+    denyUploadsDirectAccess(req, res);
+
+    expect(res.status).toHaveBeenCalledWith(403);
+    expect(res.send).toHaveBeenCalledWith('Forbidden');
+  });
+});

+ 25 - 0
apps/app/src/server/middlewares/deny-uploads-direct-access.ts

@@ -0,0 +1,25 @@
+import type { Request, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middleware:deny-uploads-direct-access');
+
+/**
+ * Deny direct access to uploaded files stored under `publicDir/uploads/**`.
+ *
+ * When the upload method is "Local", attachments are written under
+ * `publicDir/uploads/**`, which would otherwise be served directly by
+ * `express.static(publicDir)`. Serving them statically bypasses the
+ * `/attachment` and `/download` routes that apply authorization,
+ * `Content-Disposition` and `Content-Security-Policy` headers, enabling stored
+ * XSS and access-control bypass.
+ *
+ * This middleware blanket-denies the whole `/uploads` prefix (attachment, user,
+ * page-bulk-export, audit-log-bulk-export and any future subdirectory) so that
+ * adding a new storage prefix never silently re-opens the hole. It MUST be
+ * registered BEFORE `express.static(publicDir)`.
+ */
+export const denyUploadsDirectAccess = (req: Request, res: Response): void => {
+  logger.debug(`Blocked direct access to an uploaded file: ${req.originalUrl}`);
+  res.status(403).send('Forbidden');
+};

+ 2 - 1
apps/app/src/server/models/obsolete-page.js

@@ -1,5 +1,6 @@
 import { GroupType, Origin } from '@growi/core';
 import {
+  escapeStringForMongoRegex,
   pagePathUtils,
   pathUtils,
   templateChecker,
@@ -687,7 +688,7 @@ export const getPageSchema = (crowi) => {
     const regexpList = pathList.map((path) => {
       const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
       return new RegExp(
-        `^${RegExp.escape(pathWithTrailingSlash)}_{1,2}template$`,
+        `^${escapeStringForMongoRegex(pathWithTrailingSlash)}_{1,2}template$`,
       );
     });
 

+ 8 - 7
apps/app/src/server/models/page.ts

@@ -4,6 +4,7 @@ import type {
   IUserHasId,
 } from '@growi/core/dist/interfaces';
 import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import { hasSlash, isTopPage } from '@growi/core/dist/utils/page-path-utils';
 import {
   addTrailingSlash,
@@ -347,7 +348,7 @@ export class PageQueryBuilder {
     const pathNormalized = normalizePath(path);
     const pathWithTrailingSlash = addTrailingSlash(path);
 
-    const startsPattern = RegExp.escape(pathWithTrailingSlash);
+    const startsPattern = escapeStringForMongoRegex(pathWithTrailingSlash);
 
     this.query = this.query.and({
       $or: [
@@ -372,7 +373,7 @@ export class PageQueryBuilder {
 
     const pathWithTrailingSlash = addTrailingSlash(path);
 
-    const startsPattern = RegExp.escape(pathWithTrailingSlash);
+    const startsPattern = escapeStringForMongoRegex(pathWithTrailingSlash);
 
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
 
@@ -408,7 +409,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const startsPattern = RegExp.escape(path);
+    const startsPattern = escapeStringForMongoRegex(path);
 
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
 
@@ -423,7 +424,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const startsPattern = RegExp.escape(str);
+    const startsPattern = escapeStringForMongoRegex(str);
 
     this.query = this.query.and({
       path: new RegExp(`^(?!${startsPattern}).*$`),
@@ -439,7 +440,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const startsPattern = RegExp.escape(path);
+    const startsPattern = escapeStringForMongoRegex(path);
 
     this.query = this.query.and({
       path: { $not: new RegExp(`^${startsPattern}(/|$)`) },
@@ -454,7 +455,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const match = RegExp.escape(str);
+    const match = escapeStringForMongoRegex(str);
 
     this.query = this.query.and({ path: new RegExp(`^(?=.*${match}).*$`) });
 
@@ -467,7 +468,7 @@ export class PageQueryBuilder {
       return this;
     }
 
-    const match = RegExp.escape(str);
+    const match = escapeStringForMongoRegex(str);
 
     this.query = this.query.and({ path: new RegExp(`^(?!.*${match}).*$`) });
 

+ 1 - 0
apps/app/src/server/routes/apiv3/activity.ts

@@ -192,6 +192,7 @@ module.exports = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     parameters:
    *       - name: limit
    *         in: query

+ 3 - 0
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -392,6 +392,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: /app-settings
    *        description: get app setting params
    *        responses:
@@ -1087,6 +1088,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: AccessToken supported.
    *        description: Update V5SchemaMigration
    *        responses:
@@ -1145,6 +1147,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: AccessToken supported.
    *        description: Update MaintenanceMode
    *        requestBody:

+ 6 - 0
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -150,6 +150,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Create bookmark folder
    *        description: Create a new bookmark folder
    *        requestBody:
@@ -215,6 +216,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: List bookmark folders of a user
    *        description: List bookmark folders of a user
    *        parameters:
@@ -316,6 +318,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Delete bookmark folder
    *        description: Delete a bookmark folder and its children
    *        parameters:
@@ -372,6 +375,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -437,6 +441,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -498,6 +503,7 @@ module.exports = (crowi: Crowi) => {
    *        security:
    *          - bearer: []
    *          - accessTokenInQuery: []
+   *          - accessTokenHeaderAuth: []
    *        summary: Update bookmark in folder
    *        description: Update a bookmark in a folder
    *        requestBody:

+ 2 - 0
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -615,6 +615,7 @@ module.exports = (crowi: Crowi): Router => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      requestBody:
    *        required: true
    *        content:
@@ -688,6 +689,7 @@ module.exports = (crowi: Crowi): Router => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      requestBody:
    *        required: true
    *        content:

+ 4 - 0
apps/app/src/server/routes/apiv3/import.ts

@@ -177,6 +177,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import/status
    *      description: Get properties of stored zip files for import
    *      responses:
@@ -213,6 +214,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import
    *      description: import a collection from a zipped json
    *      requestBody:
@@ -375,6 +377,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import/upload
    *      description: upload a zip file
    *      requestBody:
@@ -441,6 +444,7 @@ export default function route(crowi: Crowi): Router {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /import/all
    *      description: Delete all zip files
    *      responses:

+ 4 - 0
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -105,6 +105,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/list
    *      description: Get the list of in-app notifications
    *      parameters:
@@ -207,6 +208,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/status
    *      description: Get the status of in-app notifications
    *      responses:
@@ -250,6 +252,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/open
    *      description: Open the in-app notification
    *      requestBody:
@@ -301,6 +304,7 @@ module.exports = (crowi: Crowi) => {
    *      security:
    *        - bearer: []
    *        - accessTokenInQuery: []
+   *        - accessTokenHeaderAuth: []
    *      summary: /in-app-notification/all-statuses-open
    *      description: Open all in-app notifications
    *      responses:

+ 4 - 0
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -76,6 +76,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/root
    *     description: Get the root page
    *     responses:
@@ -113,6 +114,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/children
    *     description: Get the children of a page
    *     parameters:
@@ -193,6 +195,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/info
    *     description: Get summary information of pages
    *     parameters:
@@ -354,6 +357,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     summary: /page-listing/item
    *     description: Get a single page item for tree display
    *     parameters:

+ 2 - 1
apps/app/src/server/routes/apiv3/search.js

@@ -254,6 +254,7 @@ module.exports = (crowi) => {
     async (req, res) => {
       const operation = req.body.operation;
 
+      // @type {import('~/server/service/search').SearchService}
       const { searchService } = crowi;
 
       if (!searchService.isConfigured) {
@@ -288,7 +289,7 @@ module.exports = (crowi) => {
               .send({ message: 'Operation is successfully processed.' });
           case 'rebuild':
             // NOT wait the processing is terminated
-            searchService.rebuildIndex();
+            searchService.rebuildIndex(true);
 
             activityEvent.emit('update', res.locals.activity._id, {
               action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD,

+ 1 - 0
apps/app/src/server/routes/apiv3/user-activities.ts

@@ -159,6 +159,7 @@ module.exports = (crowi: Crowi): Router => {
    *       - cookieAuth: []
    *       - bearer: []
    *       - accessTokenInQuery: []
+   *       - accessTokenHeaderAuth: []
    *     parameters:
    *       - name: limit
    *         in: query

+ 2 - 1
apps/app/src/server/routes/apiv3/users.js

@@ -1,6 +1,7 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import express from 'express';
 import { body, query } from 'express-validator';
@@ -340,7 +341,7 @@ module.exports = (crowi) => {
 
       // Search from input
       const searchText = req.query.searchText || '';
-      const searchWord = new RegExp(RegExp.escape(searchText));
+      const searchWord = new RegExp(escapeStringForMongoRegex(searchText));
       // Sort
       const { sort, sortOrder } = req.query;
       const sortOutput = {

+ 59 - 0
apps/app/src/server/service/attachment.spec.ts

@@ -0,0 +1,59 @@
+import { mock } from 'vitest-mock-extended';
+
+import type Crowi from '../crowi';
+import type { IAttachmentDocument } from '../models/attachment';
+import { Attachment } from '../models/attachment';
+import { AttachmentService } from './attachment';
+
+// Locks down two contracts of removeAttachment:
+// 1. Missing metadata doc is a no-op (the bulk-export cleanup cron relies on
+//    this to self-heal zombie job records without throwing).
+// 2. A genuine file-store failure propagates, so callers like the attachment
+//    delete API surface it instead of dropping the metadata doc and stranding
+//    an orphan blob.
+describe('AttachmentService.removeAttachment', () => {
+  test('should resolve without throwing when the attachment is already gone', async () => {
+    const findByIdSpy = vi
+      .spyOn(Attachment, 'findById')
+      .mockResolvedValue(null);
+    const deleteFile = vi.fn();
+    const crowi = mock<Crowi>({
+      fileUploadService: { deleteFile },
+    });
+    const service = new AttachmentService(crowi);
+
+    await expect(
+      service.removeAttachment('this-id-does-not-exist'),
+    ).resolves.toBeUndefined();
+
+    expect(deleteFile).not.toHaveBeenCalled();
+    findByIdSpy.mockRestore();
+  });
+
+  test('should propagate the error and not drop the metadata doc when the file store fails', async () => {
+    const attachmentRemove = vi.fn().mockResolvedValue(undefined);
+    const fakeAttachment = mock<IAttachmentDocument>({
+      remove: attachmentRemove,
+    });
+    const findByIdSpy = vi
+      .spyOn(Attachment, 'findById')
+      .mockResolvedValue(fakeAttachment);
+    const deleteFile = vi
+      .fn()
+      .mockRejectedValue(new Error('S3 is temporarily unavailable'));
+    const crowi = mock<Crowi>({
+      fileUploadService: { deleteFile },
+    });
+    const service = new AttachmentService(crowi);
+    service.detachHandlers = [];
+
+    await expect(service.removeAttachment('some-id')).rejects.toThrow(
+      'S3 is temporarily unavailable',
+    );
+
+    expect(deleteFile).toHaveBeenCalledTimes(1);
+    // metadata doc must survive so the blob stays referenceable for retry
+    expect(attachmentRemove).not.toHaveBeenCalled();
+    findByIdSpy.mockRestore();
+  });
+});

+ 15 - 1
apps/app/src/server/service/attachment.ts

@@ -147,10 +147,24 @@ export class AttachmentService implements IAttachmentService {
     const { fileUploadService } = this.crowi;
     const attachment = await Attachment.findById(attachmentId);
 
+    // No-op when the metadata doc is already gone. The bulk-export cleanup cron
+    // relies on this to self-heal: a job whose attachment was already removed by
+    // a previous tick (or by a concurrent remover that won an unsynchronized
+    // cross-process race) resolves cleanly here and gets deleted instead of
+    // lingering as a zombie record. Throwing would re-surface it every tick.
     if (attachment == null) {
-      throw new Error(`Attachment not found: ${attachmentId}`);
+      logger.debug(
+        `removeAttachment: attachment already gone, skipping: ${attachmentId}`,
+      );
+      return;
     }
 
+    // Intentionally NOT swallowing deleteFile errors. A genuine file-store
+    // failure (S3/GridFS outage, permission error) must propagate so callers
+    // such as the attachment delete API surface it instead of dropping the
+    // metadata doc and stranding an unreferenceable orphan blob. "File already
+    // gone" is not an error path here: the underlying stores already no-op it
+    // (see gridfs deleteFile, which warns and returns when the file is missing).
     await fileUploadService.deleteFile(attachment);
     await attachment.remove();
 

+ 1 - 3
apps/app/src/server/service/customize.ts

@@ -6,7 +6,6 @@ import {
   PresetThemesMetadatas,
 } from '@growi/preset-themes';
 import path from 'path';
-import uglifycss from 'uglifycss';
 
 import { growiPluginService } from '~/features/growi-plugin/server/services';
 import loggerFactory from '~/utils/logger';
@@ -94,8 +93,7 @@ export class CustomizeService implements S2sMessageHandlable {
   initCustomCss() {
     const rawCss = configManager.getConfig('customize:css') || '';
 
-    // uglify and store
-    this.customCss = uglifycss.processString(rawCss);
+    this.customCss = rawCss;
 
     this.lastLoadedAt = new Date();
   }

+ 1 - 1
apps/app/src/server/service/installer.ts

@@ -40,7 +40,7 @@ export class InstallerService {
     }
 
     try {
-      await searchService.rebuildIndex();
+      await searchService.rebuildIndex(true);
     } catch (err) {
       logger.error('Rebuild index failed', err);
     }

+ 8 - 0
apps/app/src/server/service/interfaces/search.ts

@@ -2,3 +2,11 @@ export type UpdateOrInsertPagesOpts = {
   shouldEmitProgress?: boolean;
   invokeGarbageCollection?: boolean;
 };
+
+export type AddAllPagesOption = {
+  shouldEmitProgress?: boolean;
+};
+
+export type RebuildIndexOption = {
+  shouldEmitProgress?: boolean;
+};

+ 16 - 6
apps/app/src/server/service/page-grant.ts

@@ -5,7 +5,12 @@ import {
   type IGrantedGroup,
   PageGrant,
 } from '@growi/core';
-import { pagePathUtils, pageUtils, pathUtils } from '@growi/core/dist/utils';
+import {
+  escapeStringForMongoRegex,
+  pagePathUtils,
+  pageUtils,
+  pathUtils,
+} from '@growi/core/dist/utils';
 import mongoose, { type HydratedDocument } from 'mongoose';
 
 import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
@@ -595,7 +600,10 @@ class PageGrantService implements IPageGrantService {
     };
 
     const commonCondition = {
-      path: new RegExp(`^${RegExp.escape(addTrailingSlash(targetPath))}`, 'i'),
+      path: new RegExp(
+        `^${escapeStringForMongoRegex(addTrailingSlash(targetPath))}`,
+        'i',
+      ),
       isEmpty: false,
     };
 
@@ -850,11 +858,13 @@ class PageGrantService implements IPageGrantService {
         applicableGroups: userRelatedGroups,
       };
     } else if (grant === PageGrant.GRANT_OWNER) {
-      const grantedUser = grantedUsers[0];
-
-      const isUserApplicable = grantedUser.toString() === user._id.toString();
+      const grantedUser = grantedUsers?.[0];
 
-      if (isUserApplicable) {
+      // grantedUsers may be empty due to data inconsistency; guard against TypeError
+      if (
+        grantedUser != null &&
+        grantedUser.toString() === user._id.toString()
+      ) {
         data[PageGrant.GRANT_OWNER] = null;
       }
     } else if (grant === PageGrant.GRANT_USER_GROUP) {

+ 125 - 0
apps/app/src/server/service/page/grant-preserve-on-update.integ.ts

@@ -0,0 +1,125 @@
+import { type IUserHasId, PageGrant } from '@growi/core';
+import type { HydratedDocument, Model } from 'mongoose';
+import mongoose from 'mongoose';
+import { vi } from 'vitest';
+
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import type { PageDocument, PageModel } from '~/server/models/page';
+
+/**
+ * Characterization test for the server premise that the pre-load race fix relies
+ * on (issue #11272): when a page is updated WITHOUT a grant, the update endpoint
+ * must preserve the page's existing grant rather than defaulting it.
+ *
+ * The editor omits the grant from the update request while selectedGrant is
+ * unresolved (toPageUpdateGrantParams), so this preservation is what keeps a
+ * restricted page from being silently published.
+ */
+describe('PageService.updatePage grant preservation', () => {
+  let crowi: Crowi;
+  let Page: PageModel;
+  let User: Model<IUserHasId>;
+  let user: HydratedDocument<IUserHasId>;
+
+  const create = async (
+    path: string,
+    body: string,
+    options = {},
+  ): Promise<HydratedDocument<PageDocument>> => {
+    const mockedCreateSubOperation = vi
+      .spyOn(crowi.pageService, 'createSubOperation')
+      .mockReturnValue(Promise.resolve());
+
+    const createdPage = await crowi.pageService.create(
+      path,
+      body,
+      user,
+      options,
+    );
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+    mockedCreateSubOperation.mockRestore();
+    await crowi.pageService.createSubOperation(
+      ...(argsForCreateSubOperation as Parameters<
+        typeof crowi.pageService.createSubOperation
+      >),
+    );
+
+    return createdPage;
+  };
+
+  beforeAll(async () => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfig('app:isV5Compatible', true);
+
+    User = mongoose.model<IUserHasId>('User');
+    Page = mongoose.model<PageDocument, PageModel>('Page');
+
+    // Suppress page events so their async listeners (e.g. obsolete-page onUpdate)
+    // don't run DB work after the in-memory mongo is torn down. Same pattern as
+    // page.integ.ts. The grant is set synchronously in create/updatePage, so this
+    // does not affect what we assert.
+    vi.spyOn(crowi.pageService.pageEvent, 'emit').mockReturnValue(true);
+
+    // updatePage fires updatePageSubOperation without awaiting it (descendant
+    // bookkeeping). Stub it so that fire-and-forget DB work doesn't outlive the
+    // test and hit the closed connection pool. The grant is already applied to the
+    // saved page before this runs, so stubbing it doesn't affect the assertions.
+    vi.spyOn(crowi.pageService, 'updatePageSubOperation').mockResolvedValue();
+
+    // Ensure a root page exists so created pages can be attached to the tree.
+    const existingRoot = await Page.findOne({ path: '/' });
+    if (existingRoot == null) {
+      await Page.create({ path: '/', grant: Page.GRANT_PUBLIC });
+    }
+
+    const username = 'grantPreserveUser';
+    user =
+      (await User.findOne({ username })) ??
+      (await User.create({
+        name: username,
+        username,
+        email: 'grant-preserve@example.com',
+      }));
+  });
+
+  it('keeps GRANT_OWNER when the update omits a grant', async () => {
+    const page = await create('/grant-preserve-owner', 'initial body', {
+      grant: PageGrant.GRANT_OWNER,
+    });
+    expect(page.grant).toBe(PageGrant.GRANT_OWNER);
+
+    const updated = await crowi.pageService.updatePage(
+      page,
+      'updated body',
+      'initial body',
+      user,
+      {}, // no grant
+    );
+
+    expect(updated.grant).toBe(PageGrant.GRANT_OWNER);
+  });
+
+  it('changes the grant when the update explicitly provides one', async () => {
+    const page = await create(
+      '/grant-preserve-owner-to-public',
+      'initial body',
+      {
+        grant: PageGrant.GRANT_OWNER,
+      },
+    );
+    expect(page.grant).toBe(PageGrant.GRANT_OWNER);
+
+    const updated = await crowi.pageService.updatePage(
+      page,
+      'updated body',
+      'initial body',
+      user,
+      { grant: PageGrant.GRANT_PUBLIC },
+    );
+
+    expect(updated.grant).toBe(PageGrant.GRANT_PUBLIC);
+  });
+});

+ 12 - 5
apps/app/src/server/service/page/index.ts

@@ -17,7 +17,11 @@ import type {
   Ref,
 } from '@growi/core/dist/interfaces';
 import { PageGrant } from '@growi/core/dist/interfaces';
-import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import {
+  escapeStringForMongoRegex,
+  pagePathUtils,
+  pathUtils,
+} from '@growi/core/dist/utils';
 import type EventEmitter from 'events';
 import type { Cursor, HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
@@ -3961,7 +3965,8 @@ class PageService implements IPageService {
     const ancestorPaths = paths.flatMap((p) => collectAncestorPaths(p, []));
     // targets' descendants
     const pathAndRegExpsToNormalize: (RegExp | string)[] = paths.map(
-      (p) => new RegExp(`^${RegExp.escape(addTrailingSlash(p))}`, 'i'),
+      (p) =>
+        new RegExp(`^${escapeStringForMongoRegex(addTrailingSlash(p))}`, 'i'),
     );
     // include targets' path
     pathAndRegExpsToNormalize.push(...paths);
@@ -4172,7 +4177,7 @@ class PageService implements IPageService {
           const parentId = parent._id;
 
           // Build filter
-          const parentPathEscaped = RegExp.escape(
+          const parentPathEscaped = escapeStringForMongoRegex(
             parent.path === '/' ? '' : parent.path,
           ); // adjust the path for RegExp
           const filter: any = {
@@ -5138,7 +5143,9 @@ class PageService implements IPageService {
     const wasOnTree = exPage.parent != null || isTopPage(exPage.path);
     const shouldBeOnTree = currentPage.grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({
-      path: new RegExp(`^${RegExp.escape(addTrailingSlash(currentPage.path))}`),
+      path: new RegExp(
+        `^${escapeStringForMongoRegex(addTrailingSlash(currentPage.path))}`,
+      ),
       parent: { $ne: null },
     });
 
@@ -5270,7 +5277,7 @@ class PageService implements IPageService {
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({
       path: new RegExp(
-        `^${RegExp.escape(addTrailingSlash(clonedPageData.path))}`,
+        `^${escapeStringForMongoRegex(addTrailingSlash(clonedPageData.path))}`,
       ),
       parent: { $ne: null },
     });

+ 7 - 0
apps/app/src/server/service/page/page-service.ts

@@ -185,6 +185,13 @@ export interface IPageService {
     options: IOptionsForCreate,
     pageOpId: ObjectIdLike,
   ): Promise<void>;
+  updatePageSubOperation(
+    page,
+    user,
+    exPage,
+    options: IOptionsForUpdate,
+    pageOpId: ObjectIdLike,
+  ): Promise<void>;
 
   getCreatorIdForCanDelete(page: PageDocument): Promise<ObjectIdLike | null>;
 

+ 82 - 0
apps/app/src/server/service/search-delegator/elasticsearch.integ.ts

@@ -0,0 +1,82 @@
+import { vi } from 'vitest';
+
+import { configManager } from '~/server/service/config-manager';
+import { SocketIoService } from '~/server/service/socket-io/socket-io';
+
+import { getInstance } from '../../../../test/setup/crowi';
+import type { RebuildIndexOption } from '../interfaces/search';
+import ElasticsearchDelegator from './elasticsearch';
+
+// ELASTICSEARCH_URI is mapped from VITE_ELASTICSEARCH_URI by test/setup/elasticsearch.ts
+const hasElasticsearch = !!process.env.ELASTICSEARCH_URI;
+
+describe.skipIf(!hasElasticsearch)(
+  'ElasticsearchDelegator#init() with ELASTICSEARCH_REINDEX_ON_BOOT',
+  () => {
+    // Execute sequentially to use the same index name
+    describe.sequential('when ELASTICSEARCH_REINDEX_ON_BOOT=true', () => {
+      beforeAll(async () => {
+        process.env.ELASTICSEARCH_REINDEX_ON_BOOT = 'true';
+        await configManager.loadConfigs();
+      });
+      afterAll(() => {
+        delete process.env.ELASTICSEARCH_REINDEX_ON_BOOT;
+      });
+
+      describe('with a SocketIoService with an attached dummy HTTP server', () => {
+        it('should invoke rebuildIndex and complete without error', async () => {
+          // arrange
+          const crowi = await getInstance(); // attached SocketIoService with dummy server in setupCrowi.ts
+          const delegator = new ElasticsearchDelegator(crowi.socketIoService);
+          type WithRebuildIndex = {
+            rebuildIndex: (option?: RebuildIndexOption) => Promise<void>;
+          };
+          const rebuildSpy = vi.spyOn(
+            delegator as unknown as WithRebuildIndex,
+            'rebuildIndex',
+          );
+
+          // act
+          await delegator.init();
+
+          // assert
+          expect(rebuildSpy).toHaveBeenCalledOnce();
+          const { isNormalized } = await delegator.getInfoForAdmin();
+          expect(isNormalized).toBe(true);
+          await expect(
+            rebuildSpy.mock.results[0].value,
+          ).resolves.toBeUndefined();
+        }, 60_000);
+      });
+
+      describe('with a SocketIoService without an attached HTTP server', () => {
+        it('should invoke rebuildIndex and complete without error', async () => {
+          // arrange
+          const crowi = await getInstance();
+          // Use a SocketIoService without an attached HTTP server, as in actual boot.
+          // If rebuildIndex incorrectly emits progress, getAdminSocket() throws.
+          const socketIoService = new SocketIoService(crowi);
+          const delegator = new ElasticsearchDelegator(socketIoService);
+          type WithRebuildIndex = {
+            rebuildIndex: (option?: RebuildIndexOption) => Promise<void>;
+          };
+          const rebuildSpy = vi.spyOn(
+            delegator as unknown as WithRebuildIndex,
+            'rebuildIndex',
+          );
+
+          // act
+          await delegator.init();
+
+          // assert
+          expect(rebuildSpy).toHaveBeenCalledOnce();
+          const { isNormalized } = await delegator.getInfoForAdmin();
+          expect(isNormalized).toBe(true);
+          await expect(
+            rebuildSpy.mock.results[0].value,
+          ).resolves.toBeUndefined();
+        }, 60_000);
+      });
+    });
+  },
+);

+ 46 - 36
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -24,7 +24,11 @@ import type {
 import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { configManager } from '../config-manager';
-import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
+import type {
+  AddAllPagesOption,
+  RebuildIndexOption,
+  UpdateOrInsertPagesOpts,
+} from '../interfaces/search';
 import { aggregatePipelineToIndex } from './aggregate-to-index';
 import type {
   AggregatedPage,
@@ -211,7 +215,7 @@ class ElasticsearchDelegator
     const normalizeIndices = await this.normalizeIndices();
     if (this.isElasticsearchReindexOnBoot) {
       try {
-        await this.rebuildIndex();
+        await this.rebuildIndex({ shouldEmitProgress: false });
       } catch (err) {
         logger.error('Rebuild index on boot failed', err);
       }
@@ -333,13 +337,22 @@ class ElasticsearchDelegator
   /**
    * rebuild index
    */
-  async rebuildIndex(): Promise<void> {
+  async rebuildIndex(
+    option: RebuildIndexOption = { shouldEmitProgress: false },
+  ): Promise<void> {
     const { client, indexName, aliasName } = this;
+    const { shouldEmitProgress } = option;
 
     const tmpIndexName = `${indexName}-tmp`;
 
     try {
       // reindex to tmp index
+      const isExistsTmpIndex = await client.indices.exists({
+        index: tmpIndexName,
+      });
+      if (isExistsTmpIndex) {
+        await client.indices.delete({ index: tmpIndexName });
+      }
       await this.createIndex(tmpIndexName);
       await client.reindex(indexName, tmpIndexName);
 
@@ -356,13 +369,15 @@ class ElasticsearchDelegator
         index: indexName,
       });
       await this.createIndex(indexName);
-      await this.addAllPages();
+      await this.addAllPages({ shouldEmitProgress });
     } catch (error) {
-      logger.error("An error occured while 'rebuildIndex'.", error);
-      logger.error('error.meta.body', error?.meta?.body);
+      logger.error({ err: error }, "An error occured while 'rebuildIndex'.");
+      logger.error({ body: error?.meta?.body }, 'error.meta.body');
 
-      const socket = this.socketIoService.getAdminSocket();
-      socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
+      if (shouldEmitProgress) {
+        const socket = this.socketIoService.getAdminSocket();
+        socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
+      }
 
       throw error;
     } finally {
@@ -424,11 +439,7 @@ class ElasticsearchDelegator
     }
 
     if (isES9ClientDelegator(this.client)) {
-      const { mappings } =
-        process.env.CI == null
-          ? await import('./mappings/mappings-es9')
-          : await import('./mappings/mappings-es9-for-ci');
-
+      const { mappings } = await import('./mappings/mappings-es9');
       return this.client.indices.create({
         index,
         ...mappings,
@@ -502,10 +513,11 @@ class ElasticsearchDelegator
     body.push(command);
   }
 
-  addAllPages() {
+  addAllPages(option: AddAllPagesOption = { shouldEmitProgress: false }) {
+    const { shouldEmitProgress } = option;
     const Page = this.getPageModel();
     return this.updateOrInsertPages(() => Page.find(), {
-      shouldEmitProgress: true,
+      shouldEmitProgress,
       invokeGarbageCollection: true,
     });
   }
@@ -528,10 +540,12 @@ class ElasticsearchDelegator
    */
   async updateOrInsertPages(
     queryFactory,
-    option: UpdateOrInsertPagesOpts = {},
+    option: UpdateOrInsertPagesOpts = {
+      shouldEmitProgress: false,
+      invokeGarbageCollection: false,
+    },
   ): Promise<void> {
-    const { shouldEmitProgress = false, invokeGarbageCollection = false } =
-      option;
+    const { shouldEmitProgress, invokeGarbageCollection } = option;
 
     const Page = this.getPageModel();
     const { PageQueryBuilder } = Page;
@@ -669,7 +683,7 @@ class ElasticsearchDelegator
       const validateQueryResponse = await (async () => {
         if (isES7ClientDelegator(this.client)) {
           const es7SearchQuery = query as ES7SearchQuery;
-          return this.client.indices.validateQuery({
+          return await this.client.indices.validateQuery({
             explain: true,
             index: es7SearchQuery.index,
             body: {
@@ -680,7 +694,7 @@ class ElasticsearchDelegator
 
         if (isES8ClientDelegator(this.client)) {
           const es8SearchQuery = query as ES8SearchQuery;
-          return this.client.indices.validateQuery({
+          return await this.client.indices.validateQuery({
             explain: true,
             index: es8SearchQuery.index,
             query: es8SearchQuery.body.query,
@@ -689,7 +703,7 @@ class ElasticsearchDelegator
 
         if (isES9ClientDelegator(this.client)) {
           const es9SearchQuery = query as ES9SearchQuery;
-          return this.client.indices.validateQuery({
+          return await this.client.indices.validateQuery({
             explain: true,
             index: es9SearchQuery.index,
             query: es9SearchQuery.body.query,
@@ -705,16 +719,16 @@ class ElasticsearchDelegator
 
     const searchResponse = await (async () => {
       if (isES7ClientDelegator(this.client)) {
-        return this.client.search(query as ES7SearchQuery);
+        return await this.client.search(query as ES7SearchQuery);
       }
 
       if (isES8ClientDelegator(this.client)) {
-        return this.client.search(query as ES8SearchQuery);
+        return await this.client.search(query as ES8SearchQuery);
       }
 
       if (isES9ClientDelegator(this.client)) {
         const { body, ...rest } = query as ES9SearchQuery;
-        return this.client.search({
+        return await this.client.search({
           ...rest,
           // Elimination of the body property since ES9
           // https://raw.githubusercontent.com/elastic/elasticsearch-js/2f6200eb397df0e54d23848d769a93614ee1fb45/docs/release-notes/breaking-changes.md
@@ -956,11 +970,7 @@ class ElasticsearchDelegator
     }
   }
 
-  async filterPagesByViewer(
-    query: SearchQuery,
-    user,
-    userGroups,
-  ): Promise<void> {
+  filterPagesByViewer(query: SearchQuery, user, userGroups): void {
     const showPagesRestrictedByOwner = !configManager.getConfig(
       'security:list-policy:hideRestrictedByOwner',
     );
@@ -1096,7 +1106,7 @@ class ElasticsearchDelegator
     const query = this.createSearchQuery();
 
     this.appendCriteriaForQueryString(query, terms);
-    await this.filterPagesByViewer(query, user, userGroups);
+    this.filterPagesByViewer(query, user, userGroups);
     await this.appendFunctionScore(query, queryString);
 
     this.appendResultSize(query, from, size);
@@ -1105,7 +1115,7 @@ class ElasticsearchDelegator
 
     this.appendHighlight(query);
 
-    return this.searchKeyword(query);
+    return await this.searchKeyword(query);
   }
 
   isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
@@ -1129,7 +1139,7 @@ class ElasticsearchDelegator
 
   async syncPageUpdated(page, user) {
     logger.debug('SearchClient.syncPageUpdated', page.path);
-    return this.updateOrInsertPageById(page._id);
+    return await this.updateOrInsertPageById(page._id);
   }
 
   // remove pages whitch should nod Indexed
@@ -1147,7 +1157,7 @@ class ElasticsearchDelegator
   }
 
   async syncDescendantsPagesUpdated(parentPage, user) {
-    return this.updateOrInsertDescendantsPagesById(parentPage, user);
+    return await this.updateOrInsertDescendantsPagesById(parentPage, user);
   }
 
   async syncDescendantsPagesDeleted(pages, user) {
@@ -1175,19 +1185,19 @@ class ElasticsearchDelegator
   async syncBookmarkChanged(pageId) {
     logger.debug('SearchClient.syncBookmarkChanged', pageId);
 
-    return this.updateOrInsertPageById(pageId);
+    return await this.updateOrInsertPageById(pageId);
   }
 
   async syncCommentChanged(comment) {
     logger.debug('SearchClient.syncCommentChanged', comment);
 
-    return this.updateOrInsertPageById(comment.page);
+    return await this.updateOrInsertPageById(comment.page);
   }
 
   async syncTagChanged(page) {
     logger.debug('SearchClient.syncTagChanged', page.path);
 
-    return this.updateOrInsertPageById(page._id);
+    return await this.updateOrInsertPageById(page._id);
   }
 }
 

+ 0 - 121
apps/app/src/server/service/search-delegator/mappings/mappings-es9-for-ci.ts

@@ -1,121 +0,0 @@
-import type { estypes } from '@elastic/elasticsearch9';
-
-type Mappings = {
-  settings: estypes.IndicesCreateRequest['settings'];
-  mappings: estypes.IndicesCreateRequest['mappings'];
-};
-
-export const mappings: Mappings = {
-  settings: {
-    analysis: {
-      filter: {
-        english_stop: {
-          type: 'stop',
-          stopwords: '_english_',
-        },
-      },
-      tokenizer: {
-        edge_ngram_tokenizer: {
-          type: 'edge_ngram',
-          min_gram: 2,
-          max_gram: 20,
-          token_chars: ['letter', 'digit'],
-        },
-      },
-      analyzer: {
-        japanese: {
-          type: 'custom',
-          tokenizer: 'edge_ngram_tokenizer',
-          filter: ['lowercase', 'english_stop'],
-        },
-        english_edge_ngram: {
-          type: 'custom',
-          tokenizer: 'edge_ngram_tokenizer',
-          filter: ['lowercase', 'english_stop'],
-        },
-      },
-    },
-  },
-  mappings: {
-    properties: {
-      path: {
-        type: 'text',
-        fields: {
-          raw: {
-            type: 'text',
-            analyzer: 'keyword',
-          },
-          ja: {
-            type: 'text',
-            analyzer: 'japanese',
-          },
-          en: {
-            type: 'text',
-            analyzer: 'english_edge_ngram',
-            search_analyzer: 'standard',
-          },
-        },
-      },
-      body: {
-        type: 'text',
-        fields: {
-          ja: {
-            type: 'text',
-            analyzer: 'japanese',
-          },
-          en: {
-            type: 'text',
-            analyzer: 'english_edge_ngram',
-            search_analyzer: 'standard',
-          },
-        },
-      },
-      comments: {
-        type: 'text',
-        fields: {
-          ja: {
-            type: 'text',
-            analyzer: 'japanese',
-          },
-          en: {
-            type: 'text',
-            analyzer: 'english_edge_ngram',
-            search_analyzer: 'standard',
-          },
-        },
-      },
-      username: {
-        type: 'keyword',
-      },
-      comment_count: {
-        type: 'integer',
-      },
-      bookmark_count: {
-        type: 'integer',
-      },
-      like_count: {
-        type: 'integer',
-      },
-      grant: {
-        type: 'integer',
-      },
-      granted_users: {
-        type: 'keyword',
-      },
-      granted_groups: {
-        type: 'keyword',
-      },
-      created_at: {
-        type: 'date',
-        format: 'date_optional_time',
-      },
-      updated_at: {
-        type: 'date',
-        format: 'date_optional_time',
-      },
-      tag_names: {
-        type: 'keyword',
-      },
-    },
-  },
-};

+ 5 - 0
apps/app/src/server/service/search-query.spec.ts

@@ -34,6 +34,11 @@ vi.mock('~/server/service/config-manager/config-manager', () => {
 });
 
 class TestSearchService extends SearchService {
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
   override generateFullTextSearchDelegator(): ElasticsearchDelegator {
     return mock<ElasticsearchDelegator>();
   }

+ 23 - 13
apps/app/src/server/service/search.ts

@@ -87,36 +87,46 @@ const findPageListByIds = async (pageIds: ObjectIdLike[], crowi: any) => {
 };
 
 class SearchService implements SearchQueryParser, SearchResolver {
+  protected constructor() {}
+
   crowi: Crowi;
 
   isErrorOccuredOnHealthcheck: boolean | null;
 
   isErrorOccuredOnSearching: boolean | null;
 
-  fullTextSearchDelegator: any & ElasticsearchDelegator;
+  fullTextSearchDelegator: ElasticsearchDelegator;
 
   nqDelegators: { [key in SearchDelegatorName]: SearchDelegator };
 
-  constructor(crowi: Crowi) {
-    this.crowi = crowi;
+  static async create(crowi: Crowi) {
+    const instance = new SearchService();
+
+    instance.crowi = crowi;
 
-    this.isErrorOccuredOnHealthcheck = null;
-    this.isErrorOccuredOnSearching = null;
+    instance.isErrorOccuredOnHealthcheck = null;
+    instance.isErrorOccuredOnSearching = null;
 
     try {
-      this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
-      this.nqDelegators = this.generateNQDelegators(
-        this.fullTextSearchDelegator,
+      const tmpFullTextSearchDelegator =
+        instance.generateFullTextSearchDelegator();
+      if (tmpFullTextSearchDelegator == null) {
+        throw new Error('Failed to initialize search delegator');
+      }
+      instance.fullTextSearchDelegator = tmpFullTextSearchDelegator;
+      instance.nqDelegators = instance.generateNQDelegators(
+        instance.fullTextSearchDelegator,
       );
       logger.info('Succeeded to initialize search delegators');
     } catch (err) {
       logger.error(err);
     }
 
-    if (this.isConfigured) {
-      this.fullTextSearchDelegator.init();
-      this.registerUpdateEvent();
+    if (instance.isConfigured) {
+      await instance.fullTextSearchDelegator.init();
+      instance.registerUpdateEvent();
     }
+    return instance;
   }
 
   get isConfigured() {
@@ -321,8 +331,8 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return this.fullTextSearchDelegator.normalizeIndices();
   }
 
-  async rebuildIndex() {
-    return this.fullTextSearchDelegator.rebuildIndex();
+  async rebuildIndex(shouldEmitProgress = false) {
+    return this.fullTextSearchDelegator.rebuildIndex({ shouldEmitProgress });
   }
 
   async parseSearchQuery(

+ 1 - 1
apps/app/src/server/service/search/search-service.integ.ts

@@ -34,7 +34,7 @@ describe('SearchService test', () => {
 
   beforeAll(async () => {
     crowi = await getInstance();
-    searchService = new SearchService(crowi);
+    searchService = await SearchService.create(crowi);
     searchService.nqDelegators = {
       ...searchService.nqDelegators,
       [DEFAULT]: dummyFullTextSearchDelegator, // override with dummy full-text search delegator

+ 1 - 0
apps/app/src/states/ui/editor/index.ts

@@ -8,6 +8,7 @@ export * from './reserved-next-caret-line';
 export * from './selected-grant';
 export type { EditorMode as EditorModeType } from './types';
 export { EditorMode } from './types';
+export { useSyncSelectedGrantWithCurrentPage } from './use-sync-selected-grant';
 // Export utility functions that might be needed elsewhere
 export { determineEditorModeByHash } from './utils';
 export * from './waiting-save-processing';

+ 82 - 0
apps/app/src/states/ui/editor/selected-grant.spec.ts

@@ -0,0 +1,82 @@
+import { GroupType, PageGrant } from '@growi/core';
+
+import type { IPageGrantData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus } from '~/interfaces/page';
+
+import { toPageUpdateGrantParams, toSelectedGrant } from './selected-grant';
+
+describe('toSelectedGrant', () => {
+  it('maps the grant of the current page', () => {
+    const currentPageGrant: IPageGrantData = { grant: PageGrant.GRANT_OWNER };
+
+    expect(toSelectedGrant(currentPageGrant).grant).toBe(PageGrant.GRANT_OWNER);
+  });
+
+  it('returns an empty userRelatedGrantedGroups when groupGrantData is absent', () => {
+    const currentPageGrant: IPageGrantData = { grant: PageGrant.GRANT_PUBLIC };
+
+    expect(toSelectedGrant(currentPageGrant).userRelatedGrantedGroups).toEqual(
+      [],
+    );
+  });
+
+  it('includes only groups whose status is isGranted, mapped to { item, type }', () => {
+    const currentPageGrant: IPageGrantData = {
+      grant: PageGrant.GRANT_USER_GROUP,
+      groupGrantData: {
+        userRelatedGroups: [
+          {
+            id: 'granted-group',
+            name: 'granted',
+            type: GroupType.userGroup,
+            status: UserGroupPageGrantStatus.isGranted,
+          },
+          {
+            id: 'not-granted-group',
+            name: 'not granted',
+            type: GroupType.userGroup,
+            status: UserGroupPageGrantStatus.notGranted,
+          },
+          {
+            id: 'cannot-grant-group',
+            name: 'cannot grant',
+            type: GroupType.externalUserGroup,
+            status: UserGroupPageGrantStatus.cannotGrant,
+          },
+        ],
+        nonUserRelatedGrantedGroups: [],
+      },
+    };
+
+    expect(toSelectedGrant(currentPageGrant).userRelatedGrantedGroups).toEqual([
+      { item: 'granted-group', type: GroupType.userGroup },
+    ]);
+  });
+});
+
+describe('toPageUpdateGrantParams', () => {
+  // When the grant has not been chosen/loaded (null), the update must omit grant
+  // so the server preserves the page's existing grant — see issue #11272.
+  it('omits grant fields when no grant is selected (null)', () => {
+    expect(toPageUpdateGrantParams(null)).toEqual({
+      grant: undefined,
+      userRelatedGrantUserGroupIds: undefined,
+    });
+  });
+
+  it('passes through the selected grant and granted groups', () => {
+    const userRelatedGrantedGroups = [
+      { item: 'group-1', type: GroupType.userGroup },
+    ];
+
+    expect(
+      toPageUpdateGrantParams({
+        grant: PageGrant.GRANT_USER_GROUP,
+        userRelatedGrantedGroups,
+      }),
+    ).toEqual({
+      grant: PageGrant.GRANT_USER_GROUP,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
+    });
+  });
+});

+ 50 - 6
apps/app/src/states/ui/editor/selected-grant.ts

@@ -1,18 +1,62 @@
-import { PageGrant } from '@growi/core/dist/interfaces';
 import { atom, useAtom } from 'jotai';
 
-import type { IPageSelectedGrant } from '~/interfaces/page';
+import type {
+  IOptionsForUpdate,
+  IPageGrantData,
+  IPageSelectedGrant,
+} from '~/interfaces/page';
+import { UserGroupPageGrantStatus } from '~/interfaces/page';
 
 /**
  * Atom for selected grant in page editor
- * Stores temporary grant selection before it's applied to the page
+ * Stores temporary grant selection before it's applied to the page.
+ *
+ * Defaults to null ("not yet loaded") — NOT GRANT_PUBLIC. The real grant is the
+ * page's current grant (which, for a newly created page, is inherited from the
+ * closest ancestor), supplied asynchronously by useSyncSelectedGrantWithCurrentPage.
+ * A GRANT_PUBLIC default would let an early save publish a restricted page before
+ * that value arrives — see [[use-sync-selected-grant]] and issue #11272.
  */
-const selectedGrantAtom = atom<IPageSelectedGrant | null>({
-  grant: PageGrant.GRANT_PUBLIC,
-});
+const selectedGrantAtom = atom<IPageSelectedGrant | null>(null);
 
 /**
  * Hook for managing selected grant in page editor
  * Used for temporary grant selection before applying to the page
  */
 export const useSelectedGrant = () => useAtom(selectedGrantAtom);
+
+/**
+ * Convert the page's current grant data (server-side shape) into the
+ * IPageSelectedGrant shape held by the editor's selected-grant state.
+ *
+ * Pure function so it can be reused from both the sync hook
+ * ([[use-sync-selected-grant]]) and GrantSelector's change handler.
+ */
+export const toSelectedGrant = (
+  currentPageGrant: IPageGrantData,
+): IPageSelectedGrant => {
+  const userRelatedGrantedGroups =
+    currentPageGrant.groupGrantData?.userRelatedGroups
+      .filter((group) => group.status === UserGroupPageGrantStatus.isGranted)
+      .map((group) => ({ item: group.id, type: group.type })) ?? [];
+
+  return {
+    grant: currentPageGrant.grant,
+    userRelatedGrantedGroups,
+  };
+};
+
+/**
+ * Build the grant-related params for a page update from the selected grant.
+ *
+ * When nothing is selected (null) — e.g. the grant has not loaded yet, or
+ * GrantSelector never mounted on mobile — both fields are omitted (undefined),
+ * so the update endpoint preserves the page's existing grant rather than
+ * overwriting it with a stale default. See issue #11272.
+ */
+export const toPageUpdateGrantParams = (
+  selectedGrant: IPageSelectedGrant | null,
+): Pick<IOptionsForUpdate, 'grant' | 'userRelatedGrantUserGroupIds'> => ({
+  grant: selectedGrant?.grant,
+  userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
+});

+ 90 - 0
apps/app/src/states/ui/editor/use-sync-selected-grant.spec.tsx

@@ -0,0 +1,90 @@
+import { PageGrant } from '@growi/core';
+import { act, renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+
+import { useCurrentPageId } from '~/states/page';
+import { useSWRxCurrentGrantData } from '~/stores/page';
+
+import { useSelectedGrant } from './selected-grant';
+import { useSyncSelectedGrantWithCurrentPage } from './use-sync-selected-grant';
+
+vi.mock('~/states/page', () => ({ useCurrentPageId: vi.fn() }));
+vi.mock('~/stores/page', () => ({ useSWRxCurrentGrantData: vi.fn() }));
+
+const mockedUseCurrentPageId = vi.mocked(useCurrentPageId);
+const mockedUseSWRxCurrentGrantData = vi.mocked(useSWRxCurrentGrantData);
+
+// Build a plain SWR response. vitest-mock-extended's mock<SWRResponse>() cannot be
+// used here: its deep proxy auto-stubs `.then`, so React treats `data` as a thenable
+// and breaks rendering. A plain object with a single localized cast is the repo norm
+// (see states/page/use-fetch-current-page.spec.tsx).
+const grantDataResponse = (currentPageGrant?: {
+  grant: PageGrant;
+}): ReturnType<typeof useSWRxCurrentGrantData> =>
+  ({
+    data:
+      currentPageGrant == null
+        ? undefined
+        : {
+            isGrantNormalized: true,
+            grantData: { isForbidden: false, currentPageGrant },
+          },
+    error: undefined,
+    isLoading: false,
+    isValidating: false,
+    mutate: vi.fn(),
+  }) as ReturnType<typeof useSWRxCurrentGrantData>;
+
+describe('useSyncSelectedGrantWithCurrentPage', () => {
+  let store: ReturnType<typeof createStore>;
+
+  // Render the consumer's view (useSelectedGrant) alongside the sync hook so we
+  // assert on the observable atom value, not on the setter being called.
+  const renderSyncHook = () =>
+    renderHook(
+      () => {
+        const selected = useSelectedGrant();
+        useSyncSelectedGrantWithCurrentPage();
+        return selected;
+      },
+      {
+        wrapper: ({ children }) => (
+          <Provider store={store}>{children}</Provider>
+        ),
+      },
+    );
+
+  beforeEach(() => {
+    store = createStore();
+    mockedUseCurrentPageId.mockReturnValue('page1');
+  });
+
+  it("initializes selectedGrant from the current page's grant", () => {
+    mockedUseSWRxCurrentGrantData.mockReturnValue(
+      grantDataResponse({ grant: PageGrant.GRANT_OWNER }),
+    );
+
+    // renderHook flushes mount effects in its internal act(), so the sync has
+    // already applied by the time it returns.
+    const { result } = renderSyncHook();
+
+    expect(result.current[0]).toEqual({
+      grant: PageGrant.GRANT_OWNER,
+      userRelatedGrantedGroups: [],
+    });
+  });
+
+  it('does not overwrite an existing selection while grant data is unavailable', () => {
+    mockedUseSWRxCurrentGrantData.mockReturnValue(grantDataResponse());
+
+    const { result } = renderSyncHook();
+
+    act(() => {
+      result.current[1]({ grant: PageGrant.GRANT_RESTRICTED });
+    });
+
+    // The sync effect re-runs on the update but must leave the selection intact
+    // because there is no grant data to apply yet.
+    expect(result.current[0]).toEqual({ grant: PageGrant.GRANT_RESTRICTED });
+  });
+});

+ 33 - 0
apps/app/src/states/ui/editor/use-sync-selected-grant.ts

@@ -0,0 +1,33 @@
+import { useEffect } from 'react';
+
+import { useCurrentPageId } from '~/states/page';
+import { useSWRxCurrentGrantData } from '~/stores/page';
+
+import { toSelectedGrant, useSelectedGrant } from './selected-grant';
+
+/**
+ * Sync selectedGrantAtom with the current page's grant.
+ *
+ * The atom starts as null (unresolved); this fills it with the page's actual
+ * grant so the editor reflects the real visibility. It must run from an
+ * always-mounted component: on mobile, GrantSelector is rendered only inside a
+ * closed Modal and therefore never mounts, so it cannot own this sync. (Saving
+ * while the atom is still null omits the grant, so the server preserves it — the
+ * pre-load race is handled separately in PageEditor's save path.)
+ *
+ * Call this once from an always-mounted editor component (e.g. SavePageControls).
+ *
+ * @see https://github.com/growilabs/growi/issues/11272
+ */
+export const useSyncSelectedGrantWithCurrentPage = (): void => {
+  const currentPageId = useCurrentPageId();
+  const { data } = useSWRxCurrentGrantData(currentPageId);
+  const [, setSelectedGrant] = useSelectedGrant();
+
+  const currentPageGrant = data?.grantData.currentPageGrant;
+
+  useEffect(() => {
+    if (currentPageGrant == null) return;
+    setSelectedGrant(toSelectedGrant(currentPageGrant));
+  }, [currentPageGrant, setSelectedGrant]);
+};

+ 14 - 0
apps/app/test/setup/elasticsearch.ts

@@ -0,0 +1,14 @@
+// Vitest injects VITE_- and VITE_-prefixed .env.test vars into process.env for Node environments.
+// Map VITE_-prefixed vars to the names that the app's configManager expects.
+const VITE_ENV_MAP: Record<string, string> = {
+  VITE_ELASTICSEARCH_URI: 'ELASTICSEARCH_URI',
+  VITE_ELASTICSEARCH_VERSION: 'ELASTICSEARCH_VERSION',
+  VITE_ELASTICSEARCH_REINDEX_ON_BOOT: 'ELASTICSEARCH_REINDEX_ON_BOOT',
+};
+
+for (const [vitestKey, appKey] of Object.entries(VITE_ENV_MAP)) {
+  const value = process.env[vitestKey];
+  if (value != null) {
+    process.env[appKey] = value;
+  }
+}

+ 12 - 0
apps/app/turbo.json

@@ -89,6 +89,18 @@
       "dependsOn": ["^dev"],
       "outputLogs": "new-only"
     },
+    "test:unit": {
+      "dependsOn": ["^dev"],
+      "outputLogs": "new-only"
+    },
+    "test:integ": {
+      "dependsOn": ["^dev"],
+      "outputLogs": "new-only"
+    },
+    "test:components": {
+      "dependsOn": ["^dev"],
+      "outputLogs": "new-only"
+    },
 
     "version:patch": {
       "cache": false,

+ 1 - 0
apps/app/vitest.workspace.mts

@@ -46,6 +46,7 @@ export default defineWorkspace([
       // Pre-download the MongoDB binary before workers start to avoid lock-file race conditions
       globalSetup: ['./test/setup/mongo/global-setup.ts'],
       setupFiles: [
+        './test/setup/elasticsearch.ts',
         './test/setup/migrate-mongo.ts',
         './test/setup/mongo/index.ts',
       ],

+ 2 - 2
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.5.4-slackbot-proxy.0",
+  "version": "7.5.5-slackbot-proxy.0",
   "license": "MIT",
   "private": true,
   "scripts": {
@@ -46,7 +46,7 @@
     "@tsed/schema": "=6.43.0",
     "@tsed/swagger": "=6.43.0",
     "@tsed/typeorm": "=6.43.0",
-    "axios": "^1.15.0",
+    "axios": "^1.16.0",
     "body-parser": "^1.20.3",
     "compression": "^1.7.4",
     "cookie-parser": "^1.4.5",

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.5.4",
+  "version": "7.5.5-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": true,
@@ -45,7 +45,7 @@
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
     "@faker-js/faker": "^9.0.1",
-    "@playwright/test": "^1.58.2",
+    "@playwright/test": "^1.60.0",
     "@swc-node/register": "^1.10.9",
     "@swc/core": "^1.5.25",
     "@swc/helpers": "^0.5.18",
@@ -78,7 +78,7 @@
     "ts-patch": "^3.3.0",
     "tsconfig-paths": "^4.2.0",
     "tspc": "^1.1.2",
-    "turbo": "^2.1.3",
+    "turbo": "^2.9.14",
     "typescript": "^5.9.3",
     "typescript-transform-paths": "^3.5.6",
     "vite": "^6.4.2",

+ 8 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,13 @@
 # @growi/core
 
+## 2.3.1
+
+### Patch Changes
+
+- [#11236](https://github.com/growilabs/growi/pull/11236) [`bd28252`](https://github.com/growilabs/growi/commit/bd28252c1a6e7f76f9bdadbdc3a07690f6bc0573) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Fix page operations and v5 page migration failing for page paths that contain non-ASCII whitespace (e.g. U+3000 IDEOGRAPHIC SPACE)
+
+  Node.js 24's `RegExp.escape()` escapes non-ASCII whitespace (code points >= U+0100, such as U+3000) into `\uXXXX` form, which MongoDB's PCRE2 engine does not support (error 51091). Added `escapeStringForMongoRegex()`, which escapes only regex metacharacters and passes other characters through literally, and used it wherever the resulting pattern is sent to MongoDB.
+
 ## 2.3.0
 
 ### Minor Changes

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "2.3.0",
+  "version": "2.3.1",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "repository": {

+ 61 - 0
packages/core/src/utils/escape-string-for-regex.spec.ts

@@ -0,0 +1,61 @@
+import { describe, expect, test } from 'vitest';
+
+import { escapeStringForMongoRegex } from './escape-string-for-regex';
+
+describe('escapeStringForMongoRegex', () => {
+  test('escapes regex metacharacters', () => {
+    expect(escapeStringForMongoRegex('a.b*c+d?e')).toBe('a\\.b\\*c\\+d\\?e');
+    expect(escapeStringForMongoRegex('(group)[set]{n}')).toBe(
+      '\\(group\\)\\[set\\]\\{n\\}',
+    );
+    expect(escapeStringForMongoRegex('^start$ | end\\')).toBe(
+      '\\^start\\$ \\| end\\\\',
+    );
+  });
+
+  test('escapes hyphen as \\x2d (escape-string-regexp v5 behavior)', () => {
+    expect(escapeStringForMongoRegex('a-b')).toBe('a\\x2db');
+  });
+
+  test('does NOT escape forward slash or ASCII space', () => {
+    // The .source getter still renders "/" as "\/", but the escaped string itself keeps "/" literal.
+    expect(escapeStringForMongoRegex('/parent/child')).toBe('/parent/child');
+    expect(escapeStringForMongoRegex('a b')).toBe('a b');
+  });
+
+  // Core property of the fix: unlike RegExp.escape(), this must NOT emit \uXXXX,
+  // because MongoDB's PCRE2 engine rejects \u (error 51091).
+  test('passes non-ASCII whitespace through literally (no \\u escape)', () => {
+    const ideographicSpace = ' '; // full-width space
+    const escaped = escapeStringForMongoRegex(`/page${ideographicSpace}title`);
+    expect(escaped).toContain(ideographicSpace);
+    expect(escaped).not.toContain('\\u');
+  });
+
+  test.each([
+    ' ',
+    ' ',
+    ' ',
+    ' ',
+    ' ',
+    '
',
+    '
',
+    ' ',
+    ' ',
+    ' ',
+  ])('does not emit \\u for whitespace char %j', (ws) => {
+    expect(escapeStringForMongoRegex(`x${ws}y`)).not.toContain('\\u');
+  });
+
+  test('produces a pattern that literally matches the original string', () => {
+    for (const s of [
+      '/parent/全角 space', // U+3000
+      '/a.b+c?(d)[e]',
+      '/path-with-hyphen',
+      '/nbsp here',
+    ]) {
+      const re = new RegExp(`^${escapeStringForMongoRegex(s)}$`);
+      expect(re.test(s)).toBe(true);
+    }
+  });
+});

+ 22 - 0
packages/core/src/utils/escape-string-for-regex.ts

@@ -0,0 +1,22 @@
+/**
+ * Escape a string for safe use inside a regular expression that is sent to MongoDB
+ * (`$regex` / `new RegExp(...)` used in a query). MongoDB evaluates regular expressions
+ * with the PCRE2 engine.
+ *
+ * Why not `RegExp.escape()`:
+ *   Node.js 24's built-in `RegExp.escape()` escapes non-ASCII whitespace
+ *   (code points >= U+0100, e.g. U+3000 IDEOGRAPHIC SPACE) into `\uXXXX` form.
+ *   PCRE2 does not support `\u`, so such a pattern makes MongoDB throw
+ *   "Regular expression is invalid: PCRE2 does not support ... \u" (error 51091).
+ *
+ * This helper instead escapes only regex metacharacters and passes every other
+ * character through literally — behaviourally identical to `escape-string-regexp` v5,
+ * which is what GROWI used before the v7.5.0 refactor. The output never contains `\u`,
+ * so it is safe to hand to MongoDB.
+ *
+ * Use this (not `RegExp.escape`) whenever the resulting pattern is sent to MongoDB.
+ * For in-process JS regex (`.test()` / `.replace()`), `RegExp.escape` is fine.
+ */
+export const escapeStringForMongoRegex = (str: string): string => {
+  return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
+};

+ 1 - 0
packages/core/src/utils/index.ts

@@ -4,6 +4,7 @@ import * as _envUtils from './env-utils';
 export const envUtils = _envUtils;
 
 export * from './browser-utils';
+export * from './escape-string-for-regex';
 export * from './global-event-target';
 export * from './growi-theme-metadata';
 export * as deepEquals from './is-deep-equals';

+ 12 - 2
packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts

@@ -17,8 +17,10 @@ describe('generateChildrenRegExp', () => {
       invalidPaths: ['/parent', '/parent/child/grandchild', '/other/path'],
     },
     {
+      // escapeStringForMongoRegex does not escape ASCII space (it is PCRE-safe as-is),
+      // unlike RegExp.escape which would emit \x20.
       path: '/parent (with brackets)',
-      expected: '^\\/parent\\x20\\(with\\x20brackets\\)(\\/[^/]+)\\/?$',
+      expected: '^\\/parent \\(with brackets\\)(\\/[^/]+)\\/?$',
       validPaths: [
         '/parent (with brackets)/child',
         '/parent (with brackets)/test',
@@ -30,13 +32,21 @@ describe('generateChildrenRegExp', () => {
     },
     {
       path: '/parent[with square]',
-      expected: '^\\/parent\\[with\\x20square\\](\\/[^/]+)\\/?$',
+      expected: '^\\/parent\\[with square\\](\\/[^/]+)\\/?$',
       validPaths: ['/parent[with square]/child', '/parent[with square]/test'],
       invalidPaths: [
         '/parent[with square]',
         '/parent[with square]/child/grandchild',
       ],
     },
+    {
+      // Regression for #11235: a path containing U+3000 (full-width space) must NOT be
+      // escaped to   — MongoDB's PCRE2 rejects \u (error 51091). The char passes through literally.
+      path: '/親 ページ',
+      expected: '^\\/親 ページ(\\/[^/]+)\\/?$',
+      validPaths: ['/親 ページ/child', '/親 ページ/テスト'],
+      invalidPaths: ['/親 ページ', '/親 ページ/child/grandchild'],
+    },
     {
       path: '/parent*with+special?chars',
       expected: '^\\/parent\\*with\\+special\\?chars(\\/[^/]+)\\/?$',

+ 5 - 1
packages/core/src/utils/page-path-utils/generate-children-regexp.ts

@@ -1,3 +1,4 @@
+import { escapeStringForMongoRegex } from '../escape-string-for-regex';
 import { isTopPage } from './is-top-page';
 
 /**
@@ -10,5 +11,8 @@ export const generateChildrenRegExp = (path: string): RegExp => {
 
   // https://regex101.com/r/mrDJrx/1
   // ex. /parent/any_child OR /any_level1
-  return new RegExp(`^${RegExp.escape(path)}(\\/[^/]+)\\/?$`);
+  // NOTE: use escapeStringForMongoRegex (not RegExp.escape) because this pattern is sent to
+  // MongoDB ($regex). RegExp.escape would emit \uXXXX for non-ASCII whitespace (e.g. U+3000),
+  // which PCRE2 rejects (error 51091).
+  return new RegExp(`^${escapeStringForMongoRegex(path)}(\\/[^/]+)\\/?$`);
 };

+ 1 - 1
packages/editor/package.json

@@ -27,7 +27,7 @@
   "// comments for devDependencies": {},
   "devDependencies": {
     "@codemirror/autocomplete": "^6.18.4",
-    "@codemirror/commands": "^6.8.0",
+    "@codemirror/commands": "^6.10.3",
     "@codemirror/lang-markdown": "^6.3.2",
     "@codemirror/language": "^6.11.3",
     "@codemirror/language-data": "^6.5.1",

+ 1 - 1
packages/pdf-converter-client/package.json

@@ -12,7 +12,7 @@
     "build": "pnpm gen:client-code && tsc -p tsconfig.json"
   },
   "dependencies": {
-    "axios": "^1.15.0",
+    "axios": "^1.16.0",
     "tslib": "^2.8.0"
   },
   "devDependencies": {

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