Переглянути джерело

Merge commit '4cbd0e1ab9' into fix/153352-184094-bulk-export-attachment-removal-race

tomoyuki-t 1 тиждень тому
батько
коміт
311f6e533b
44 змінених файлів з 773 додано та 744 видалено
  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. 8 1
      .kiro/steering/tdd.md
  11. 2 2
      AGENTS.md
  12. 23 1
      CHANGELOG.md
  13. 1 1
      apps/app/.claude/skills/app-specific-patterns/SKILL.md
  14. 1 1
      apps/app/docker/README.md
  15. 3 3
      apps/app/package.json
  16. 15 0
      apps/app/playwright/10-installer/install.spec.ts
  17. 7 2
      apps/app/playwright/20-basic-features/presentation.spec.ts
  18. 4 2
      apps/app/src/features/openai/server/services/openai.ts
  19. 3 0
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  20. 159 0
      apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.spec.ts
  21. 89 0
      apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.ts
  22. 15 1
      apps/app/src/server/models/bookmark-folder.ts
  23. 3 0
      apps/app/src/server/models/errors.ts
  24. 2 1
      apps/app/src/server/models/obsolete-page.js
  25. 8 7
      apps/app/src/server/models/page.ts
  26. 15 2
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  27. 2 1
      apps/app/src/server/routes/apiv3/users.js
  28. 16 6
      apps/app/src/server/service/page-grant.ts
  29. 12 5
      apps/app/src/server/service/page/index.ts
  30. 1 1
      apps/slackbot-proxy/package.json
  31. 2 2
      package.json
  32. 8 0
      packages/core/CHANGELOG.md
  33. 1 1
      packages/core/package.json
  34. 61 0
      packages/core/src/utils/escape-string-for-regex.spec.ts
  35. 22 0
      packages/core/src/utils/escape-string-for-regex.ts
  36. 1 0
      packages/core/src/utils/index.ts
  37. 12 2
      packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts
  38. 5 1
      packages/core/src/utils/page-path-utils/generate-children-regexp.ts
  39. 7 0
      packages/pluginkit/CHANGELOG.md
  40. 1 1
      packages/pluginkit/package.json
  41. 5 4
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  42. 7 5
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  43. 1 1
      packages/slack/package.json
  44. 93 302
      pnpm-lock.yaml

+ 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
 

+ 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

+ 23 - 1
CHANGELOG.md

@@ -1,9 +1,31 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.5.3...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.5.4...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.5.4](https://github.com/growilabs/compare/v7.5.3...v7.5.4) - 2026-05-27
+
+### 💎 Features
+
+* feat: Retrieve GROWI news (#10986) @ryotaro-nagahara
+* feat: Editor guide (#10847) @yuki-takei
+* feat(otel): Add growi_installed_at metrics (#11214) @ryotaro-nagahara
+* feat(otel): add yjs docs count and mongoose connection pool metrics (v7 backport) (#11218) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: UserPicture in order to show tooltip in production builds (#11192) @yuki-takei
+* fix: Correct parent grant value in /grant-data endpoint (#11181) @yuki-takei
+* fix: Permit GRANT_RESTRICTED child under any parent grant (#11182) @yuki-takei
+* fix(editor): Fix cursor stuck on wrapped lines by upgrading @codemirror/view to ^6.42.1 (#11153) @yuki-takei
+* fix(bookmark): Add owner authorization checks to bookmark folder api (#11178) @Ryosei-Fukushima
+* fix(bulk-export): Set completedAt on all bulk export completion paths (#11195) @tomoyuki-t-weseek
+* fix(admin): Prompt reload after toggling page bulk export setting (#11180) @tomoyuki-t-weseek
+* fix(drawio): draw.io stencil URLs for local instances (#11196) @yuki-takei
+* fix(bulk-export): Show bulk-export restart modal on duplicate-job error (#11186) @tomoyuki-t-weseek
+* fix(admin): stop infinite render loop on G2G data transfer page (#11166) @miya
+
 ## [v7.5.3](https://github.com/growilabs/compare/v7.5.2...v7.5.3) - 2026-05-14
 
 ### 🐛 Bug Fixes

+ 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
 

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.5.3`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.5.3/apps/app/docker/Dockerfile)
+* [`7.5.4`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.5.4/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 

+ 3 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.5.4-RC.0",
+  "version": "7.5.5-RC.0",
   "license": "MIT",
   "private": true,
   "scripts": {
@@ -175,7 +175,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",
@@ -223,7 +223,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",

+ 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();
 });

+ 7 - 2
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -41,12 +41,17 @@ test('Slide page (slide: true frontmatter) renders without crashing', async ({
   // save
   await page.keyboard.press('Control+s');
 
+  // The editor stays mounted but hidden (d-none) after switching to view mode,
+  // so its preview pane also contains a `.slides` deck. Scope to the visible
+  // deck to avoid a strict-mode violation against the hidden editor preview.
+  const viewSlides = page.locator('.slides').filter({ visible: true });
+
   // view mode must render the slide deck after save
   await page.getByTestId('view-button').click();
-  await expect(page.locator('.slides')).toBeVisible();
+  await expect(viewSlides).toBeVisible();
 
   // reload exercises the SWR loading path where rendererOptions is briefly
   // undefined; the slide page must still render without crashing.
   await page.reload();
-  await expect(page.locator('.slides')).toBeVisible();
+  await expect(viewSlides).toBeVisible();
 });

+ 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}($|/)`);
     }

+ 3 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/index.ts

@@ -1,4 +1,5 @@
 export { addApplicationMetrics } from './application-metrics';
+export { addInstalledAtMetrics } from './installed-at-metrics';
 export { addMongooseConnectionPoolMetrics } from './mongoose-connection-pool-metrics';
 export { addPageCountsMetrics } from './page-counts-metrics';
 export { addSystemMetrics } from './system-metrics';
@@ -7,6 +8,7 @@ export { addYjsMetrics } from './yjs-metrics';
 
 export const setupCustomMetrics = async (): Promise<void> => {
   const { addApplicationMetrics } = await import('./application-metrics');
+  const { addInstalledAtMetrics } = await import('./installed-at-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
   const { addPageCountsMetrics } = await import('./page-counts-metrics');
   const { addSystemMetrics } = await import('./system-metrics');
@@ -17,6 +19,7 @@ export const setupCustomMetrics = async (): Promise<void> => {
 
   // Add custom metrics
   addApplicationMetrics();
+  addInstalledAtMetrics();
   addUserCountsMetrics();
   addPageCountsMetrics();
   addSystemMetrics();

+ 159 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.spec.ts

@@ -0,0 +1,159 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addInstalledAtMetrics } from './installed-at-metrics';
+
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => ({
+      error: vi.fn(),
+    }),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('addInstalledAtMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockInstalledAtGauge = mock<ObservableGauge>();
+  const mockInstalledAtByOldestUserGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge
+      .mockReturnValueOnce(mockInstalledAtGauge)
+      .mockReturnValueOnce(mockInstalledAtByOldestUserGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('should create observable gauges and set up metrics collection', () => {
+    addInstalledAtMetrics();
+
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-installed-at-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenNthCalledWith(
+      1,
+      'growi.installed_at.timestamp.seconds',
+      {
+        description: 'GROWI installation time as Unix timestamp (seconds)',
+        unit: 's',
+      },
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenNthCalledWith(
+      2,
+      'growi.installed_at.by_oldest_user.timestamp.seconds',
+      {
+        description:
+          'GROWI installation time inferred from the oldest user as Unix timestamp (seconds)',
+        unit: 's',
+      },
+    );
+    expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
+      expect.any(Function),
+      [mockInstalledAtGauge, mockInstalledAtByOldestUserGauge],
+    );
+  });
+
+  describe('metrics callback behavior', () => {
+    it('should observe both gauges in unix seconds when both dates exist', async () => {
+      const installedAt = new Date('2023-01-01T00:00:00.000Z');
+      const installedAtByOldestUser = new Date('2022-06-15T12:30:00.000Z');
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue({
+        additionalInfo: {
+          installedAt,
+          installedAtByOldestUser,
+        },
+      });
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+        includeInstalledInfo: true,
+      });
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockInstalledAtGauge,
+        Math.floor(installedAt.getTime() / 1000),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockInstalledAtByOldestUserGauge,
+        Math.floor(installedAtByOldestUser.getTime() / 1000),
+      );
+    });
+
+    it('should skip observe for missing installedAt', async () => {
+      const installedAtByOldestUser = new Date('2022-06-15T12:30:00.000Z');
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue({
+        additionalInfo: {
+          installedAt: undefined,
+          installedAtByOldestUser,
+        },
+      });
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).not.toHaveBeenCalledWith(
+        mockInstalledAtGauge,
+        expect.anything(),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockInstalledAtByOldestUserGauge,
+        Math.floor(installedAtByOldestUser.getTime() / 1000),
+      );
+    });
+
+    it('should skip both observes when additionalInfo is missing', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue({
+        additionalInfo: undefined,
+      });
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+
+    it('should swallow errors from growiInfoService gracefully', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
+      const mockResult = { observe: vi.fn() };
+
+      addInstalledAtMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 89 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.ts

@@ -0,0 +1,89 @@
+/**
+ * Installed-at metrics.
+ *
+ * Exposes two independent metrics derived from the same data source
+ * (growiInfoService.getGrowiInfo). Bundled in a single file because they share
+ * the fetch — a single batch callback observes both gauges in one call,
+ * avoiding duplicate DB access per collection interval.
+ *
+ * Prometheus exposure (OTel `.` → Prometheus `_`):
+ *   growi.installed_at.timestamp.seconds              → growi_installed_at_timestamp_seconds
+ *   growi.installed_at.by_oldest_user.timestamp.seconds → growi_installed_at_by_oldest_user_timestamp_seconds
+ */
+import { diag, metrics } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-metrics:installed-at-metrics',
+);
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:installed-at',
+});
+
+function toUnixSeconds(date: Date | null | undefined): number | undefined {
+  if (date == null) return undefined;
+  return Math.floor(date.getTime() / 1000);
+}
+
+export function addInstalledAtMetrics(): void {
+  logger.info('Starting installed-at metrics collection');
+
+  const meter = metrics.getMeter('growi-installed-at-metrics', '1.0.0');
+
+  // Metric 1/2: installation time recorded at system setup
+  const installedAtGauge = meter.createObservableGauge(
+    'growi.installed_at.timestamp.seconds',
+    {
+      description: 'GROWI installation time as Unix timestamp (seconds)',
+      unit: 's',
+    },
+  );
+
+  // Metric 2/2: installation time inferred from the oldest user
+  const installedAtByOldestUserGauge = meter.createObservableGauge(
+    'growi.installed_at.by_oldest_user.timestamp.seconds',
+    {
+      description:
+        'GROWI installation time inferred from the oldest user as Unix timestamp (seconds)',
+      unit: 's',
+    },
+  );
+
+  // Single batch callback feeds both gauges from one growiInfoService fetch
+  meter.addBatchObservableCallback(
+    async (result) => {
+      try {
+        // Dynamic import to avoid circular dependencies
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeInstalledInfo: true,
+        });
+
+        const installedAtSeconds = toUnixSeconds(
+          growiInfo.additionalInfo?.installedAt,
+        );
+        if (installedAtSeconds != null) {
+          result.observe(installedAtGauge, installedAtSeconds);
+        }
+
+        const installedAtByOldestUserSeconds = toUnixSeconds(
+          growiInfo.additionalInfo?.installedAtByOldestUser,
+        );
+        if (installedAtByOldestUserSeconds != null) {
+          result.observe(
+            installedAtByOldestUserGauge,
+            installedAtByOldestUserSeconds,
+          );
+        }
+      } catch (error) {
+        loggerDiag.error('Failed to collect installed-at metrics', { error });
+      }
+    },
+    [installedAtGauge, installedAtByOldestUserGauge],
+  );
+
+  logger.info('Installed-at metrics collection started successfully');
+}

+ 15 - 1
apps/app/src/server/models/bookmark-folder.ts

@@ -10,7 +10,11 @@ import type {
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
-import { InvalidParentBookmarkFolderError } from './errors';
+import {
+  BookmarkFolderForbiddenError,
+  BookmarkFolderNotFoundError,
+  InvalidParentBookmarkFolderError,
+} from './errors';
 
 const logger = loggerFactory('growi:models:bookmark-folder');
 const Bookmark = monggoose.model('Bookmark');
@@ -28,6 +32,7 @@ export interface BookmarkFolderModel extends Model<BookmarkFolderDocument> {
   createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>;
   deleteFolderAndChildren(
     bookmarkFolderId: Types.ObjectId | string,
+    ownerId?: Types.ObjectId | string,
   ): Promise<{ deletedCount: number }>;
   updateBookmarkFolder(
     bookmarkFolderId: string,
@@ -115,8 +120,17 @@ bookmarkFolderSchema.statics.createByParameters = async function (
 
 bookmarkFolderSchema.statics.deleteFolderAndChildren = async function (
   bookmarkFolderId: Types.ObjectId | string,
+  ownerId?: Types.ObjectId | string,
 ): Promise<{ deletedCount: number }> {
   const bookmarkFolder = await this.findById(bookmarkFolderId);
+  if (ownerId != null) {
+    if (bookmarkFolder == null) {
+      throw new BookmarkFolderNotFoundError('Bookmark folder not found');
+    }
+    if (bookmarkFolder.owner.toString() !== ownerId.toString()) {
+      throw new BookmarkFolderForbiddenError('Forbidden');
+    }
+  }
   // Delete parent and all children folder
   let deletedCount = 0;
   if (bookmarkFolder != null) {

+ 3 - 0
apps/app/src/server/models/errors.ts

@@ -16,3 +16,6 @@ export class NullUsernameToBeRegisteredError extends ExtensibleCustomError {}
 
 // Invalid Parent bookmark folder error
 export class InvalidParentBookmarkFolderError extends ExtensibleCustomError {}
+
+export class BookmarkFolderNotFoundError extends ExtensibleCustomError {}
+export class BookmarkFolderForbiddenError extends ExtensibleCustomError {}

+ 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}).*$`) });
 

+ 15 - 2
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -8,7 +8,11 @@ import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import loginRequiredFactory from '~/server/middlewares/login-required';
-import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
+import {
+  BookmarkFolderForbiddenError,
+  BookmarkFolderNotFoundError,
+  InvalidParentBookmarkFolderError,
+} from '~/server/models/errors';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import loggerFactory from '~/utils/logger';
 
@@ -340,10 +344,19 @@ module.exports = (crowi: Crowi) => {
     async (req, res) => {
       const { id } = req.params;
       try {
-        const result = await BookmarkFolder.deleteFolderAndChildren(id);
+        const result = await BookmarkFolder.deleteFolderAndChildren(
+          id,
+          req.user._id,
+        );
         const { deletedCount } = result;
         return res.apiv3({ deletedCount });
       } catch (err) {
+        if (err instanceof BookmarkFolderNotFoundError) {
+          return res.apiv3Err('bookmark_folder_not_found', 404);
+        }
+        if (err instanceof BookmarkFolderForbiddenError) {
+          return res.apiv3Err('forbidden', 403);
+        }
         logger.error(err);
         return res.apiv3Err(err, 500);
       }

+ 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 = {

+ 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) {

+ 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 },
     });

+ 1 - 1
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": {

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.5.4-RC.0",
+  "version": "7.5.5-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": true,
@@ -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)}(\\/[^/]+)\\/?$`);
 };

+ 7 - 0
packages/pluginkit/CHANGELOG.md

@@ -1,5 +1,12 @@
 # @growi/pluginkit
 
+## 1.2.4
+
+### Patch Changes
+
+- Updated dependencies [[`bd28252`](https://github.com/growilabs/growi/commit/bd28252c1a6e7f76f9bdadbdc3a07690f6bc0573)]:
+  - @growi/core@2.3.1
+
 ## 1.2.3
 
 ### Patch Changes

+ 1 - 1
packages/pluginkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pluginkit",
-  "version": "1.2.3",
+  "version": "1.2.4",
   "description": "Plugin kit for GROWI",
   "license": "MIT",
   "repository": {

+ 5 - 4
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -1,4 +1,5 @@
 import type { IPageHasId, IUser } from '@growi/core';
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import type { Request, Response } from 'express';
 import createError from 'http-errors';
 import { mock } from 'vitest-mock-extended';
@@ -187,7 +188,7 @@ describe('listPages', () => {
       const pagePath = '/parent';
       const optionsFilter = '^child';
       const expectedRegex = new RegExp(
-        `^${RegExp.escape('/parent/')}${RegExp.escape('child')}`,
+        `^${escapeStringForMongoRegex('/parent/')}${escapeStringForMongoRegex('child')}`,
       );
 
       // when
@@ -202,7 +203,7 @@ describe('listPages', () => {
       const pagePath = '/parent';
       const optionsFilter = 'child';
       const expectedRegex = new RegExp(
-        `^${RegExp.escape('/parent/')}.*${RegExp.escape('child')}`,
+        `^${escapeStringForMongoRegex('/parent/')}.*${escapeStringForMongoRegex('child')}`,
       );
 
       // when
@@ -230,7 +231,7 @@ describe('listPages', () => {
       const pagePath = '/parent';
       const optionsFilter = 'child';
       const expectedRegex = new RegExp(
-        `^${RegExp.escape('/parent/')}.*${RegExp.escape('child')}`,
+        `^${escapeStringForMongoRegex('/parent/')}.*${escapeStringForMongoRegex('child')}`,
       );
 
       // when
@@ -320,7 +321,7 @@ describe('when excludedPaths is handled', () => {
 
     // check if the logic generates the correct regex: ^\/(user|tmp)(\/|$)
     const expectedRegex = new RegExp(
-      `^\\/(${RegExp.escape('user')}|${RegExp.escape('tmp')})(\\/|$)`,
+      `^\\/(${escapeStringForMongoRegex('user')}|${escapeStringForMongoRegex('tmp')})(\\/|$)`,
     );
     expect(queryMock.and).toHaveBeenCalledWith([
       {

+ 7 - 5
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -1,6 +1,6 @@
 import type { IUser } from '@growi/core';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
-import { pathUtils } from '@growi/core/dist/utils';
+import { escapeStringForMongoRegex, pathUtils } from '@growi/core/dist/utils';
 import { loggerFactory } from '@growi/logger';
 import type { Request, Response } from 'express';
 import createError, { isHttpError } from 'http-errors';
@@ -33,16 +33,18 @@ export function addFilterCondition(
     );
   }
 
-  const pagePathForRegexp = RegExp.escape(addTrailingSlash(pagePath));
+  const pagePathForRegexp = escapeStringForMongoRegex(
+    addTrailingSlash(pagePath),
+  );
 
   let filterPath: RegExp;
   try {
     if (optionsFilter.charAt(0) === '^') {
       // move '^' to the first of path
-      const escapedFilter = RegExp.escape(optionsFilter.slice(1));
+      const escapedFilter = escapeStringForMongoRegex(optionsFilter.slice(1));
       filterPath = new RegExp(`^${pagePathForRegexp}${escapedFilter}`);
     } else {
-      const escapedFilter = RegExp.escape(optionsFilter);
+      const escapedFilter = escapeStringForMongoRegex(optionsFilter);
       filterPath = new RegExp(`^${pagePathForRegexp}.*${escapedFilter}`);
     }
   } catch (err) {
@@ -101,7 +103,7 @@ export const listPages = ({
       if (excludedPaths.length > 0) {
         const escapedPaths = excludedPaths.map((p) => {
           const cleanPath = p.startsWith('/') ? p.substring(1) : p;
-          return RegExp.escape(cleanPath);
+          return escapeStringForMongoRegex(cleanPath);
         });
 
         const regex = new RegExp(`^\\/(${escapedPaths.join('|')})(\\/|$)`);

+ 1 - 1
packages/slack/package.json

@@ -59,7 +59,7 @@
     "date-fns": "^3.6.0",
     "extensible-custom-error": "^0.0.7",
     "http-errors": "^2.0.0",
-    "qs": "^6.14.2",
+    "qs": "^6.15.2",
     "url-join": "^4.0.0"
   },
   "devDependencies": {

Різницю між файлами не показано, бо вона завелика
+ 93 - 302
pnpm-lock.yaml


Деякі файли не було показано, через те що забагато файлів було змінено