Sfoglia il codice sorgente

Merge pull request #11162 from growilabs/master

Release v7.5.4
mergify[bot] 2 settimane fa
parent
commit
e7dae9e39e
100 ha cambiato i file con 8235 aggiunte e 598 eliminazioni
  1. 18 0
      .claude/commands/investigate-issue.md
  2. 39 0
      .claude/rules/devcontainer.md
  3. 100 37
      .claude/skills/investigate-issue/SKILL.md
  4. 23 2
      .devcontainer/compose.yml
  5. 5 8
      .github/mergify.yml
  6. 7 0
      .github/workflows/reusable-app-prod.yml
  7. 221 47
      .kiro/specs/news-inappnotification/design.md
  8. 19 3
      .kiro/specs/news-inappnotification/requirements.md
  9. 2 2
      .kiro/specs/news-inappnotification/spec.json
  10. 71 34
      .kiro/specs/news-inappnotification/tasks.md
  11. 90 0
      .kiro/specs/opentelemetry/brief.md
  12. 737 0
      .kiro/specs/opentelemetry/design.md
  13. 139 0
      .kiro/specs/opentelemetry/requirements.md
  14. 173 0
      .kiro/specs/opentelemetry/research.md
  15. 23 0
      .kiro/specs/opentelemetry/spec.json
  16. 29 0
      .kiro/specs/opentelemetry/tasks.md
  17. 100 0
      apps/app/.claude/skills/app-commands/SKILL.md
  18. 3 3
      apps/app/package.json
  19. 7 2
      apps/app/playwright/20-basic-features/presentation.spec.ts
  20. 11 0
      apps/app/public/static/locales/en_US/admin.json
  21. 9 3
      apps/app/public/static/locales/en_US/commons.json
  22. 75 0
      apps/app/public/static/locales/en_US/translation.json
  23. 11 0
      apps/app/public/static/locales/fr_FR/admin.json
  24. 9 3
      apps/app/public/static/locales/fr_FR/commons.json
  25. 76 0
      apps/app/public/static/locales/fr_FR/translation.json
  26. 11 0
      apps/app/public/static/locales/ja_JP/admin.json
  27. 9 3
      apps/app/public/static/locales/ja_JP/commons.json
  28. 76 0
      apps/app/public/static/locales/ja_JP/translation.json
  29. 11 0
      apps/app/public/static/locales/ko_KR/admin.json
  30. 5 1
      apps/app/public/static/locales/ko_KR/commons.json
  31. 75 0
      apps/app/public/static/locales/ko_KR/translation.json
  32. 11 0
      apps/app/public/static/locales/zh_CN/admin.json
  33. 9 3
      apps/app/public/static/locales/zh_CN/commons.json
  34. 76 0
      apps/app/public/static/locales/zh_CN/translation.json
  35. 12 0
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  36. 17 4
      apps/app/src/client/components/Admin/App/ConfirmModal.tsx
  37. 30 0
      apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx
  38. 8 5
      apps/app/src/client/components/Admin/G2GDataTransfer.tsx
  39. 6 4
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  40. 6 0
      apps/app/src/client/components/InAppNotification/UnreadDot.module.scss
  41. 50 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.module.scss
  42. 143 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.tsx
  43. 21 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.module.scss
  44. 89 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.tsx
  45. 19 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.module.scss
  46. 214 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.tsx
  47. 18 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.module.scss
  48. 230 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.tsx
  49. 20 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.module.scss
  50. 219 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.tsx
  51. 29 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/dynamic.tsx
  52. 1 0
      apps/app/src/client/components/PageEditor/EditorGuideModal/index.ts
  53. 35 31
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  54. 9 3
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  55. 138 0
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationContent.tsx
  56. 97 0
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.spec.tsx
  57. 69 0
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.tsx
  58. 0 74
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  59. 5 4
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  60. 201 0
      apps/app/src/client/components/Sidebar/InAppNotification/hooks/useMergedInAppNotifications.ts
  61. 2 1
      apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantAlert.tsx
  62. 14 1
      apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx
  63. 140 0
      apps/app/src/components/Script/DrawioViewerScript/patch-stencil-registry-urls.spec.ts
  64. 20 0
      apps/app/src/components/Script/DrawioViewerScript/patch-stencil-registry-urls.ts
  65. 191 0
      apps/app/src/features/news/client/components/NewsItem.spec.tsx
  66. 83 0
      apps/app/src/features/news/client/components/NewsItem.tsx
  67. 77 0
      apps/app/src/features/news/client/components/admin/NewsDeliverySetting.tsx
  68. 59 0
      apps/app/src/features/news/client/hooks/use-news.ts
  69. 42 0
      apps/app/src/features/news/client/services/news-delivery-setting.ts
  70. 34 0
      apps/app/src/features/news/interfaces/news-item.ts
  71. 11 0
      apps/app/src/features/news/interfaces/news-read-status.ts
  72. 56 0
      apps/app/src/features/news/server/models/news-item.spec.ts
  73. 56 0
      apps/app/src/features/news/server/models/news-item.ts
  74. 29 0
      apps/app/src/features/news/server/models/news-read-status.spec.ts
  75. 43 0
      apps/app/src/features/news/server/models/news-read-status.ts
  76. 286 0
      apps/app/src/features/news/server/routes/news-integration.integ.ts
  77. 359 0
      apps/app/src/features/news/server/routes/news.spec.ts
  78. 246 0
      apps/app/src/features/news/server/routes/news.ts
  79. 65 0
      apps/app/src/features/news/server/services/feed-parser.ts
  80. 319 0
      apps/app/src/features/news/server/services/news-cron-service.spec.ts
  81. 140 0
      apps/app/src/features/news/server/services/news-cron-service.ts
  82. 445 0
      apps/app/src/features/news/server/services/news-service.spec.ts
  83. 184 0
      apps/app/src/features/news/server/services/news-service.ts
  84. 0 123
      apps/app/src/features/opentelemetry/docs/custom-metrics/architecture.md
  85. 0 87
      apps/app/src/features/opentelemetry/docs/custom-metrics/implementation-guide.md
  86. 0 49
      apps/app/src/features/opentelemetry/docs/overview.md
  87. 6 0
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts
  88. 1 0
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts
  89. 14 0
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  90. 159 0
      apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.spec.ts
  91. 89 0
      apps/app/src/features/opentelemetry/server/custom-metrics/installed-at-metrics.ts
  92. 277 0
      apps/app/src/features/opentelemetry/server/custom-metrics/mongoose-connection-pool-metrics.spec.ts
  93. 119 0
      apps/app/src/features/opentelemetry/server/custom-metrics/mongoose-connection-pool-metrics.ts
  94. 373 0
      apps/app/src/features/opentelemetry/server/custom-metrics/system-metrics.spec.ts
  95. 93 0
      apps/app/src/features/opentelemetry/server/custom-metrics/system-metrics.ts
  96. 179 0
      apps/app/src/features/opentelemetry/server/custom-metrics/yjs-metrics.spec.ts
  97. 47 0
      apps/app/src/features/opentelemetry/server/custom-metrics/yjs-metrics.ts
  98. 8 40
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts
  99. 1 9
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts
  100. 2 12
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts

+ 18 - 0
.claude/commands/investigate-issue.md

@@ -0,0 +1,18 @@
+---
+name: investigate-issue
+description: Investigate a GitHub issue - fetch info, update labels, analyze code/reproduce, report findings, and optionally fix. Usage: /investigate-issue <issue-url-or-number>
+---
+
+# /investigate-issue
+
+Invoke the `investigate-issue` skill in **interactive mode** with the given issue number or URL.
+
+Pass `$ARGUMENTS` as-is to the skill (issue number, URL, or URL with `--auto`).
+
+Interactive mode preserves the original stop-gate behavior: the skill will pause and ask for your direction at each decision point (version mismatch, fix decision, PR decision).
+
+To run in autonomous mode — where the skill makes decisions independently when confidence is HIGH and only stops when confidence is MEDIUM or LOW — append `--auto`:
+
+```
+/investigate-issue 12345 --auto
+```

+ 39 - 0
.claude/rules/devcontainer.md

@@ -0,0 +1,39 @@
+# Devcontainer Environment
+
+## Service Connectivity
+
+This project runs inside a devcontainer defined in `.devcontainer/compose.yml`. The Docker Compose services are **always accessible by hostname** — do NOT run connectivity checks (`ping`, `nc`, `node net.connect`, etc.) before using them.
+
+| Service | Hostname | Port | Notes |
+|---------|----------|------|-------|
+| MongoDB | `mongo` | `27017` | Replica set `rs0`; required for transactions and change streams |
+| Elasticsearch | `elasticsearch` | `9200` | Full-text search |
+
+## MongoDB
+
+Connection string (already in `apps/app/.env.development`):
+```
+mongodb://mongo:27017/growi?replicaSet=rs0
+```
+
+`mongosh` is **not** installed in the devcontainer (`app` service). To run ad-hoc queries from the devcontainer, use the bundled MongoDB driver via Node.js:
+
+```bash
+node -e "
+const { MongoClient } = require('/workspace/growi-vault/node_modules/.pnpm/mongodb@6.8.0_@aws-sdk+credential-providers@3.600.0_@aws-sdk+client-sso-oidc@3.600.0__socks@2.8.3/node_modules/mongodb');
+async function main() {
+  const client = new MongoClient('mongodb://mongo:27017/growi?replicaSet=rs0');
+  await client.connect();
+  const db = client.db('growi');
+  // ... your query here ...
+  await client.close();
+}
+main().catch(console.error);
+"
+```
+
+## Smoke Testing the App
+
+The development server **can always be started** in the devcontainer for smoke and integration verification. Never claim the runtime environment is unavailable.
+
+See `apps/app/.claude/skills/app-commands/SKILL.md` → **Smoke Testing** section for the full workflow.

+ 100 - 37
.claude/commands/invest-issue.md → .claude/skills/investigate-issue/SKILL.md

@@ -1,19 +1,50 @@
 ---
-name: invest-issue
-description: Investigate a GitHub issue - fetch info, update labels, analyze code/reproduce, report findings, and optionally fix. Usage: /invest-issue <issue-url-or-number>
+name: investigate-issue
+description: Investigate a GitHub issue - fetch info, update labels, analyze code/reproduce, report findings, and optionally fix. Usage: /investigate-issue <issue-url-or-number>
+allowed-tools: Read, Write, Edit, Bash, Grep, Glob, Agent, AskUserQuestion
+argument-hint: <issue-url-or-number> [--auto]
 ---
 
-# /invest-issue — Issue Investigation
+# investigate-issue
+
+## Overview
 
 Investigate a GROWI GitHub issue end-to-end: fetch details, label it, analyze or reproduce the problem, report findings, and proceed to fix if approved.
 
+This skill supports two execution modes:
+- **interactive** (default): stop gates ask the user at each decision point — original behavior
+- **autonomous** (pass `--auto` or when invoked from a routine): stop gates are crossed automatically when confidence is HIGH; only stop when confidence is MEDIUM or LOW
+
 ## Input
 
 `$ARGUMENTS` is either:
 - A full GitHub issue URL: `https://github.com/growilabs/growi/issues/99999`
 - An issue number: `99999`
+- Either of the above with `--auto` appended to enable autonomous mode
+
+Parse the issue number from whichever form is provided. Detect `--auto` flag to set `mode = autonomous`; otherwise `mode = interactive`.
+
+---
+
+## Confidence Framework
+
+At each decision gate, assess confidence before deciding whether to ask the user.
 
-Parse the issue number from whichever form is provided.
+**CONFIDENCE levels:**
+
+| Level | Meaning | Action in `autonomous` mode | Action in `interactive` mode |
+|-------|---------|---------------------------|------------------------------|
+| HIGH | Evidence is clear, risk is low, path forward is unambiguous | Proceed autonomously — state the evidence and the decision made | Ask user (present recommendation clearly) |
+| MEDIUM | Some evidence exists but ambiguity remains, or blast radius is larger than expected | Stop and ask — present findings and your recommendation | Ask user |
+| LOW | Evidence is thin, multiple theories, or the change is risky | Stop and ask — present what is known and what is missing | Ask user |
+
+When stopping in `autonomous` mode (MEDIUM or LOW), present:
+1. What evidence was gathered
+2. Why confidence is not HIGH (specifically what is missing or ambiguous)
+3. A recommended action with your reasoning
+4. The alternatives
+
+---
 
 ## Step 1: Fetch Issue Information
 
@@ -31,6 +62,8 @@ Extract and display:
 - Steps to reproduce (if any)
 - Expected vs actual behavior
 
+---
+
 ## Step 2: Update Labels — Mark as Under Investigation
 
 Before applying any labels, fetch the exact label names from the repository:
@@ -53,6 +86,8 @@ gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "{EXACT_PHASE_UN
 
 If `phase/new` is not present, skip the removal step and only add `phase/under-investigation`.
 
+---
+
 ## Step 3: Analyze the Issue
 
 ### 3-A: Version Check
@@ -62,16 +97,25 @@ If `phase/new` is not present, skip the removal step and only add `phase/under-i
    ```bash
    cat apps/app/package.json | grep '"version"'
    ```
-3. If the reported major version matches master's major version → proceed with master-branch analysis.
-4. If the reported major version is **older** than master's major version → **STOP analysis** and ask the user:
+3. If the reported major version **matches** master's major version → proceed directly to Step 3-B.
+4. If the reported major version is **older** than master's major version:
+
+   **Autonomous confidence assessment — before asking, gather evidence:**
 
-   > Reported version is v{X}.x, but master is v{Y}.x.
-   > Would you like me to:
-   > 1. **Check out v{X}.x tag/branch** and analyze on that version
-   > 2. **Continue on master** — the issue may still be relevant
-   > 3. **Close as outdated** — skip analysis
+   Spawn a subagent to do a targeted search in the current codebase for any code paths, symptoms, or identifiers mentioned in the issue (error messages, function names, UI element names, API endpoints). Instruct the subagent to determine whether the issue is likely still present in master.
 
-   **Wait for the user's response before continuing to Step 3-B.**
+   Then assess confidence:
+
+   - **HIGH**: The subagent finds the exact buggy code path unchanged in master (same logic, same file, same behavior described). Proceed on master and note: _"Reported on v{X}.x; confirmed the same code path is present in master — continuing analysis on master."_
+   - **MEDIUM**: Code has changed but it is unclear whether the bug was fixed or remains. Stop and ask:
+     > Reported version is v{X}.x, master is v{Y}.x. Related code has changed significantly (see evidence below).
+     > **Recommendation**: Continue on master to check if the behavior persists.
+     > Would you like me to: 1) Continue on master, 2) Check out v{X}.x tag, 3) Close as outdated?
+   - **LOW**: Major version gap, completely different architecture, or the subagent finds no relevant code. Stop and ask:
+     > Reported version is v{X}.x, master is v{Y}.x. Could not locate relevant code in master.
+     > Would you like me to: 1) Check out v{X}.x tag, 2) Continue on master anyway, 3) Close as outdated?
+
+   In `interactive` mode, always ask regardless of confidence. Present the evidence gathered and your recommendation.
 
 ### 3-B: Code Investigation
 
@@ -107,10 +151,12 @@ gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "{EXACT_PHASE_CO
 gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "type/bug"
 ```
 
+---
+
 ## Step 4: Report Findings
 
 > **CRITICAL**: Do NOT modify any source files in this step. Step 4 is analysis and planning only.
-> Implementing code changes before receiving explicit user approval is strictly forbidden.
+> Implementing code changes before the gate in Step 4-C is strictly forbidden.
 
 ### 4-A: Report in This Session
 
@@ -136,7 +182,6 @@ List specific files and changes needed, but do NOT apply them yet.}
 
 **CRITICAL — Language rule**: Detect the language of the issue body (from Step 1) and write the comment **strictly in that language**, regardless of the language used in this conversation.
 The issue body language takes absolute priority over the conversation language.
-For example, if the issue body is written in English, the comment MUST be in English even if the user conversed in Japanese — and vice versa.
 
 Post the findings as a GitHub issue comment:
 
@@ -161,23 +206,34 @@ EOF
 )"
 ```
 
-### 4-C: STOP — Ask for Direction
+### 4-C: Fix Decision Gate
+
+**Confidence assessment — evaluate before deciding:**
 
-**STOP HERE. Do not proceed to Step 5 until the user explicitly approves.**
+Assess confidence based on:
+- Root cause: **pinpointed** (exact file + lines identified) vs. **suspected** vs. **unknown**
+- Fix approach: **clear** (1-3 files, minimal blast radius, no architectural change) vs. **complex** vs. **unclear**
+- Risk: **low** (isolated logic, well-tested module) vs. **medium** vs. **high** (auth, data migration, shared utilities)
 
-After reporting, ask the user:
+| Situation | Confidence |
+|-----------|-----------|
+| Root cause pinpointed + fix is surgical (1-3 files) + low risk | HIGH |
+| Root cause identified but fix touches multiple modules or has side effects | MEDIUM |
+| Root cause unconfirmed, multiple theories, or complex fix | LOW |
 
-> Investigation complete. Root cause [found / not yet confirmed].
-> Would you like me to:
-> 1. **Proceed with the fix** — I'll implement the fix now
-> 2. **Investigate further** — specify what additional analysis is needed
-> 3. **Stop here** — you'll handle the fix manually
+**In `autonomous` mode:**
+- **HIGH** → proceed to Step 5 automatically. State: _"Root cause confirmed at {file}:{line}. Fix approach is clear and low-risk — proceeding to implementation."_
+- **MEDIUM or LOW** → stop and ask:
+  > Investigation complete. Root cause [found/not confirmed]. Fix confidence: {MEDIUM/LOW} because {specific reason}.
+  > **Recommendation**: {your recommended action with reasoning}
+  > Would you like me to: 1) Proceed with the fix, 2) Investigate further ({what specifically}), 3) Stop here?
 
-**Wait for the user's response before doing anything else.**
+**In `interactive` mode:**
+Always ask, presenting confidence level and recommendation clearly.
 
-## Step 5: Implement the Fix (Only if Approved)
+---
 
-Proceed only after explicit user approval.
+## Step 5: Implement the Fix (Only if approved or autonomous HIGH confidence)
 
 ### 5-A: Add WIP Label — BEFORE Any Code Changes
 
@@ -200,8 +256,6 @@ Branch naming convention: `fix/{ISSUE_NUMBER}-{short-description}`
 git checkout -b fix/{ISSUE_NUMBER}-{short-description}
 ```
 
-Example: `fix/12345-page-title-overflow`
-
 ### 5-C: Implement the Fix
 
 - Make the minimal targeted fix
@@ -217,21 +271,27 @@ Example: `fix/12345-page-title-overflow`
   Fixes #ISSUE_NUMBER
   ```
 
-### 5-D: STOP — Ask for PR Approval
+### 5-D: PR Decision Gate
 
-**STOP HERE. Do not create a PR until the user explicitly approves.**
+**Confidence assessment — evaluate after implementation:**
 
-Report the implementation summary and ask:
+| Situation | Confidence |
+|-----------|-----------|
+| All tests pass + lint passes + fix stays within originally scoped files | HIGH |
+| Tests pass but warnings exist, or fix expanded beyond original scope | MEDIUM |
+| Tests fail, lint errors, or unexpected scope expansion | LOW |
 
-> Implementation complete. Changes committed to `fix/{ISSUE_NUMBER}-{short-description}`.
-> Would you like me to:
-> 1. **Create a PR** — I'll open a pull request now
-> 2. **Review first** — you'll review the changes before PR
-> 3. **Stop here** — you'll handle the PR manually
+**In `autonomous` mode:**
+- **HIGH** → proceed to create PR automatically. State: _"Tests and lint pass. Fix is within scope — creating PR."_
+- **MEDIUM or LOW** → stop and ask:
+  > Implementation complete on `fix/{ISSUE_NUMBER}-{short-description}`. PR confidence: {MEDIUM/LOW} because {specific reason — e.g., "2 tests failing in unrelated module", "fix expanded to touch auth middleware"}.
+  > **Recommendation**: {your recommended action}
+  > Would you like me to: 1) Create a PR, 2) Review first, 3) Stop here?
 
-**Wait for the user's response before proceeding.**
+**In `interactive` mode:**
+Always ask, presenting confidence level and recommendation clearly.
 
-### 5-E: Open a Pull Request (Only if Approved)
+### 5-E: Open a Pull Request (Only if approved or autonomous HIGH confidence)
 
 ```bash
 gh pr create \
@@ -272,9 +332,12 @@ gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --remove-label "{EXACT_PHASE
 gh issue edit {ISSUE_NUMBER} --repo growilabs/growi --add-label "{EXACT_PHASE_RESOLVED_LABEL}"
 ```
 
+---
+
 ## Error Handling
 
 - If the issue number is invalid or not found: display error from `gh` and stop
 - If `gh` is not authenticated: instruct the user to run `gh auth login`
 - If a label does not exist in the repo: note it in output and skip (don't create new labels)
 - If the dev server fails to start: note this and rely on code analysis only
+- If a subagent returns inconclusive results during confidence assessment: treat as MEDIUM confidence

+ 23 - 2
.devcontainer/compose.yml

@@ -18,6 +18,7 @@ services:
   mongo:
     image: mongo:8.2
     restart: unless-stopped
+    command: ["--replSet", "rs0", "--bind_ip_all"]
     ports:
       - 27017
     volumes:
@@ -28,8 +29,9 @@ services:
       timeout: 5s
       retries: 20
 
-  # Ensures MongoDB Feature Compatibility Version matches the mongo image.
-  # Required when the mongo image is upgraded while existing data persists in the volume.
+  # Initializes the replica set and ensures Feature Compatibility Version matches the mongo image.
+  # The replica set (single node `rs0`) is required for transactions and change streams used in development.
+  # FCV update is required when the mongo image is upgraded while existing data persists in the volume.
   # https://www.mongodb.com/ja-jp/docs/upcoming/release-notes/8.2-upgrade-standalone/
   mongo-init:
     image: mongo:8.2
@@ -44,6 +46,25 @@ services:
       - mongo:27017
       - --eval
       - |
+        // 1. Initiate the replica set (single node) if not already initialized.
+        try {
+          const status = rs.status();
+          print(`Replica set already initialized: $${status.set}`);
+        } catch (e) {
+          if (e.codeName === 'NotYetInitialized') {
+            print('Initiating replica set rs0...');
+            rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'mongo:27017' }] });
+            // Wait until this node is elected primary so subsequent admin commands succeed.
+            while (!db.hello().isWritablePrimary) {
+              sleep(500);
+            }
+            print('Replica set initiated and primary is ready');
+          } else {
+            throw e;
+          }
+        }
+
+        // 2. Ensure FCV matches the mongo image.
         const target = '8.2';
         const result = db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 });
         if (result.featureCompatibilityVersion.version === target) {

+ 5 - 8
.github/mergify.yml

@@ -7,11 +7,6 @@ queue_rules:
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= test-prod-node24 /
-      # Explicitly enumerate sub-checks of test-prod-node24 so that matrix-
-      # job level failures (e.g. `run-playwright (chromium, 2/2, 6.0)`)
-      # reliably block merges. The broader `-check-failure ~= test-prod-node24 /`
-      # has historically let such failures through (observed on run
-      # 24828684287 for PR #11032).
       - -check-failure ~= test-prod-node24 / build-prod
       - -check-failure ~= test-prod-node24 / launch-prod
       - -check-failure ~= test-prod-node24 / run-playwright
@@ -22,12 +17,14 @@ queue_rules:
       - check-success ~= ci-app-launch-dev
       - check-success = test-prod-node24 / build-prod
       - check-success ~= test-prod-node24 / launch-prod
-      - check-success ~= test-prod-node24 / run-playwright
+      # Gate on report-playwright (not individual run-playwright shards).
+      # report-playwright runs after ALL shards complete (needs: [run-playwright])
+      # and fails if any shard did not succeed (including cancelled), so a single
+      # SUCCESS here guarantees every playwright matrix job passed — no race condition.
+      - check-success = test-prod-node24 / report-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= test-prod-node24 /
-      # Defensive: same explicit enumeration as queue_conditions so the
-      # merge-time gate cannot be bypassed by a matrix-job level failure.
       - -check-failure ~= test-prod-node24 / build-prod
       - -check-failure ~= test-prod-node24 / launch-prod
       - -check-failure ~= test-prod-node24 / run-playwright

+ 7 - 0
.github/workflows/reusable-app-prod.yml

@@ -356,3 +356,10 @@ jobs:
         name: html-report
         path: playwright-report
         retention-days: 30
+
+    - name: Fail if any playwright shard did not succeed
+      if: needs.run-playwright.result != 'success'
+      run: |
+        echo "run-playwright aggregate result: ${{ needs.run-playwright.result }}"
+        echo "One or more Playwright shards failed or were cancelled. See html-report artifact for details."
+        exit 1

+ 221 - 47
.kiro/specs/news-inappnotification/design.md

@@ -12,7 +12,7 @@
 
 ### Goals
 
-- 外部フィード(`NEWS_FEED_URL`)を cron で定期取得し、MongoDB にキャッシュする
+- 外部フィード(コードにハードコードされた配信元 URL)を cron で定期取得し、MongoDB にキャッシュする
 - InAppNotification パネルで通知とニュースを統合表示する
 - ニュースの既読/未読状態をユーザー単位で管理する
 - ロール別表示制御(admin/general)をサーバーサイドで強制する
@@ -33,7 +33,7 @@
 
 InAppNotification は per-user ドキュメント設計であり、`user` フィールドが必須。通知発生時に全対象ユーザー分のドキュメントを生成する(push 型)。ニュースは全ユーザーで1件のドキュメントを共有し、ユーザーがパネルを開いたときに取得する(pull 型)。この設計上の差異により、ニュースは別モデルとして実装する(詳細は `research.md` の Design Decisions を参照)。
 
-サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、`InAppNotificationSubstance.tsx` へ prop として渡すパターンを採用している。本機能のフィルタ state も同じパターンで実装する。
+サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、子コンポーネントへ prop として渡すパターンを採用している。本機能ではフィルタ state も同じく親で管理する。データ層(2 つの SWR ストリーム合流・マージ・mutation handlers)は責務集中による凝集度低下を避けるため `hooks/useMergedInAppNotifications.ts` のカスタムフックに集約し、Forms(フィルタ UI)と Content(リスト描画)のプレゼンテーションを分離する。
 
 ### Architecture Pattern & Boundary Map
 
@@ -65,8 +65,9 @@ graph TB
 
 **Architecture Integration**:
 - 選択パターン: Pull 型 + クライアントサイドマージ
-- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`
-- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `InAppNotificationSubstance.tsx`(フィルタタブ + InfiniteScroll), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算)
+- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`, `useMergedInAppNotifications`(パネルのデータ層フック), `InAppNotificationForms.tsx`, `InAppNotificationContent.tsx`
+- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算), `InAppNotificationElm.tsx`(既存通知側の修正あり)
+- スコープ拡張: `@growi/core` に `features.in_app_notification` を新設し、News API と既存 `/in-app-notification/*` の通知データ取得系エンドポイントを移行(設定 CRUD は `user_settings.in_app_notification` のまま)
 - 既存 `InfiniteScroll.tsx` をそのまま再利用
 
 ### Technology Stack
@@ -74,7 +75,7 @@ graph TB
 | Layer | 選択 / バージョン | 役割 |
 |---|---|---|
 | Backend Cron | node-cron(既存) | フィード定期取得スケジューリング |
-| Backend HTTP | node `fetch` / axios(既存) | `NEWS_FEED_URL` から feed.json 取得 |
+| Backend HTTP | node `fetch` / axios(既存) | コードに内蔵された配信元 URL から feed.json 取得 |
 | Data Store | MongoDB + Mongoose(既存) | NewsItem, NewsReadStatus の永続化 |
 | Frontend Data | SWR `useSWRInfinite`(既存) | ニュース・通知の無限スクロール取得 |
 | Frontend State | React `useState`(既存パターン) | フィルタタブ・未読トグルのローカル state |
@@ -92,14 +93,15 @@ sequenceDiagram
   participant Feed as GitHub Pages
   participant DB as MongoDB
 
-  Cron->>Cron: getCronSchedule() = '0 1 * * *'
-  Cron->>Cron: NEWS_FEED_URL 未設定? → スキップ
+  Cron->>Cron: getCronSchedule() = '0 0 * * *'(midnight 起動)
+  Cron->>Cron: configManager.getConfig('news:isDeliveryEnabled') が false? → スキップ
+  Cron->>Cron: randomSleep(0–5 時間)でリクエスト時刻を分散
   Cron->>Feed: HTTP GET feed.json
   alt 取得失敗
     Cron->>Cron: ログ記録、既存 DB データ維持
   else 取得成功
     Cron->>Cron: growiVersionRegExps でフィルタ
-    Cron->>DB: externalId で upsert(新規/更新
+    Cron->>DB: bulkWrite で一括 upsert(externalId キー、ordered:false
     Cron->>DB: フィードにないアイテムを削除
   end
   Note over DB: TTL インデックス(90日)で自動削除
@@ -155,7 +157,7 @@ sequenceDiagram
 | 2.1–2.4 | NewsItem モデル | NewsItem Model | MongoDB schema | フィード取得フロー |
 | 3.1–3.5 | 既読/未読管理 | NewsReadStatus Model, NewsService, News API | `POST /mark-read`, `GET /unread-count` | 既読フロー |
 | 4.1–4.2 | ロール別表示制御 | NewsService | `listForUser(userRole)` | パネル表示フロー |
-| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationSubstance | filter state props | パネル表示フロー |
+| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationForms, InAppNotificationContent, useMergedInAppNotifications | filter state props, フックの戻り値 | パネル表示フロー |
 | 6.1–6.4 | 視覚表示 | NewsItem Component | CSS classes(`fw-bold`, `bg-primary`) | — |
 | 7.1–7.2 | 未読バッジ | PrimaryItemForNotification | `useSWRxNewsUnreadCount` | — |
 | 8.1–8.4 | 多言語対応 | NewsItem Component, locales | locale fallback logic | — |
@@ -168,11 +170,13 @@ sequenceDiagram
 
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
 |---|---|---|---|---|
-| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7 | CronService (P0), NewsService (P0) |
+| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7, 9.5, 9.6 | CronService (P0), NewsService (P0), configManager (P0) |
 | NewsItem Model | Server / Data | ニュースアイテムの永続化 | 2.1–2.4 | MongoDB (P0) |
 | NewsReadStatus Model | Server / Data | ユーザー既読状態の永続化 | 3.1–3.3 | MongoDB (P0) |
 | NewsService | Server / Domain | ニュース一覧・既読管理のビジネスロジック | 3.4–3.5, 4.1–4.2 | NewsItem Model (P0), NewsReadStatus Model (P0) |
 | News API | Server / API | HTTP エンドポイント提供 | 3.1–3.5, 4.1–4.2 | NewsService (P0) |
+| News Delivery Config | Server / Config | 配信フラグ `news:isDeliveryEnabled` の登録(DB 主体、defaultValue: true) | 9.1, 9.2 | configManager (P0) |
+| App Settings UI(拡張) | Client / Admin | `/admin/app` UI から配信フラグを切り替える | 9.3, 9.4 | News Delivery Config (P0), 既存 `app-settings` API (P0) |
 
 ---
 
@@ -184,36 +188,41 @@ sequenceDiagram
 | Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 |
 
 **Responsibilities & Constraints**
-- 毎日 AM 1:00 に実行(`'0 1 * * *'`)
-- `NEWS_FEED_URL` 未設定時はスキップ(エラーなし)
+- 毎日 0 時に発火し、ランダムスリープで実取得時刻を 0–5 時に分散させる(cron 起動 `'0 0 * * *'` + `randomSleep(0–5h)`)
+- **配信フラグ判定**:cron 発火ごとに `configManager.getConfig('news:isDeliveryEnabled')` を読み、`false` ならフィード取得をスキップ(再起動不要、次回 tick から即時反映)
+- **配信元 URL はコードにハードコード**(`https://growilabs.github.io/growi-news-feed/feed.json`)。env による上書き経路は持たず、ユーザー(admin 含む)・運用者ともに変更不可
 - 取得失敗時は既存 DB データを維持
 - `growiVersionRegExps` の照合はここで実施(DB には合致アイテムのみ保存)
-- ランダムスリープ(0–5分)で複数インスタンスのリクエストを分散
+
+**配信先への分散戦略**:
+全 GROWI インスタンスが同じ時間帯にフィードへアクセスするため、CDN ミス時に origin(GitHub Pages)へ集中する thundering herd を避ける必要がある。`'0 1 * * *'` + 5 分窓では実用上の希釈が小さいため、**5 時間ウィンドウ + 60 倍希釈**(対 5 分窓比)に拡張した。GitHub Pages の月間 100GB 帯域クォータと CDN キャッシュ TTL 10 分という外部条件を踏まえ、夜間帯 0–5 時に均等分散する設計。
 
 **Dependencies**
 - Inbound: node-cron — スケジュール実行(P0)
 - Outbound: NewsService — upsert/delete(P0)
-- External: `NEWS_FEED_URL` の HTTP エンドポイント — feed.json 取得(P0)
+- External: 弊社管理の HTTP エンドポイント(コードに内蔵された URL) — feed.json 取得(P0)
 
 **Contracts**: Batch [x]
 
 ##### Batch / Job Contract
-- Trigger: `node-cron` スケジュール `'0 1 * * *'`
-- Input: `NEWS_FEED_URL` 環境変数、GROWI バージョン文字列
+- Trigger: `node-cron` スケジュール `'0 0 * * *'`(実取得は randomSleep を経て 0–5 時に分散)
+- Input: GROWI バージョン文字列(配信元 URL はコードに内蔵)
 - Output: MongoDB の NewsItem コレクションを最新フィードと同期
 - Idempotency: `externalId` ユニークインデックスにより冪等。再実行しても重複なし
 
 ##### Service Interface
 ```typescript
 class NewsCronService extends CronService {
-  getCronSchedule(): string;  // '0 1 * * *'
+  getCronSchedule(): string;  // '0 0 * * *'
   executeJob(): Promise<void>;
 }
+
+const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;  // 5 hours
 ```
 
 **Implementation Notes**
 - Integration: `server/service/cron.ts` の `CronService` を継承。`startCron()` をアプリ起動時に呼ぶ
-- Validation: `NEWS_FEED_URL` が `https://` で始まることを確認。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
+- Validation: 配信元 URL はコードにハードコードされており、ランタイムの URL 検証は不要(外部入力経路がない)。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
 - Risks: フィード取得タイムアウト(10秒推奨)。外部依存のため失敗を前提に設計する
 
 ---
@@ -313,6 +322,29 @@ interface INewsItemWithReadStatus extends INewsItem {
 - Postconditions: `listForUser` の結果は `publishedAt` 降順。各アイテムに `isRead` が付与される
 - ロールフィルタ: `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返す
 
+**`upsertNewsItems` の実装制約**:
+
+配信側(`tmp/news-feed-delivery-spec.md`)でフィードアイテム数の上限は規定されない(運用の柔軟性を優先)。受信側 NewsItem の TTL(90 日)はフィードに残り続けるアイテムの `fetchedAt` が毎回更新されるため実質発火しない。よって items 配列は理論上無制限に成長しうる。実運用想定は 5 年で ~150–250 件だが上限保証は無い。
+
+`Promise.all(items.map(NewsItem.updateMany))` での並列 fan-out は項目数増加時に DB コネクションプール圧迫・IO 飽和を招くため、**`NewsItem.bulkWrite([...], { ordered: false })` で 1 DB コマンドにバッチ化**する。`markAllRead` の `insertMany({ ordered: false })` と一貫したスタイル。
+
+```typescript
+async upsertNewsItems(items: INewsItemInput[]): Promise<void> {
+  if (items.length === 0) return;
+  const now = new Date();
+  await NewsItem.bulkWrite(
+    items.map(item => ({
+      updateOne: {
+        filter: { externalId: item.id },
+        update: { $set: { ... fetchedAt: now } },
+        upsert: true,
+      },
+    })),
+    { ordered: false },
+  );
+}
+```
+
 ---
 
 #### News API
@@ -335,13 +367,76 @@ interface INewsItemWithReadStatus extends INewsItem {
 
 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する。
 
+**Scope 設計**:
+
+GROWI の scope 階層は以下の意味論で運用する:
+
+| 階層 | 意味 | 例 |
+|---|---|---|
+| `user_settings.X` | ユーザーの **X 機能に関する設定値** の CRUD | `/personal-setting/in-app-notification-settings`(通知設定) |
+| `features.X` | **X 機能のデータ自体** へのアクセス | `/pages/list`(ページデータ), `/news/list`(ニュースデータ) |
+
+通知データ取得は機能データへのアクセスに該当するため `features.in_app_notification` を新設し、News API 4 エンドポイントを移行する。あわせて既存 `/in-app-notification/*` の 4 エンドポイント(`list` / `status` / `open` / `all-statuses-open`)も同スコープへ移行(既存は `user_settings.in_app_notification` を誤用していた)。`/personal-setting/in-app-notification-settings` GET/POST は通知設定 CRUD なので `user_settings.in_app_notification` のまま維持する。
+
+| Method | Endpoint | Scope |
+|---|---|---|
+| GET | `/apiv3/news/list` | `read:features:in_app_notification` |
+| GET | `/apiv3/news/unread-count` | `read:features:in_app_notification` |
+| POST | `/apiv3/news/mark-read` | `write:features:in_app_notification` |
+| POST | `/apiv3/news/mark-all-read` | `write:features:in_app_notification` |
+
+`@growi/core` の `SCOPE_SEED_USER.features.in_app_notification` 追加と、`accesstoken_scopes_desc` i18n(`en_US` / `ja_JP` / `zh_CN` / `fr_FR`)の更新が必要。
+
 **Implementation Notes**
-- Integration: `apps/app/src/server/routes/apiv3/news.ts` に新規作成
+- Integration: `apps/app/src/features/news/server/routes/news.ts` に新規作成。`createNewsRouter(crowi?: Crowi)` をエクスポートし、optional `Crowi` で受けてテスト時にミドルウェアを pass-through できる構造(型アサーションは使わない)
 - Validation: `newsItemId` は `mongoose.isValidObjectId()` で検証
 - Risks: ロールフィルタはサーバーサイドで強制。クライアントから `targetRoles` を受け取らない
 
 ---
 
+#### News Delivery Config
+
+| Field | Detail |
+|---|---|
+| Intent | `news:isDeliveryEnabled` を configManager に登録し、cron/API/UI から共通で参照できるようにする |
+| Requirements | 9.1, 9.2 |
+
+**Responsibilities & Constraints**
+- `apps/app/src/server/service/config-manager/config-definition.ts` に CONFIG_KEYS と `defineConfig` の 2 箇所を追加
+- `defineConfig` パターンを踏襲しつつ、**`envVarName` を意図的に持たせない**(`defaultValue: true` のみ)。これにより env からの上書きを禁じ、admin UI 経由の DB 操作のみが ON/OFF を変えられる経路となる
+- `defaultValue: true` をコードに内蔵 → DB に値が無い状態で全顧客が ON
+- 値の優先順は configManager の既存仕様(DB > env > defaultValue)に従う
+
+**Dependencies**
+- Inbound: NewsCronService, App Settings UI
+- Outbound: configManager(既存)
+
+**Implementation Notes**
+- env 変数として一切暴露しないため `/admin` 環境変数一覧には決して現れない(DB 単独運用)
+- 開発時に強制的に値を変更したい場合は、ローカルで DB レコードを直接書き換えるか、コードを一時編集する
+- 設定変更時は configManager の `updateConfigs` がメモリキャッシュ更新と pubsub 通知(multi-pod 反映)を行う
+
+---
+
+#### App Settings UI(拡張)
+
+| Field | Detail |
+|---|---|
+| Intent | `/admin/app` 画面に「ニュース配信」ON/OFF トグルを追加する |
+| Requirements | 9.3, 9.4 |
+
+**Responsibilities & Constraints**
+- 既存 `app-settings` 画面・API(`PUT /apiv3/app-setting`)に項目を追加するパターンを踏襲
+- 認可:`accessTokenParser([SCOPE.WRITE.ADMIN.APP])` + `adminRequired` で admin のみに制限
+- トグル値の永続化先:`configManager.updateConfigs({ 'news:isDeliveryEnabled': boolean })`
+- UI 文言は i18n 対応(`ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`)
+
+**Dependencies**
+- Inbound: 管理画面(admin user)
+- Outbound: News Delivery Config(configManager 経由)
+
+---
+
 ### クライアントサイド
 
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
@@ -349,9 +444,11 @@ interface INewsItemWithReadStatus extends INewsItem {
 | useSWRINFxNews | Client / Hooks | ニュースアイテムの無限スクロール取得 | 5.4 | News API (P0) |
 | useSWRxNewsUnreadCount | Client / Hooks | ニュース未読カウント取得 | 7.1 | News API (P0) |
 | useSWRINFxInAppNotifications | Client / Hooks | 通知の無限スクロール取得(既存 hook を拡張) | 5.4 | InAppNotification API (P0) |
+| useMergedInAppNotifications | Client / Hooks | パネルのデータ層(2 SWR + 終端判定 + 合成 response + マージ + 既読 mutation handlers) | 5.1–5.5 | useSWRINFxNews (P0), useSWRINFxInAppNotifications (P0) |
 | InAppNotification.tsx(変更) | Client / UI | フィルタ state を追加管理 | 5.2, 5.3 | useState (P0) |
-| InAppNotificationSubstance.tsx(変更) | Client / UI | フィルタタブ + InfiniteScroll | 5.1–5.5 | useSWRINFxNews (P0), InfiniteScroll (P0) |
-| NewsItem Component | Client / UI | ニュースアイテム1件の表示 | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
+| InAppNotificationForms.tsx(新設) | Client / UI | フィルタタブ + 未読トグル UI | 5.2, 5.3 | — |
+| InAppNotificationContent.tsx(新設) | Client / UI | 3 分岐レンダラー(all/news/notifications) + InfiniteScroll | 5.1, 5.4, 5.5 | useMergedInAppNotifications (P0), InfiniteScroll (P0) |
+| NewsItem Component | Client / UI | ニュースアイテム1件の表示(`React.memo` で wrap) | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
 | PrimaryItemForNotification(変更) | Client / UI | 未読バッジに NewsItem の未読数を合算 | 7.1, 7.2 | useSWRxNewsUnreadCount (P0) |
 
 ---
@@ -399,27 +496,87 @@ type FilterType = 'all' | 'news' | 'notifications';
 
 ---
 
-#### InAppNotificationSubstance.tsx(変更)
+#### InAppNotificationElm.tsx(既存・修正あり)
+
+**実装後に判明した落とし穴**: 未読ドットに使われていた CSS クラス `grw-unopend-notification` はコードベースに定義が存在せず、ドットが不可視だった。`bg-primary rounded-circle` + インラインスタイル(`width/height: 8px, display: inline-block`)に置き換えて修正済み。このコンポーネントを今後変更する場合、同クラスを再導入しないこと。
+
+---
+
+#### Panel modules: Forms + Content + data hook
 
 | Field | Detail |
 |---|---|
-| Intent | フィルタタブ UI の追加と、InfiniteScroll を用いた統合リスト表示 |
+| Intent | フィルタタブ UI とリスト描画を独立に保ち、データ層はカスタムフックに集約する |
 | Requirements | 5.1, 5.2, 5.3, 5.4, 5.5 |
 
 **Contracts**: State [x]
 
-**InAppNotificationForms への追加**:
+**ファイル構成**:
+
+```
+client/components/Sidebar/InAppNotification/
+├── InAppNotification.tsx                  (フィルタ state を管理し props で配布)
+├── InAppNotificationForms.tsx             (Forms UI のみ)
+├── InAppNotificationContent.tsx           (3 分岐レンダラー)
+└── hooks/
+    └── useMergedInAppNotifications.ts     (データ層)
+```
+
+**設計原則**:
+- **データ層と表示層を分離する**。`useMergedInAppNotifications` フックがニュース・通知の両 `useSWRInfinite` 呼び出し、ページ終端判定、合成 SWRInfiniteResponse の構築、マージ、既読 mutation handlers を一手に引き受ける。これにより `InAppNotificationContent` はフックの戻り値を受け取って `activeFilter` で 3 分岐するだけの薄い renderer になる
+- **Forms はプレゼンテーションのみ**。データ層に触れない
+- 単一ファイルで 7 責務(スクロール戦略・SWR 2 本・終端判定・合成 response・マージ・mutation 2 種・3 分岐 render)を抱えていた v1 の `InAppNotificationSubstance.tsx`(339 行)は廃止し、上記 3 モジュールに分割する
+
+**InAppNotificationForms**:
 - フィルタボタン(「すべて」「通知」「お知らせ」)を Bootstrap `btn-group` で実装
-- 既存「未読のみ」トグルは維持
-
-**InAppNotificationContent の変更**:
-- `activeFilter` に応じて3パターンに分岐
-  - `'all'`: `useSWRINFxNews` + `useSWRINFxInAppNotifications` の結果を `publishedAt/createdAt` 降順でマージ
-  - `'news'`: `useSWRINFxNews` のみ。`NewsList` に渡す
-  - `'notifications'`: `useSWRINFxInAppNotifications` のみ。既存 `InAppNotificationList` に渡す
-- 既存 `InfiniteScroll` コンポーネントを使用(`client/components/InfiniteScroll.tsx`)
+- 既存「未読のみ」トグルを維持
+
+**InAppNotificationContent (3 分岐)**:
+- `'all'`: `useMergedInAppNotifications.allModeSWRResponse` + `mergedItems` を `InfiniteScroll` に渡し、両ストリームをマージ表示
+- `'news'`: `newsResponse` + `allNewsItems` のみ
+- `'notifications'`: `notificationResponse` + `allNotificationItems` のみ
+- 既存 `InfiniteScroll` コンポーネント(`client/components/InfiniteScroll.tsx`)を再利用
 - 既存 `// TODO: Infinite scroll implemented` コメントを解消
 
+**useMergedInAppNotifications フック**:
+
+戻り値:
+```typescript
+{
+  newsResponse, allNewsItems, newsExhausted,
+  notificationResponse, allNotificationItems, notifExhausted,
+  allModeSWRResponse, mergedItems,
+  handleReadMutate, handleNotificationRead,
+}
+```
+
+- `'all'` モード用の合成 `SWRInfiniteResponse`: `setSize` は終端に達していないストリームをインクリメント(両方未終端なら両方)、`isValidating` はいずれかが true なら true、両ストリーム終端時に `isReachingEnd = true`
+- `mergedItems` は両ストリームの `flatMap → publishedAt/createdAt 降順 sort`
+- ハンドラは `useCallback` で参照を安定化する。SWR の `mutate` は cache key 単位で stable なので、`{ mutate: mutateNews } = newsResponse` のように destructure して deps に含める(biome のルール対応)
+
+**サイドバーモード別スクロール戦略**:
+
+サイドバーには2種類のモードがあり、スクロール担当コンテナが異なる。
+
+| モード | UI | スクロール担当 | コンテンツエリアの制約 |
+|---|---|---|---|
+| collapsed(ホバーパネル ①) | ベルアイコンにホバー時の小パネル | `InAppNotificationContent` 内の `overflow-auto` div | `maxHeight: 60vh` で高さを制限 |
+| dock / drawer(全面サイドバー ②) | 展開した全面パネル | 外側の `SimpleBar`(`h-100`) | 制約なし。コンテンツが自然に伸長 |
+
+collapsed モードで `overflow-auto + maxHeight` を使い、dock/drawer モードでは外していない場合、**二重スクロールコンテナ**が発生する。具体的には:
+- `overflow-auto` div がサイドバーと同高の scroll context を作る
+- スクロールバーがコンテンツ高さとほぼ同じ縦幅で出現し、わずかな余白でしか動かせなくなる(振動挙動)
+
+対策として `InAppNotificationContent` 内で `useSidebarMode()` を呼び、`isCollapsedMode()` が true のときのみ `overflow-auto` クラスと `maxHeight: 60vh` を付与する。dock/drawer モードでは div に何も付与せず、SimpleBar にスクロールを委ねる。
+
+**通知ドット即時消去: SWR mutate による楽観的更新**:
+
+`InAppNotificationElm` はクリック時に `apiv3Post('/in-app-notification/open')` でサーバーへ書き込みを行うが、UI への反映は SWR キャッシュの即時書き換えで行う。`useMergedInAppNotifications.handleNotificationRead` 内で `mutateNotifications(updater, { revalidate: false })` を用い、`useSWRInfinite` のページごとに該当 `doc.status` を `STATUS_OPENED` へ書き換える。
+
+`useSWRInfinite` のキャッシュは `SWRConfig` プロバイダの Map に保持されるため、同一 React tree のアンマウント/リマウントを跨いで状態が維持され、リマウント後もドットは消えたままとなる。ローカル `useState` を持たずに SWR の標準機能のみで完結させることで、キャッシュ・再検証制御・キー共有といった SWR の利点をそのまま活かせる。
+
+品質改善の経緯: PR #10986 のレビュー FB を受け、当初採用した `useState<Set<string>>` 戦略を SWR `mutate` + `revalidate: false` に差し替えた。さらに PR #11050 で Substance 単一ファイル構造を Forms / Content / data hook の 3 モジュールに分割(凝集度向上)し、ハンドラを `useCallback` 化した。
+
 ---
 
 #### NewsItem Component
@@ -431,11 +588,16 @@ type FilterType = 'all' | 'news' | 'notifications';
 
 **Implementation Notes**
 - 配置: `features/news/client/components/NewsItem.tsx`
-- ロケールフォールバック: `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
-- 未読: `fw-bold` + 左端に `bg-primary` 8px 丸ドット
-- 既読: `fw-normal` + 同幅の透明スペーサー
-- `emoji` 未設定時は `📢` をフォールバック
+- **レイアウト**: 既存の `InAppNotificationElm` と同一カラム構成に揃える
+  - 左端: 未読ドット(`bg-primary` 8px 丸)または同幅の透明スペーサー
+  - アバター位置: `emoji` を表示(`UserPicture` が占める位置と同等)。未設定時は `📢` をフォールバック
+  - コンテンツ列: タイトル(未読時 `fw-bold`、既読時 `fw-normal`)+ 公開日時
+- ロケールフォールバック: `i18n.language → ja_JP → en_US → 最初に利用可能なキー`(`useTranslation()` から取得)
+- 日付フォーマット: `date-fns` の `format` と `getLocale(i18n.language)` を用い、`ActivityListItem` と同じロケールパターンに統一
+- Bootstrap クラス: `w-100 text-start bg-transparent fs-5 lh-1` などを利用し、インラインスタイルを最小化
+- 未読ドット: `InAppNotificationElm` と共有の `UnreadDot.module.scss` を使用し、両者の見た目を完全に揃える
 - クリック時: `POST /mark-read` + SWR mutate + `url` があれば新タブで開く
+- **再レンダ最適化**: `export const NewsItem = memo(NewsItemInner)` で `React.memo` ラップ。親 `InAppNotificationContent` の再レンダ時、SWR が同一参照を返している `item` props と `useCallback` で参照安定化された `onReadMutate` props により、変化のないアイテムは再レンダされない。`<InAppNotificationElm>`(legacy 経路 `InAppNotificationDropdown` / `InAppNotificationPage` から共有される)の memo 化は本機能のスコープ外として将来 PR で対応
 
 ---
 
@@ -522,13 +684,7 @@ interface INewsItemWithReadStatus {
   isRead: boolean;
 }
 
-interface PaginateResult<T> {
-  docs: T[];
-  totalDocs: number;
-  limit: number;
-  offset: number;
-  hasNextPage: boolean;
-}
+// PaginateResult<T> は ~/interfaces/in-app-notification の既存型を再利用する(再定義不要)
 ```
 
 ---
@@ -544,7 +700,7 @@ interface PaginateResult<T> {
 | カテゴリ | エラー | 対応 |
 |---|---|---|
 | Cron / External | フィード取得失敗(ネットワーク、タイムアウト) | `logger.error` + 既存 DB データ維持。次回 cron で再試行 |
-| Cron / Config | `NEWS_FEED_URL` 未設定 | スキップ(ログなし)。設定されるまで無害に動作 |
+| Cron / Config | `news:isDeliveryEnabled` が `false` | スキップ(debug ログ)。admin が再度 ON にするまで無害に停止 |
 | Cron / Validation | `growiVersionRegExps` に不正 regex | try-catch で該当アイテムをスキップ、`logger.warn` |
 | API / Auth | 未認証リクエスト | 401(`loginRequiredStrictly` が処理) |
 | API / Validation | 不正な `newsItemId` フォーマット | 400(`mongoose.isValidObjectId()` チェック) |
@@ -561,7 +717,7 @@ interface PaginateResult<T> {
 
 ### Unit Tests
 
-- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`NEWS_FEED_URL` 未設定 → スキップ
+- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`news:isDeliveryEnabled` が `false` → スキップ
 - `NewsCronService.executeJob()`: `growiVersionRegExps` 一致 → 保存、不一致 → 除外
 - `NewsService.listForUser()`: `targetRoles` フィルタ(admin のみ、general 除外)
 - `NewsService.listForUser()`: `onlyUnread=true` で未読のみ返す
@@ -586,13 +742,31 @@ interface PaginateResult<T> {
 ## Security Considerations
 
 - すべての `/apiv3/news/*` エンドポイントに `loginRequiredStrictly` を適用する
+- アクセストークン用 scope は **`features.in_app_notification`** を使用する(read / write)。設定 CRUD 用の `user_settings.in_app_notification` とはセマンティクスが異なるため流用しない。アクセストークン発行時にユーザーが意図した粒度でアクセスを許可できるようにする
 - `conditions.targetRoles` のフィルタリングはサーバーサイドの `NewsService.listForUser()` で強制する。クライアントから `targetRoles` パラメータを受け付けない
-- `NEWS_FEED_URL` は `https://` のみ許可(HTTP 不可)
+- 配信元 URL はコードにハードコードされており、ランタイムで変更できる経路を持たない。env 変数による上書きもサポートしない
 - フィードから取得したデータはそのまま DB に保存し、クライアントへのレスポンス時に Mongoose スキーマで型安全に扱う
 
 ## Performance & Scalability
 
+**データ量とインデックス**:
 - NewsItem は全ユーザーで1件共有のため、ユーザー数に比例してドキュメントが増えない
 - `publishedAt` インデックスにより降順ソートが効率的
-- `fetchedAt` TTL インデックス(90日)で古いデータを自動削除し、コレクションサイズを制限
+- `fetchedAt` TTL インデックス(90日)は **フィードから外れたアイテムにのみ実質発火** する(フィードに残り続けるアイテムは毎回 `fetchedAt` が更新されるため発火しない)。よってコレクションサイズの上限は配信側のキュレーションに依存する
 - `NewsReadStatus` の compound unique index により `listForUser` の LEFT JOIN 相当クエリが効率的
+
+**フィードアイテム規模の前提**:
+- 配信側スキーマ(`tmp/news-feed-delivery-spec.md`)でフィードアイテム数の上限規定は設けない(運用の柔軟性を優先)
+- 想定ペース: release 12–24 件/年、security/tips/maintenance/announcement 合わせて 30–50 件/年
+- 5 年運用で **150–250 件程度** の見込み。ただし上限保証はないため、実装は無制限成長に耐える形で設計する
+
+**書き込み戦略**:
+- `NewsCronService.executeJob()` 内の upsert は `NewsItem.bulkWrite([...], { ordered: false })` で 1 DB コマンドにバッチ化。`Promise.all(items.map(updateMany))` の並列 fan-out は項目数増加時に DB コネクションプール圧迫・IO 飽和を招くため採用しない
+
+**配信先への分散**:
+- cron を `'0 0 * * *'` + `randomSleep(0–5 時間)` に設定し、複数 GROWI インスタンスのリクエストを夜間 5 時間ウィンドウに均等分散する
+- `'0 1 * * *'` + 5 分窓と比較して **約 60 倍の希釈**。GitHub Pages の月間 100GB 帯域クォータ・10 分 CDN キャッシュ TTL に対して thundering herd を回避できる
+- 即時性は不要(日次配信)であり、5 時間ウィンドウは UX への影響なし
+
+**フロントエンド再レンダ**:
+- `<NewsItem>` は `React.memo` ラップ。`useMergedInAppNotifications` のハンドラ群は `useCallback` で参照安定化されており、SWR が返す `item` 参照と組み合わせて、変化のないリスト項目は再レンダをスキップする

+ 19 - 3
.kiro/specs/news-inappnotification/requirements.md

@@ -14,12 +14,12 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 
 #### Acceptance Criteria
 
-1. When cron スケジュールの実行時刻に達した場合, the News Cron Service shall 設定された URL から JSON フィードを HTTP GET で取得する
+1. When cron スケジュールの実行時刻に達した場合, the News Cron Service shall コードに内蔵された配信元 URL から JSON フィードを HTTP GET で取得する
 2. When フィードの取得に成功した場合, the News Cron Service shall 取得したニュースアイテムをローカル MongoDB に upsert(`externalId` で重複排除)する
 3. When フィードに含まれなくなったニュースアイテムがある場合, the News Cron Service shall 該当アイテムをローカル DB から削除する
 4. When 複数の GROWI インスタンスが同時に取得を試みる場合, the News Cron Service shall ランダムスリープにより配信元へのリクエストを時間分散する
 5. If フィードの取得に失敗した場合, then the News Cron Service shall エラーをログに記録し、既存のキャッシュデータを維持する
-6. Where `NEWS_FEED_URL` が未設定または空の場合, the News Cron Service shall フィード取得をスキップしエラーなく動作する
+6. Where ニュース配信が無効化されている場合(`news:isDeliveryEnabled` が `false` の場合), the News Cron Service shall フィード取得をスキップしエラーなく動作する
 7. When ニュースアイテムに `growiVersionRegExps` 条件が設定されている場合, the News Cron Service shall 現在の GROWI バージョンと照合し、一致しないアイテムを除外する
 
 ### Requirement 2: ニュースアイテムのローカルキャッシュ
@@ -71,7 +71,7 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 1. The InAppNotificationパネル shall 通知とニュースを公開日時/作成日時の降順で混合した1つのリストとして表示する
 2. The InAppNotificationパネル shall 上部にフィルタボタン(「すべて」「通知」「お知らせ」)を配置し、デフォルトは「すべて」とする。「お知らせ」選択時はニュースのみ、「通知」選択時はニュース以外のすべての通知を表示する
 3. The InAppNotificationパネル shall 既存の「未読のみ」トグルスイッチを維持し、種別フィルタと組み合わせた2重フィルタリングを提供する。種別フィルタ(すべて/通知/お知らせ)で表示対象を絞り込んだ上で、トグルON時は未読アイテムのみをさらに絞り込む
-4. The InAppNotificationパネル shall リスト領域に最大高さを設定し、超過分はスクロールで表示する。スクロールが末端に達した場合は次のページを自動で読み込む無限スクロールとする
+4. The InAppNotificationパネル shall リスト領域のスクロールを提供し、末端に達した場合は次のページを自動で読み込む無限スクロールとする。スクロールの実現方法はサイドバーモードに依存する:collapsed モード(ホバーパネル)では最大高さ(`60vh`)を設定した内部スクロールコンテナを使用し、dock/drawer モード(全面サイドバー)では外側の SimpleBar コンテナにスクロールを委ねることで二重スクロールコンテナを回避する
 5. The InAppNotificationパネル shall ニュースアイテムの `emoji` フィールドをタイトル前に表示する。`emoji` 未設定の場合は 📢 をフォールバックとして使用する
 6. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall ニュースの詳細 URL を新しいタブで開く
 7. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall 該当ニュースを既読としてマークし、未読インジケータを更新する
@@ -106,3 +106,19 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 2. If ブラウザの言語に対応するテキストが存在しない場合, then the NewsItem コンポーネント shall `ja_JP` → `en_US` の順にフォールバックする
 3. The UI ラベル(「ニュース」「ニュースはありません。」等)shall `ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR` の i18n ロケールファイルで提供する
 4. The フィルタボタン用ラベル(「通知」「お知らせ」)shall 全対応言語のロケールファイルに追加する
+
+### Requirement 9: ニュース配信のオンオフ切替
+
+**Objective:** As a GROWI 管理者, I want ニュース配信のオンオフを管理画面から切り替えたい, so that 環境変数の編集や再起動なしにインスタンス単位で配信を停止/再開できる
+
+**Note:** 配信フラグは DB(`Config` コレクション)で管理し、admin が `/admin/app` UI から操作する。configManager + `defineConfig` + `defaultValue` の既存パターンを踏襲するが、**env からの上書き経路は意図的に持たない**(`envVarName` を設定しない)。これにより「インフラ側の env 注入」を不要にし、ニュース配信の意思は **DB のみ**で表現される。`defaultValue: true` をコードに内蔵することで、新規・既存インスタンスとも DB に値が無い状態で**デフォルト ON**が成立する。配信元 URL もコードにハードコードされており、ユーザー(admin 含む)・運用者ともに変更できない。pod 再起動は不要。
+
+#### Acceptance Criteria
+
+1. The configuration `news:isDeliveryEnabled` shall `defaultValue: true` を持ち、DB に値が無い場合は ON として扱われる
+2. The 設定値 shall configManager 経由で読み出される。env からの上書きは意図的にサポートしない(`envVarName` を設定しない)ため、優先順位は **DB > defaultValue** のみとなる
+3. When 管理者が `/admin/app` の UI からトグルを切り替えた場合, the GROWI shall `Config` コレクションの該当キーを更新し、再起動なしで設定値を反映する
+4. The 切替操作 shall admin 権限を持つユーザーのみに許可される
+5. When `news:isDeliveryEnabled` が `false` の場合, the News Cron Service shall 次回 cron 発火時にフィード取得をスキップする(既に取得済みの DB キャッシュは維持する)
+6. When `news:isDeliveryEnabled` が `true` に戻された場合, the News Cron Service shall 次回 cron 発火時に通常どおりフィード取得を再開する
+7. The 設定値 shall 環境変数として暴露されないため、`/admin` トップの「サーバー側で設定されている環境変数一覧」には決して現れない

+ 2 - 2
.kiro/specs/news-inappnotification/spec.json

@@ -15,8 +15,8 @@
     },
     "tasks": {
       "generated": true,
-      "approved": false
+      "approved": true
     }
   },
-  "ready_for_implementation": false
+  "ready_for_implementation": true
 }

+ 71 - 34
.kiro/specs/news-inappnotification/tasks.md

@@ -1,150 +1,187 @@
 # Implementation Plan
 
-- [ ] 0. 動作確認用ローカルフィードサーバーをセットアップする
+- [x] 0. 動作確認用ローカルフィードサーバーをセットアップする
   - `/tmp/feed.json` にサンプルフィードファイルを作成する。`emoji` あり・なし(未設定時は 📢 フォールバック確認)、`title`/`body` の多言語フィールド(`ja_JP`, `en_US`)、`url` あり・なし、`conditions.targetRoles`(admin のみ、全ユーザー)の両パターンを含む複数アイテムで構成する
   - devcontainer 内で `cd /tmp && python3 -m http.server 8099` を起動し、`http://localhost:8099/feed.json` でアクセスできることを確認する
   - `.env` に `NEWS_FEED_URL=http://localhost:8099/feed.json` を追加する
   - 以降のタスクで cron 動作確認が必要な場合はこのサーバーを使用する
   - _Requirements: 1.1, 1.6_
 
-- [ ] 1. データモデルを実装する
-- [ ] 1.1 (P) NewsItem モデルを実装する
+- [x] 1. データモデルを実装する
+- [x] 1.1 (P) NewsItem モデルを実装する
   - `externalId`(ユニークインデックス)、多言語 `title`/`body`(Map of String)、`emoji`、`url`、`publishedAt`(インデックス)、`fetchedAt`(TTL 90日インデックス)、`conditions.targetRoles` を持つ Mongoose スキーマを定義する
   - 型インターフェース `INewsItem` と `INewsItemHasId` を定義する
   - _Requirements: 2.1, 2.2, 2.3, 2.4_
 
-- [ ] 1.2 (P) NewsReadStatus モデルを実装する
+- [x] 1.2 (P) NewsReadStatus モデルを実装する
   - `userId`・`newsItemId` の複合ユニークインデックス、`readAt` を持つ Mongoose スキーマを定義する
   - 型インターフェース `INewsReadStatus` を定義する
   - _Requirements: 3.3_
 
-- [ ] 2. ニュースサービス層を実装する
-- [ ] 2.1 ニュース一覧取得ロジックを実装する
+- [x] 2. ニュースサービス層を実装する
+- [x] 2.1 ニュース一覧取得ロジックを実装する
   - `listForUser(userId, userRoles, { limit, offset, onlyUnread })` を実装する
   - `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返すロール別フィルタを適用する
   - NewsReadStatus との突き合わせにより各アイテムに `isRead: boolean` を付与する
   - 結果は `publishedAt` 降順で返す
   - _Requirements: 3.4, 4.1, 4.2_
 
-- [ ] 2.2 既読管理ロジックを実装する
+- [x] 2.2 既読管理ロジックを実装する
   - `markRead(userId, newsItemId)` を実装する。NewsReadStatus を upsert することで冪等性を保証する
   - `markAllRead(userId, userRoles)` を実装する。ロール別フィルタに合致する全未読アイテムを一括既読にする
   - `getUnreadCount(userId, userRoles)` を実装する
   - _Requirements: 3.1, 3.2, 3.5_
 
-- [ ] 2.3 フィード同期ロジックを実装する
+- [x] 2.3 フィード同期ロジックを実装する
   - `upsertNewsItems(items)` を実装する。`externalId` をキーに upsert し、`fetchedAt` を更新する
   - `deleteNewsItemsByExternalIds(externalIds)` を実装する
   - _Requirements: 1.2, 1.3_
 
-- [ ] 3. News API エンドポイントを実装する
-- [ ] 3.1 (P) ニュース取得エンドポイントを実装する
+- [x] 3. News API エンドポイントを実装する
+- [x] 3.1 (P) ニュース取得エンドポイントを実装する
   - `GET /apiv3/news/list`(`limit`, `offset`, `onlyUnread` クエリパラメータ)を実装する
   - `GET /apiv3/news/unread-count` を実装する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - _Requirements: 3.4, 3.5, 4.1, 4.2_
 
-- [ ] 3.2 (P) ニュース既読操作エンドポイントを実装する
+- [x] 3.2 (P) ニュース既読操作エンドポイントを実装する
   - `POST /apiv3/news/mark-read`(`newsItemId` を受け取る)を実装する。`newsItemId` を `mongoose.isValidObjectId()` で検証する
   - `POST /apiv3/news/mark-all-read` を実装する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - _Requirements: 3.1, 3.2_
 
-- [ ] 3.3 News API ルートをアプリに登録する
+- [x] 3.3 News API ルートをアプリに登録する
   - Express アプリの apiv3 ルーター定義に `news.ts` を追加する
   - _Requirements: 3.1, 3.4_
 
-- [ ] 4. NewsCronService を実装する
-- [ ] 4.1 (P) フィード取得・DB 同期処理を実装する
+- [x] 4. NewsCronService を実装する
+- [x] 4.1 (P) フィード取得・DB 同期処理を実装する
   - `CronService` を継承し `getCronSchedule()` で `'0 1 * * *'` を返す
   - `executeJob()` を実装する:`NEWS_FEED_URL` 未設定時はスキップ、HTTP GET、取得失敗時はログ記録のみ(既存データ維持)
   - 取得した各アイテムの `growiVersionRegExps` と現バージョンを照合し、不一致アイテムを除外する。不正 regex は try-catch でスキップしてログ警告する
   - フィード外のアイテムを DB から削除し、ランダムスリープ(0–5分)でリクエストを分散する
   - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
 
-- [ ] 4.2 cron をアプリ起動時に登録する
+- [x] 4.2 cron をアプリ起動時に登録する
   - アプリの初期化処理で `NewsCronService.startCron()` を呼ぶ
   - _Requirements: 1.1_
 
-- [ ] 5. フロントエンド SWR フックを実装する
-- [ ] 5.1 (P) ニュース用 SWR フックを新設する
+- [x] 5. フロントエンド SWR フックを実装する
+- [x] 5.1 (P) ニュース用 SWR フックを新設する
   - `useSWRINFxNews(limit, options)` を `useSWRInfinite` ベースで実装する。キーに `limit`, `pageIndex`, `onlyUnread` を含める
   - `useSWRxNewsUnreadCount()` を実装する
   - _Requirements: 5.4, 7.1_
 
-- [ ] 5.2 (P) InAppNotification 用の無限スクロール対応フックを追加する
+- [x] 5.2 (P) InAppNotification 用の無限スクロール対応フックを追加する
   - 既存 `useSWRxInAppNotifications`(`useSWR` ベース)に加えて `useSWRINFxInAppNotifications(limit, options)` を `useSWRInfinite` ベースで新設する
   - 既存フックは `InAppNotificationPage.tsx` での利用のため維持する
   - _Requirements: 5.4_
 
-- [ ] 6. InAppNotification パネルを改修する
-- [ ] 6.1 フィルタタブを追加する
+- [x] 6. InAppNotification パネルを改修する
+- [x] 6.1 フィルタタブを追加する
   - `InAppNotification.tsx` に `activeFilter: 'all' | 'news' | 'notifications'` の state(デフォルト `'all'`)を追加し、`InAppNotificationForms` と `InAppNotificationContent` へ prop として渡す
   - `InAppNotificationForms` に Bootstrap `btn-group` でフィルタボタン(「すべて」「通知」「お知らせ」)を追加する。既存「未読のみ」トグルは維持する
   - _Requirements: 5.2, 5.3_
 
-- [ ] 6.2 無限スクロールを導入する
+- [x] 6.2 無限スクロールを導入する
   - `InAppNotificationContent` で `useSWRINFxNews` と `useSWRINFxInAppNotifications` を使用するよう変更する
   - 既存の `InfiniteScroll` コンポーネントをラップしてリストを表示する
   - 既存の `// TODO: Infinite scroll implemented` コメントを解消する
   - _Requirements: 5.4_
 
-- [ ] 6.3 「すべて」フィルタ時のクライアントサイドマージを実装する
+- [x] 6.3 「すべて」フィルタ時のクライアントサイドマージを実装する
   - `activeFilter === 'all'` の場合、通知(`createdAt`)とニュース(`publishedAt`)を日時降順でマージして表示する
   - `activeFilter === 'news'` の場合は NewsItem のみ、`activeFilter === 'notifications'` の場合は InAppNotification のみ表示する
   - _Requirements: 5.1, 5.2_
 
-- [ ] 7. NewsItem コンポーネントを実装する
-- [ ] 7.1 (P) ニュースアイテムの表示コンポーネントを実装する
+- [x] 7. NewsItem コンポーネントを実装する
+- [x] 7.1 (P) ニュースアイテムの表示コンポーネントを実装する
   - `emoji` フィールドをタイトル前に表示する。未設定時は 📢 をフォールバックとする
   - 多言語タイトルをブラウザ言語で解決する。フォールバック順は `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
   - 未読時はタイトルを `fw-bold` + 左端に `bg-primary` 8px 丸ドット、既読時は `fw-normal` + 同幅の透明スペーサーで表示する
   - _Requirements: 5.5, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
 
-- [ ] 7.2 (P) ニュースアイテムのクリック処理を実装する
+- [x] 7.2 (P) ニュースアイテムのクリック処理を実装する
   - クリック時に `POST /apiv3/news/mark-read` を呼び、SWR キャッシュを mutate して未読インジケータを更新する
   - `url` が設定されている場合は新しいタブで開く
   - _Requirements: 5.6, 5.7_
 
-- [ ] 8. (P) 未読バッジにニュース未読数を合算する
+- [x] 8. (P) 未読バッジにニュース未読数を合算する
   - `PrimaryItemForNotification` で `useSWRxNewsUnreadCount` を呼び、既存の InAppNotification 未読カウントと合算してバッジに表示する
   - 全ニュースが既読の場合はニュース分のカウントを含めない
   - _Requirements: 7.1, 7.2_
 
-- [ ] 9. (P) i18n ロケールファイルを更新する
+- [x] 9. (P) i18n ロケールファイルを更新する
   - `commons.json` の `in_app_notification` 名前空間に以下のキーを全ロケール(`ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`)に追加する:`news`(お知らせ)、`notifications`(通知)、`all`(すべて)、`no_news`(ニュースはありません)
   - _Requirements: 8.3, 8.4_
 
-- [ ] 10. サーバーサイドテストを実装する
-- [ ] 10.1 NewsCronService のテストを実装する
+- [x] 10. サーバーサイドテストを実装する
+- [x] 10.1 NewsCronService のテストを実装する
   - `executeJob()` が正常取得時に upsert・削除を行うことを確認する
   - `NEWS_FEED_URL` 未設定時にスキップすることを確認する
   - フィード取得失敗時に DB データが変更されないことを確認する
   - `growiVersionRegExps` の一致・不一致・不正 regex の各ケースをテストする
   - _Requirements: 1.1, 1.2, 1.3, 1.5, 1.6, 1.7_
 
-- [ ] 10.2 NewsService のテストを実装する
+- [x] 10.2 NewsService のテストを実装する
   - `listForUser()` がロール別フィルタを正しく適用し `isRead` を付与することを確認する
   - `onlyUnread=true` で未読のみ返ることを確認する
   - `markRead()` の冪等性(2回呼んでもエラーなし)を確認する
   - `getUnreadCount()` が `markAllRead()` 後に 0 を返すことを確認する
   - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1, 4.2_
 
-- [ ] 10.3 News API 統合テストを実装する
+- [x] 10.3 News API 統合テストを実装する
   - `GET /apiv3/news/list` がロール別フィルタを強制することを確認する
   - `POST /apiv3/news/mark-read` が冪等であることを確認する
   - 未認証リクエストが 401 を返すことを確認する
   - _Requirements: 3.1, 3.4, 4.1_
 
-- [ ] 11. フロントエンドテストを実装する
-- [ ] 11.1 NewsItem コンポーネントのテストを実装する
+- [x] 11. フロントエンドテストを実装する
+- [x] 11.1 NewsItem コンポーネントのテストを実装する
   - `emoji` 未設定時に 📢 が表示されることをテストする
   - タイトルのロケールフォールバック(`browserLocale → ja_JP → en_US`)をテストする
   - 未読・既読の視覚表示(`fw-bold`、青ドット、スペーサー)をテストする
   - クリック時に `mark-read` が呼ばれ、`url` がある場合に新タブで開くことをテストする
   - _Requirements: 5.5, 5.6, 5.7, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
 
-- [ ]* 11.2 InAppNotification パネルのフィルタ動作をテストする
+- [x]* 11.2 InAppNotification パネルのフィルタ動作をテストする
   - フィルタタブ切り替えで表示対象が変わることを確認する(5.2 の AC カバレッジ)
   - 「未読のみ」トグルとの組み合わせで2重フィルタリングが機能することを確認する(5.3 の AC カバレッジ)
   - _Requirements: 5.2, 5.3_
+
+- [x] 12. 既存コードの不具合修正(実装後検証で発覚)
+- [x] 12.1 既存通知の未読ドットを修正する
+  - `InAppNotificationElm.tsx` の `grw-unopend-notification` クラスに対応する CSS 定義がコードベースに存在しないため、未読ドットが表示されない
+  - NewsItem と同様に `width/height/display: inline-block` のインラインスタイルを追加する
+  - _Requirements: 6.1_
+
+- [x] 12.2 全面サイドバー(② dock/drawer モード)での通知表示エリアを拡張する
+  - `InAppNotificationSubstance.tsx` の各フィルタ表示エリアに `style={{ maxHeight: '60vh' }}` が固定されており、② dock/drawer モードでもホバーパネル(①)サイズに制限される
+  - `useSidebarMode()` で collapsed モードを判定し、collapsed 時のみ `maxHeight: '60vh'` を適用する。dock/drawer モードでは制約を外し、外側の SimpleBar コンテナによるスクロールに委ねる
+  - _Requirements: 5.1_
+
+- [x] 12.3 アプリ内通知の未読ドットをクリック時に即時消去する
+  - `InAppNotificationSubstance.tsx` の `handleNotificationRead` で `useSWRInfinite` の `mutate(updater, { revalidate: false })` を使って既読状態をキャッシュに書き込もうとしていたが、ナビゲーション(`<a href>`)によってコンポーネントがアンマウントされた後に `useSWRInfinite` のページ単位キャッシュが古い状態に戻るため、ドットが再表示される
+  - `useState<Set<string>>` でローカルに開封済み通知 ID を管理し、各 `InAppNotificationElm` のレンダリング時に `status` をその場でオーバーライドすることで、SWR キャッシュに依存せず即時反映を実現する
+  - _Requirements: 6.1, 6.2_
+
+- [x] 13. PR レビュー FB 対応によるコード品質改善
+- [x] 13.1 型アサーションを排除する(FB ①)
+  - `interfaces/in-app-notification.ts` に `IInAppNotificationHasId = IInAppNotification & HasObjectId` を追加
+  - `stores/in-app-notification.ts` で `apiv3Get<InAppNotificationPaginateResult>()` にジェネリクスを注入し、`response.data as ...` を削除
+  - `InAppNotificationSubstance.tsx` の `allModeSWRResponse` を `SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>` として明示的に宣言し、`as unknown as Parameters<typeof InfiniteScroll>[0]...` を撤去
+  - `notificationResponse.data.flatMap(...) as (IInAppNotification & HasObjectId)[]` の cast を削除(型情報が自然に流れる)
+  - _Requirements: 品質改善_
+- [x] 13.2 SWR state-less による未読ドット即時消去へ差し替える(FB ②)
+  - 12.3 で採用した `useState<Set<string>>` を撤去し、`notificationResponse.mutate((pages) => ..., { revalidate: false })` による SWR ネイティブの楽観更新に置換
+  - `SWRConfig` プロバイダのキャッシュ Map がアンマウント/リマウントを跨いで保持されるため、再マウント時もドットは消えたまま(実機検証済み)
+  - SWR のキャッシュ・hook の利点を損なわない実装とする
+  - _Requirements: 品質改善, 6.1, 6.2_
+- [x] 13.3 NewsItem の言語ユーティリティと Bootstrap クラスを既存パターンに統一する(FB ③)
+  - `navigator.language` の独自ロジックを撤去し、`useTranslation()` の `i18n.language` を使用(`ActivityListItem` と同パターン)
+  - 日付表示を `date-fns` `format` + `getLocale(i18n.language)` に統一
+  - button のインラインスタイル(`cursor/width/textAlign/background`)を Bootstrap クラス `w-100 text-start bg-transparent` に置換
+  - emoji span の `fontSize/lineHeight` を `fs-5 lh-1` に置換
+  - 未読ドットのインラインスタイルを `UnreadDot.module.scss` の共通 CSS Module に抽出し、`NewsItem.tsx` と `InAppNotificationElm.tsx` の両者から参照して見た目を統一
+  - `browserLanguage` prop を廃止し、テストも i18n モックへ合わせて更新
+  - _Requirements: 品質改善_

+ 90 - 0
.kiro/specs/opentelemetry/brief.md

@@ -0,0 +1,90 @@
+# Brief: opentelemetry
+
+## Problem
+GROWI は監視・可観測性のために OpenTelemetry を採用しており、`apps/app/src/features/opentelemetry/` 配下に NodeSDK 初期化・Resource Attribute・Custom Metrics・Anonymization の各レイヤを実装している。本 spec は `features/opentelemetry/` の **大局的なメンテナンスリファレンス** として、将来の追加・変更(メトリクス追加、新規 anonymization handler、SDK バージョンアップ)が踏むべき境界線と設計意図を提供する。
+
+## Current State
+- ランタイム: Node.js `^24`(cgroup 系 API・V8 統計が利用可能)。
+- 依存パッケージ:
+  - `@opentelemetry/api ^1.9.0`
+  - `@opentelemetry/sdk-node ^0.217.0`
+  - `@opentelemetry/auto-instrumentations-node ^0.75.0`
+  - `@opentelemetry/exporter-trace-otlp-grpc`, `@opentelemetry/exporter-metrics-otlp-grpc ^0.202.0`
+  - `@opentelemetry/sdk-metrics ^2.0.1`, `@opentelemetry/resources ^2.0.1`, `@opentelemetry/sdk-trace-node ^2.0.1`
+  - `@opentelemetry/semantic-conventions ^1.34.0`
+- 全コードは server-only。クライアント側からの import は無い。
+- ディレクトリ構成 (`apps/app/src/features/opentelemetry/server/`):
+  - `node-sdk.ts` — SDK ライフサイクル管理(`initInstrumentation` / `setupAdditionalResourceAttributes` / `startOpenTelemetry`)。
+  - `node-sdk-configuration.ts` — `NodeSDKConfiguration` 構築と Resource 構築(2 段階初期化)。
+  - `node-sdk-resource.ts` — `NodeSDK._resource` への低レベルアクセサ(リフレクション)。
+  - `logger.ts` — `DiagLogger` を pino logger にアダプトする実装。
+  - `semconv.ts` — incubating semantic conventions のコピー(`service.instance.id`, `http.target`)。
+  - `custom-resource-attributes/` — `os-resource-attributes` / `application-resource-attributes`。identity 専用。
+  - `custom-metrics/` — `application-metrics` / `user-counts-metrics` / `page-counts-metrics` / `system-metrics` + `setupCustomMetrics()` 合成。
+  - `anonymization/` — `httpInstrumentationConfig` と 4 個の handler(search / page-listing / page / page-access)。
+- 設定キー(`config-definition.ts`):
+  - `otel:enabled` (`OPENTELEMETRY_ENABLED`, default `true`)
+  - `otel:isAppSiteUrlHashed` (`OPENTELEMETRY_IS_APP_SITE_URL_HASHED`, default `false`)
+  - `otel:anonymizeInBestEffort` (`OPENTELEMETRY_ANONYMIZE_IN_BEST_EFFORT`, default `false`)
+  - `otel:serviceInstanceId` (`OPENTELEMETRY_SERVICE_INSTANCE_ID`, default `undefined`)
+- Exporter: OTLP gRPC(trace / metric とも)。Endpoint は OTel 標準環境変数(`OTEL_EXPORTER_OTLP_ENDPOINT` 等)で制御。
+- Metric 出力間隔: `PeriodicExportingMetricReader` の `exportIntervalMillis: 300000`(5 分)。
+
+## Desired Outcome
+- `features/opentelemetry/` のすべての公開モジュールが本 spec の Boundary Commitments / Out of Boundary で明示的に分類されており、新規メトリクス追加・新規 anonymization handler 追加・SDK バージョンアップが「どこを触ればよいか / どこを触ってはいけないか」を本 spec 1 か所で参照できる。
+- Resource Attribute は identity 専用、設定値は `growi.configs` info-gauge ラベルへ、観測値は `growi.*` または `system.*` / `process.*` メトリクスへ、というレイヤ責務が明文化されている。
+- 旧来の `apps/app/src/features/opentelemetry/docs/` 配下の散在ドキュメントは破棄され、本 spec が単一の真実ソースになる。
+
+## Approach
+**新規実装ではなくドキュメント統合。** 既に動作している `features/opentelemetry/` の構造を本 spec に固定化する。
+1. SDK ライフサイクル・Resource 2 段階初期化・Custom Metrics 合成・Anonymization の 4 レイヤを Boundary Commitments で分割。
+2. Configuration(env var / config key)と Metric Schema を表形式で明示。
+3. Resource Attribute は identity 専用、設定値は `growi.configs` info-gauge ラベル、観測値(メモリ・ヒープ等)は `system.*` / `process.*` メトリクスへ、というレイヤ責務を Design Decisions として固定する。
+
+## Scope
+- **In**:
+  - `features/opentelemetry/server/` 配下のすべての公開モジュール(SDK / Resource / Metric / Anonymization / Logger / semconv)の責務と境界の明文化。
+  - 設定キー一覧と Resource Attribute / Metric Schema の確定スナップショット。
+  - 既存 Anonymization Handler の登録手順(`handlers/index.ts` への module 追加 + `canHandle` / `handle` インターフェース実装)。
+- **Out**:
+  - 既存メトリクスの名称変更や再構成。
+  - Trace span attribute の追加(`http.target` 以外)。
+  - GROWI 本体の logger pipeline と OpenTelemetry log signal の統合。
+  - フロントエンド(ブラウザ)からの telemetry 出力。
+  - サードパーティ製パッケージ(`@opentelemetry/host-metrics` 等)への置き換え。
+
+## Boundary Candidates
+1. **SDK ライフサイクル**(`node-sdk.ts`, `node-sdk-configuration.ts`, `node-sdk-resource.ts`, `logger.ts`) — SDK 初期化・enable/disable 制御・Resource 2 段階注入。
+2. **Resource Attribute レイヤ**(`custom-resource-attributes/`) — identity 専用属性の供給。
+3. **Custom Metric レイヤ**(`custom-metrics/`) — `growi.*` / `system.*` / `process.*` メトリクスの emit と合成。
+4. **HTTP Anonymization レイヤ**(`anonymization/`) — `http.target` の匿名化と handler の選択ロジック。
+5. **SemConv ローカルコピー**(`semconv.ts`) — 不安定 semconv の固定化。
+
+これら 5 つはそれぞれ独立に拡張・置換可能で、相互の dependency は明確に下流方向に限定されている。
+
+## Out of Boundary
+- `~/server/service/growi-info`(`growiInfoService`) — 上流。本 spec は consumer。
+- `~/server/service/config-manager`(`configManager`) — 上流。本 spec は consumer。
+- `~/utils/growi-version` / `~/utils/logger` — utility。本 spec は consumer。
+- Anonymization で参照する `@growi/core/dist/utils/page-path-utils` の各 helper(`isPermalink`, `isUserPage`, `getUsernameByPath` 等) — `@growi/core` の責務。
+- Auto-instrumentation(HTTP / Express / Mongoose 等)のチューニング — 設定オブジェクトの構造は本 spec が定義するが、各 instrumentation の挙動は上流パッケージの責務。
+- OTLP Exporter の wire 仕様、Prometheus / Grafana / Collector 等の下流ツールチェイン。
+
+## Upstream / Downstream
+- **Upstream**:
+  - `~/server/service/config-manager` — `otel:*` config 4 種。
+  - `~/server/service/growi-info` — `growiInfoService.getGrowiInfo(opts)`。Metric / Resource 双方が consumer。
+  - `~/utils/growi-version` — `service.version` Resource Attribute の供給元。
+- **Downstream**:
+  - OpenTelemetry Collector(OTLP gRPC)。
+  - 受信側ダッシュボード(Prometheus / Grafana / Tempo / Loki 等)。otel-infra 管理者は本 spec の Metric Schema と Resource Attribute 表を参照する。
+
+## Existing Spec Touchpoints
+- **Adjacent**: なし。`growi-logger` spec はアプリケーションロガーの spec で、`logger.ts` の `DiagLogger` アダプタが pino を経由する点で接点があるが、両者の責務は独立。
+
+## Constraints
+- ランタイム要件: Node.js `^24`(cgroup memory API、V8 統計のため)。
+- 新規 npm dependency の追加は原則不可(既存 `@opentelemetry/*` パッケージで完結させる)。追加が必要な場合は `apps/app/.next/node_modules/` 残留有無を確認し `dependencies` 分類が必要かを判定する(参照: `.claude/rules/package-dependencies.md`)。
+- Semconv の不安定 attribute は `semconv.ts` にローカルコピーする(incubating entry-point は import しない)。詳細は [semconv.ts](../../apps/app/src/features/opentelemetry/server/semconv.ts) のコメント参照。
+- `setResource()` は `NodeSDK._resource` への private アクセスを行う(type cast 必須)。OpenTelemetry SDK が public な resource 上書き API を提供したら撤去する。
+- Anonymization の出力先 attribute は `http.target`(incubating)。OTLP semconv で対応 stable attribute が決定したら移行する。

+ 737 - 0
.kiro/specs/opentelemetry/design.md

@@ -0,0 +1,737 @@
+# Technical Design — opentelemetry
+
+## Overview
+
+**Purpose**: GROWI の OpenTelemetry 統合 (`apps/app/src/features/opentelemetry/`) を、SDK ライフサイクル / Resource Attribute / Custom Metric / HTTP Anonymization の 4 レイヤに分けて責務境界を明文化する大局的なメンテナンス spec。
+
+**Users**:
+- GROWI 開発者(メトリクス追加・anonymization handler 追加・SDK バージョンアップを行う)。
+- OpenTelemetry 受信側インフラ管理者(Prometheus / Grafana / Collector を運用する)。
+
+**Impact**: 既に稼働している実装の現状をスナップショットとして固定化する。新規実装はゼロで、コード変更は伴わない。将来の機能追加・変更は本 spec の Boundary Commitments に従って境界の中で行われる。
+
+### Goals
+
+- 4 レイヤそれぞれの責務・境界・依存関係を明文化する。
+- Resource Attribute は identity 専用、設定値は `growi.configs` ラベル、観測値は `growi.*` / `system.*` / `process.*` メトリクスへ、というレイヤ分離を維持する。
+- 新規 Custom Metric / Anonymization Handler の追加手順を「テンプレート化」して、追加時のレビュー差分を最小化する。
+- Resource Attribute / Metric / Span Attribute の責務分離(identity / 設定値 / 観測値 / span attribute)を Design Decisions として固定する。
+
+### Non-Goals
+
+- 既存メトリクス / Resource Attribute の名称変更・再構成。
+- OpenTelemetry Log Signal の利用開始。
+- ブラウザサイドからの telemetry 出力。
+- `@opentelemetry/host-metrics` 等への置き換え。
+- `service.instance.id` の自動生成(現状は `otel:serviceInstanceId` か `app:serviceInstanceId` の config 値を passthrough する)。
+
+## Boundary Commitments
+
+### This Spec Owns
+
+#### Layer 1: SDK ライフサイクル
+
+- `node-sdk.ts` の `initInstrumentation()` / `setupAdditionalResourceAttributes()` / `startOpenTelemetry()` の 3 関数。
+- `overwriteSdkDisabled()` による `OTEL_SDK_DISABLED` と `otel:enabled` の整合化。
+- `node-sdk-configuration.ts` の `generateNodeSDKConfiguration(opts)` および `generateAdditionalResourceAttributes(opts)`。
+- `node-sdk-resource.ts` の `getResource()` / `setResource()`(NodeSDK private `_resource` への reflective アクセサ)。
+- `logger.ts` の `DiagLoggerPinoAdapter` と `initLogger()`。
+- 同一プロセス内の SDK インスタンス二重生成防止(`sdkInstance` モジュール変数)。
+
+#### Layer 2: Resource Attribute(identity 専用)
+
+- `custom-resource-attributes/os-resource-attributes.ts` の `getOsResourceAttributes(): Attributes`。
+- `custom-resource-attributes/application-resource-attributes.ts` の `getApplicationResourceAttributes(): Promise<Attributes>`。
+- `custom-resource-attributes/index.ts` のバレル。
+- emit する identity 属性: `os.type`, `os.platform`, `os.arch`, `growi.service.type`, `growi.deployment.type`。
+- 2 段階初期化(DB 非依存の OS info → DB 初期化後の `service.instance.id` および application info)の責務。
+
+#### Layer 3: Custom Metric
+
+- `custom-metrics/index.ts` の barrel と `setupCustomMetrics(): Promise<void>`。
+- `custom-metrics/application-metrics.ts` の `addApplicationMetrics()`(`growi.configs` info gauge + 5 ラベル)。
+- `custom-metrics/user-counts-metrics.ts` の `addUserCountsMetrics()`(`growi.users.total` / `growi.users.active`)。
+- `custom-metrics/page-counts-metrics.ts` の `addPageCountsMetrics()`(`growi.pages.total`)。
+- `custom-metrics/system-metrics.ts` の `addSystemMetrics()`(`system.memory.limit` / `system.host.memory.total` / `process.memory.usage` / `process.runtime.v8.heap.{used,total,external}`)。
+- Meter 命名規約: `growi-<scope>-metrics`, version `'1.0.0'`。
+- Observation 実装規約: `ObservableGauge` + `addBatchObservableCallback` + try/catch + `diag.createComponentLogger` で例外吸収。
+
+#### Layer 4: HTTP Anonymization
+
+- `anonymization/index.ts` のバレル(`httpInstrumentationConfig` のエクスポート)。
+- `anonymization/anonymize-http-requests.ts` の `startIncomingSpanHook` 実装(module discovery loop)。
+- `anonymization/interfaces/anonymization-module.ts` の `AnonymizationModule` インターフェース。
+- `anonymization/handlers/index.ts` の `anonymizationModules` 配列(4 module の登録順)。
+- 4 つの handler 実装: `search-api-handler.ts`, `page-listing-api-handler.ts`, `page-api-handler.ts`, `page-access-handler.ts`。
+- `anonymization/utils/anonymize-query-params.ts` の `anonymizeQueryParams(target, paramNames)`。
+- `http.target` への匿名化 URL 出力。
+
+#### Layer 5: SemConv ローカルコピー
+
+- `semconv.ts` の `ATTR_SERVICE_INSTANCE_ID` / `ATTR_HTTP_TARGET` 定義。
+- incubating semconv の文字列定数化と上流 minor リリース変更からの隔離。
+
+### Out of Boundary
+
+- `~/server/service/growi-info`(`growiInfoService`) — consumer として利用するのみ。`getGrowiInfo(opts)` の API 仕様は本 spec では定義しない。
+- `~/server/service/config-manager` — `otel:*` config 4 種を読み取るのみ。config 定義の追加・改名は config-manager 側の責務。
+- `~/utils/growi-version` — `service.version` の供給元。
+- `~/utils/logger` — pino logger ファクトリ。
+- `@growi/core/dist/utils/page-path-utils` — anonymization で利用する path helper(`isPermalink` / `isUserPage` / `getUsernameByPath` 等)。
+- 各 `@opentelemetry/*` パッケージの内部実装。Auto-instrumentation の挙動チューニング(HTTP / Express / Mongoose 等の挙動は instrumentation の責務)。
+- Trace span への独自 attribute 付与(`http.target` 以外)。
+- OpenTelemetry Log Signal、ブラウザ telemetry。
+- OTLP wire 仕様および受信側ダッシュボード/アラート。
+
+### Allowed Dependencies
+
+- Node.js 標準モジュール: `node:os`, `node:v8`, `node:process`, `node:crypto`, `node:http`。
+- `@opentelemetry/api`: `metrics`, `diag`, `Attributes`, `Meter`, `ObservableGauge` 等の public API のみ。
+- `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/sdk-trace-node`, `@opentelemetry/resources`: SDK 初期化用。
+- `@opentelemetry/exporter-trace-otlp-grpc`, `@opentelemetry/exporter-metrics-otlp-grpc`: OTLP gRPC エクスポート。
+- `@opentelemetry/auto-instrumentations-node`: `getNodeAutoInstrumentations()` および `InstrumentationConfigMap` 型。
+- `@opentelemetry/semantic-conventions`: stable attribute のみ(`ATTR_SERVICE_NAME` / `ATTR_SERVICE_VERSION`)。incubating は import しない。
+- 上記以外の新規 npm dependency 追加は不可。追加する場合は `apps/app/.next/node_modules/` 残留有無の確認と `dependencies` 分類が必要(`.claude/rules/package-dependencies.md` 参照)。
+
+### Revalidation Triggers
+
+- `@opentelemetry/api` または `@opentelemetry/sdk-*` のメジャー更新 → Meter / ObservableGauge / NodeSDK API シグネチャの再確認、特に `node-sdk-resource.ts` の `_resource` private アクセスが public 化されていないか確認。
+- `growiInfoService.getGrowiInfo()` の API 変更(追加フラグ削除、返り値型変更)→ 該当する `application-metrics.ts` / `user-counts-metrics.ts` / `page-counts-metrics.ts` / `application-resource-attributes.ts` の参照を再確認。
+- `@opentelemetry/semantic-conventions` の `service.instance.id` / `http.target` の stable 化 → `semconv.ts` のローカル定数を撤去し、stable import に切り替える。
+- Node.js ランタイム要件のダウングレード(`engines.node` が `^24` 未満)→ `process.constrainedMemory()` / `v8.getHeapStatistics()` の互換性再確認。
+- 新規 anonymization 対象パス/パラメータ追加要望 → handler の登録順と canHandle 衝突の再評価。
+- 受信側ダッシュボード/クエリの参照更新が未完了の状態でメトリクス/ラベル変更を行う → ロールアウト順序の再調整。
+
+## Architecture
+
+### Existing Architecture Analysis
+
+`features/opentelemetry/server/` は以下の 5 レイヤを下流方向の単方向依存で構成する:
+
+1. **SDK ライフサイクル** — `node-sdk.ts` が `node-sdk-configuration.ts` / `node-sdk-resource.ts` / `logger.ts` および `custom-metrics/index.ts` を統括する。
+2. **Resource Attribute** — `custom-resource-attributes/` を `node-sdk-configuration.ts` の 2 段階目(`generateAdditionalResourceAttributes`)が consumer として呼ぶ。
+3. **Custom Metric** — `custom-metrics/index.ts` の `setupCustomMetrics()` が起動時に 4 モジュールを順次登録。各モジュールは `growiInfoService` または Node.js stdlib を参照。
+4. **HTTP Anonymization** — `anonymization/index.ts` から export される `httpInstrumentationConfig` が `node-sdk-configuration.ts` の auto-instrumentation 構築時に注入される。
+5. **SemConv** — `semconv.ts` は Layer 1, 2, 4 から参照される葉ノード。
+
+各レイヤは独立に拡張可能で、横断的な相互依存(Custom Metric が Anonymization に依存する等)は存在しない。
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph Layer1["Layer 1: SDK lifecycle"]
+        NodeSdk[node-sdk.ts]
+        NodeSdkCfg[node-sdk-configuration.ts]
+        NodeSdkRes[node-sdk-resource.ts]
+        DiagLogger[logger.ts]
+    end
+    subgraph Layer2["Layer 2: Resource Attribute identity-only"]
+        OsRA[custom-resource-attributes/os-resource-attributes.ts]
+        AppRA[custom-resource-attributes/application-resource-attributes.ts]
+    end
+    subgraph Layer3["Layer 3: Custom Metric"]
+        MetIndex[custom-metrics/index.ts setupCustomMetrics]
+        AppMet[application-metrics.ts]
+        UserMet[user-counts-metrics.ts]
+        PageMet[page-counts-metrics.ts]
+        SysMet[system-metrics.ts]
+    end
+    subgraph Layer4["Layer 4: HTTP Anonymization"]
+        AnonIdx[anonymization/index.ts]
+        AnonHook[anonymize-http-requests.ts]
+        AnonHandlers[handlers/index.ts]
+        SearchH[search-api-handler.ts]
+        PageH[page-api-handler.ts]
+        PageListH[page-listing-api-handler.ts]
+        PageAccH[page-access-handler.ts]
+        QPUtil[utils/anonymize-query-params.ts]
+    end
+    SemConv[semconv.ts]
+    GrowiInfo[~/server/service/growi-info]
+    ConfigMgr[~/server/service/config-manager]
+    Logger[~/utils/logger]
+    Version[~/utils/growi-version]
+
+    NodeSdk --> NodeSdkCfg
+    NodeSdk --> NodeSdkRes
+    NodeSdk --> DiagLogger
+    NodeSdk --> MetIndex
+    NodeSdkCfg --> OsRA
+    NodeSdkCfg --> AppRA
+    NodeSdkCfg --> AnonIdx
+    NodeSdkCfg --> Version
+    NodeSdkCfg --> SemConv
+    AppRA --> GrowiInfo
+    AppMet --> GrowiInfo
+    AppMet --> ConfigMgr
+    UserMet --> GrowiInfo
+    PageMet --> GrowiInfo
+    AnonIdx --> AnonHook
+    AnonHook --> AnonHandlers
+    AnonHandlers --> SearchH
+    AnonHandlers --> PageH
+    AnonHandlers --> PageListH
+    AnonHandlers --> PageAccH
+    SearchH --> QPUtil
+    PageH --> QPUtil
+    PageListH --> QPUtil
+    SearchH --> SemConv
+    PageH --> SemConv
+    PageListH --> SemConv
+    PageAccH --> SemConv
+```
+
+### Bootstrap Sequence
+
+```mermaid
+sequenceDiagram
+    participant App as GROWI app (server entrypoint)
+    participant Lifecycle as Layer 1: node-sdk
+    participant Config as node-sdk-configuration
+    participant SDK as @opentelemetry/sdk-node
+    participant Res as Layer 2: Resource Attr
+    participant Met as Layer 3: setupCustomMetrics
+
+    App->>Lifecycle: initInstrumentation()
+    Lifecycle->>Lifecycle: configManager.loadConfigs(env)
+    Lifecycle->>Lifecycle: overwriteSdkDisabled()
+    alt otel:enabled === true
+        Lifecycle->>Config: generateNodeSDKConfiguration({ enableAnonymization })
+        Config->>Config: build Resource(service.name, service.version)
+        Config->>Config: getNodeAutoInstrumentations(httpInstrumentationConfig?)
+        Config-->>Lifecycle: Configuration
+        Lifecycle->>SDK: new NodeSDK(config)
+    end
+    Note over App: ... DB initialization completes ...
+    App->>Lifecycle: setupAdditionalResourceAttributes()
+    Lifecycle->>Config: generateAdditionalResourceAttributes()
+    Config->>Res: getOsResourceAttributes()
+    Config->>Res: getApplicationResourceAttributes()
+    Config->>Config: resource.merge(service.instance.id + osAttrs + appAttrs)
+    Config-->>Lifecycle: updatedResource
+    Lifecycle->>SDK: setResource(sdkInstance, updatedResource)
+    App->>Lifecycle: startOpenTelemetry()
+    Lifecycle->>SDK: sdkInstance.start()
+    Lifecycle->>Met: setupCustomMetrics()
+    Met->>Met: addApplicationMetrics()
+    Met->>Met: addUserCountsMetrics()
+    Met->>Met: addPageCountsMetrics()
+    Met->>Met: addSystemMetrics()
+```
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Runtime | Node.js `^24` | `process.constrainedMemory()` / `v8.getHeapStatistics()` 等 stdlib API | `apps/app/package.json` の `engines` ではなくリポジトリルートの `engines` で指定 |
+| Telemetry SDK | `@opentelemetry/api ^1.9.0`, `@opentelemetry/sdk-node ^0.217.0`, `@opentelemetry/sdk-metrics ^2.0.1`, `@opentelemetry/sdk-trace-node ^2.0.1`, `@opentelemetry/resources ^2.0.1` | NodeSDK / Meter / Resource | 既存導入済み |
+| Exporter | `@opentelemetry/exporter-trace-otlp-grpc`, `@opentelemetry/exporter-metrics-otlp-grpc ^0.202.0` | OTLP gRPC エクスポート | 引数なしで生成し endpoint は OTel 標準 env var で解決 |
+| Auto-instrumentation | `@opentelemetry/auto-instrumentations-node ^0.75.0` | HTTP / Express / Mongoose 等の自動計測 | `instrumentation-pino`, `instrumentation-fs` は無効化 |
+| SemConv | `@opentelemetry/semantic-conventions ^1.34.0` | stable attribute のみ import | incubating は `semconv.ts` にローカルコピー |
+| Logger | pino(`~/utils/logger`) + `diag` アダプタ | dev 環境のみ DiagLogger を pino に差し替え | production は OpenTelemetry の default diag |
+| Test | Vitest + `vitest-mock-extended` | `vi.mock('node:os'/'node:v8')`, `mock<Meter>()` パターン | 既存テスト基盤 |
+
+## File Structure Plan
+
+### Directory Structure
+
+```
+apps/app/src/features/opentelemetry/server/
+├── index.ts                              # public export: `export * from './node-sdk'`
+├── node-sdk.ts                           # Layer 1: SDK lifecycle entrypoints
+├── node-sdk-configuration.ts             # Layer 1: NodeSDKConfiguration + Resource builders
+├── node-sdk-resource.ts                  # Layer 1: NodeSDK._resource reflective accessor
+├── logger.ts                             # Layer 1: DiagLoggerPinoAdapter
+├── semconv.ts                            # Layer 5: incubating attribute local copy
+├── custom-resource-attributes/
+│   ├── index.ts                          # Layer 2: barrel
+│   ├── os-resource-attributes.ts         # Layer 2: OS identity (stage-1)
+│   ├── os-resource-attributes.spec.ts
+│   ├── application-resource-attributes.ts # Layer 2: GROWI service identity (stage-2)
+│   └── application-resource-attributes.spec.ts
+├── custom-metrics/
+│   ├── index.ts                          # Layer 3: barrel + setupCustomMetrics()
+│   ├── application-metrics.ts            # Layer 3: growi.configs info gauge
+│   ├── application-metrics.spec.ts
+│   ├── user-counts-metrics.ts            # Layer 3: growi.users.{total,active}
+│   ├── user-counts-metrics.spec.ts
+│   ├── page-counts-metrics.ts            # Layer 3: growi.pages.total
+│   ├── page-counts-metrics.spec.ts
+│   ├── system-metrics.ts                 # Layer 3: system.* / process.* memory metrics
+│   └── system-metrics.spec.ts
+└── anonymization/
+    ├── index.ts                          # Layer 4: barrel
+    ├── anonymize-http-requests.ts        # Layer 4: startIncomingSpanHook + module loop
+    ├── interfaces/
+    │   └── anonymization-module.ts       # Layer 4: AnonymizationModule interface
+    ├── handlers/
+    │   ├── index.ts                      # Layer 4: anonymizationModules[] registration
+    │   ├── search-api-handler.ts
+    │   ├── search-api-handler.spec.ts
+    │   ├── page-listing-api-handler.ts
+    │   ├── page-listing-api-handler.spec.ts
+    │   ├── page-api-handler.ts
+    │   ├── page-api-handler.spec.ts
+    │   ├── page-access-handler.ts
+    │   └── page-access-handler.spec.ts
+    └── utils/
+        ├── anonymize-query-params.ts
+        └── anonymize-query-params.spec.ts
+```
+
+### Extension Templates
+
+#### 新規 Custom Metric モジュールの追加
+
+1. `custom-metrics/<scope>-metrics.ts` を新規作成し、`addXxxMetrics(): void` を export する。
+2. ファイル冒頭で `loggerFactory('growi:opentelemetry:custom-metrics:<scope>')` と `diag.createComponentLogger({ namespace: 'growi:custom-metrics:<scope>' })` を初期化する。
+3. `metrics.getMeter('growi-<scope>-metrics', '1.0.0')` で Meter 取得。
+4. `meter.createObservableGauge(name, { description, unit })` で gauge 群を作成。
+5. `meter.addBatchObservableCallback(async (result) => { try { ... } catch (e) { loggerDiag.error(...) } }, [...gauges])` を 1 つ登録。
+6. `custom-metrics/index.ts` の barrel に `export { addXxxMetrics } from './<scope>-metrics';` を追加し、`setupCustomMetrics()` 内で dynamic import + 呼び出し。
+7. `*.spec.ts` を co-locate し、`vi.mock('@opentelemetry/api')` + `mock<Meter>()` パターンで unit test を書く。
+
+#### 新規 Anonymization Handler の追加
+
+1. `anonymization/handlers/<scope>-handler.ts` を新規作成し、`AnonymizationModule` 型の object を export する。
+2. `canHandle(url): boolean` で対象 URL を判別する。先頭一致 / `URL` parser / 正規表現を適宜使用。
+3. `handle(request, url): Record<string, string> | null` で `anonymizeQueryParams()` または独自ロジックを適用し、`{ [ATTR_HTTP_TARGET]: anonymizedUrl }` を返す。何も匿名化しない場合は `null`。
+4. `handlers/index.ts` の `anonymizationModules` 配列に追加。**順序が重要**: より具体的なパスを先に置く(API > 静的 page access)。
+5. `*.spec.ts` を co-locate し、`canHandle` の境界条件と `handle` の URL 変換を網羅する。
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces |
+|-------------|---------|------------|------------|
+| 1.1–1.4 | SDK ライフサイクルと有効化制御 | NodeSdkLifecycle, OverwriteSdkDisabled | `initInstrumentation()`, `setupAdditionalResourceAttributes()`, `startOpenTelemetry()` |
+| 2.1–2.4 | identity 専用 Resource Attribute | OsResourceAttributes, ApplicationResourceAttributes, NodeSdkConfiguration | `getOsResourceAttributes()`, `getApplicationResourceAttributes()`, `generateAdditionalResourceAttributes()` |
+| 3.1–3.5 | GROWI 設定情報の info-gauge ラベル統合 | ApplicationMetrics | `addApplicationMetrics()` の observe ラベル |
+| 4.1–4.4 | 業務カウントメトリクス | UserCountsMetrics, PageCountsMetrics | `addUserCountsMetrics()`, `addPageCountsMetrics()` |
+| 5.1–5.5 | コンテナ運用に対応したメモリ系メトリクス | SystemMetrics | `addSystemMetrics()` |
+| 6.1–6.5 | HTTP リクエストの best-effort anonymization | HttpInstrumentationConfig, AnonymizationModules | `httpInstrumentationConfig.startIncomingSpanHook`, 各 `AnonymizationModule.{canHandle,handle}` |
+| 7.1–7.3 | Diag Logger と pino の統合 | DiagLoggerPinoAdapter | `initLogger()` |
+| 8.1–8.3 | メトリクスエクスポートと SDK 設定 | NodeSdkConfiguration | `generateNodeSDKConfiguration()` の reader / exporter / instrumentation 設定 |
+| 9.1–9.2 | SemConv の不安定 attribute のローカルコピー | SemConv | `semconv.ts` の文字列定数 |
+| 10.1–10.3 | 拡張・追加時の境界遵守 | CustomMetricsIndex, AnonymizationHandlersIndex | 拡張テンプレート(File Structure Plan 参照) |
+
+## Components and Interfaces
+
+### Layer 1: SDK ライフサイクル
+
+#### NodeSdkLifecycle
+
+| Field | Detail |
+|-------|--------|
+| Intent | OpenTelemetry SDK のプロセス内ライフサイクル管理 |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 8.1, 8.2, 8.3 |
+
+**Responsibilities & Constraints**
+- 同一プロセス内で 1 つの `NodeSDK` インスタンスのみを保持する。
+- `otel:enabled` が `false` のときは SDK を構築しない。
+- `OTEL_SDK_DISABLED` env var と `otel:enabled` の食い違いを warn で報告し上書きする。
+- Resource 注入は 2 段階(SDK 構築時 / DB 初期化後)に分け、`setResource()` の private API 経由で 2 段階目を反映する。
+- `start()` 直後に `setupCustomMetrics()` を呼び出して Custom Metric の登録を行う。
+
+**Dependencies**
+- Inbound: `apps/app/src/server/app.ts` 系の起動シーケンス(実体は本 spec の外)。
+- Outbound: `@opentelemetry/sdk-node` (`NodeSDK`), `configManager`, `./node-sdk-configuration`, `./node-sdk-resource`, `./logger`, `./custom-metrics`.
+
+##### Service Interface
+```typescript
+export const initInstrumentation: () => Promise<void>;
+export const setupAdditionalResourceAttributes: () => Promise<void>;
+export const startOpenTelemetry: () => void;
+// テスト専用
+export const __testing__: { getSdkInstance, reset };
+```
+
+**Implementation Notes**
+- 二重 init 防止: モジュールスコープの `let sdkInstance: NodeSDK | undefined;` を見て、設定済みなら warn のみで return。
+- `start()` 前後で `instrumentationEnabled` を再確認する(dev 時に env を切り替える運用への配慮)。
+- `setResource()` は `NodeSDK._resource` を直接書き換える。OpenTelemetry SDK が public な resource 上書き API を提供したら撤去する候補(Revalidation Trigger)。
+
+#### NodeSdkConfiguration
+
+| Field | Detail |
+|-------|--------|
+| Intent | `NodeSDKConfiguration` オブジェクトと Resource の構築 |
+| Requirements | 2.1, 2.2, 2.3, 6.1, 6.5, 8.1, 8.2, 8.3 |
+
+**Responsibilities & Constraints**
+- 1 段階目 Resource: `{ service.name: 'growi', service.version: <growi-version> }`。
+- 2 段階目 Resource: `{ service.instance.id?, ...osAttrs, ...appAttrs }` を merge。
+- Trace exporter: `OTLPTraceExporter()`(引数なし)。
+- Metric reader: `PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter(), exportIntervalMillis: 300000 })`。
+- Instrumentation: `getNodeAutoInstrumentations({ pino: disabled, fs: disabled, http: { enabled, ...httpInstrumentationConfig } })`。
+- `enableAnonymization` が `true` のときのみ `httpInstrumentationConfig` を注入。
+
+**Dependencies**
+- Outbound: `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/exporter-*-otlp-grpc`, `@opentelemetry/auto-instrumentations-node`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions` (stable), `./semconv`, `./anonymization`, `./custom-resource-attributes`, `~/server/service/config-manager`, `~/utils/growi-version`.
+
+##### Service Interface
+```typescript
+type Option = { enableAnonymization?: boolean };
+type Configuration = Partial<NodeSDKConfiguration> & { resource: Resource };
+export const generateNodeSDKConfiguration: (opts?: Option) => Configuration;
+export const generateAdditionalResourceAttributes: (opts?: Option) => Promise<Resource>;
+```
+
+**Implementation Notes**
+- `configuration` と `resource` をモジュールスコープで保持し、二重生成を防止する。
+- `service.instance.id` の値は `otel:serviceInstanceId` を優先し、フォールバックで `app:serviceInstanceId`。
+
+#### NodeSdkResource
+
+| Field | Detail |
+|-------|--------|
+| Intent | NodeSDK の private `_resource` プロパティへの reflective アクセス |
+| Requirements | 1.3 |
+
+**Responsibilities & Constraints**
+- `getResource(sdk)`: `_resource` の存在を検証し返す。失敗時は throw。
+- `setResource(sdk, resource)`: `getResource` で生存確認した上で `_resource` を上書き。
+
+**Implementation Notes**
+- `as any` キャストでアクセスする。SDK のメジャー更新時に public API が出たら即座に撤去すべき箇所(Revalidation Trigger)。
+
+#### DiagLoggerPinoAdapter
+
+| Field | Detail |
+|-------|--------|
+| Intent | `@opentelemetry/api` の `DiagLogger` を pino logger にアダプトする |
+| Requirements | 7.1, 7.2, 7.3 |
+
+**Responsibilities & Constraints**
+- 開発環境(`NODE_ENV === 'development'`)でのみ `initLogger()` を `node-sdk.ts` から呼び出す。
+- `parseMessage(message, args)` で JSON 文字列を構造化 data に変換し、`logger.error(data, msg)` の pino 引数規約に整合する。
+- `error` / `warn` / `info` / `debug` / `verbose` の 5 メソッドを実装。`verbose` は pino の `trace` レベルにマップ。
+
+### Layer 2: Resource Attribute
+
+#### OsResourceAttributes
+
+| Field | Detail |
+|-------|--------|
+| Intent | OS identity を OTel Resource Attribute として返す |
+| Requirements | 2.1, 2.3 |
+
+**Responsibilities & Constraints**
+- `os.type` / `os.platform` / `os.arch` を返す。
+- 測定値(`os.totalmem` 等)は返さない(System Metric 側の責務)。
+
+**Dependencies**
+- External: `node:os` (`type()`, `platform()`, `arch()`).
+
+##### Service Interface
+```typescript
+export function getOsResourceAttributes(): Attributes;
+// 戻り値: { 'os.type': string, 'os.platform': string, 'os.arch': string }
+```
+
+#### ApplicationResourceAttributes
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI service identity を OTel Resource Attribute として返す |
+| Requirements | 2.1, 2.3, 2.4 |
+
+**Responsibilities & Constraints**
+- `growi.service.type` / `growi.deployment.type` を返す。
+- サブシステム設定値(`growi.attachment.type` 等)は返さない(`growi.configs` ラベル側の責務)。
+- try/catch で `growiInfoService` の失敗を吸収し、空 attributes を返す。
+
+**Dependencies**
+- Outbound: `~/server/service/growi-info` の `growiInfoService.getGrowiInfo({})`(dynamic import で循環依存を回避)。
+
+##### Service Interface
+```typescript
+export async function getApplicationResourceAttributes(): Promise<Attributes>;
+// 戻り値: { 'growi.service.type': string, 'growi.deployment.type': string }
+```
+
+### Layer 3: Custom Metric
+
+#### ApplicationMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI 設定情報を info-gauge `growi.configs` のラベルに集約 |
+| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5 |
+
+**Responsibilities & Constraints**
+- `growi.configs` ObservableGauge(unit `'1'`、値は常に 1)を 1 個 emit する。
+- ラベル: `site_url`, `site_url_hashed?`, `wiki_type`, `external_auth_types`, `attachment_type`。
+- `otel:isAppSiteUrlHashed === true` のとき `site_url = '[hashed]'`、`site_url_hashed = SHA-256(appSiteUrl)`。`false` のとき生 URL + `site_url_hashed = undefined`。
+- `external_auth_types` / `attachment_type` の値が未取得時は空文字 `''`。
+
+**Dependencies**
+- Outbound: `growiInfoService.getGrowiInfo({ includeAttachmentInfo: true })`, `configManager.getConfig('otel:isAppSiteUrlHashed')`.
+
+##### Service Interface
+```typescript
+export function addApplicationMetrics(): void;
+```
+
+##### Label Schema: `growi.configs`
+| Label | Source | Notes |
+|-------|--------|-------|
+| `site_url` | `isAppSiteUrlHashed ? '[hashed]' : growiInfo.appSiteUrl` | required |
+| `site_url_hashed` | `isAppSiteUrlHashed ? sha256(appSiteUrl) : undefined` | hashed 時のみ付与 |
+| `wiki_type` | `growiInfo.wikiType` | required |
+| `external_auth_types` | `additionalInfo?.activeExternalAccountTypes?.join(',') \|\| ''` | required(カンマ区切り) |
+| `attachment_type` | `additionalInfo?.attachmentType ?? ''` | required |
+
+#### UserCountsMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI 上のユーザー数とアクティブユーザー数の継続観測 |
+| Requirements | 4.1, 4.2, 4.4 |
+
+**Responsibilities & Constraints**
+- `growi.users.total` / `growi.users.active` の 2 つの ObservableGauge(unit `'users'`)。
+- `growiInfoService.getGrowiInfo({ includeUserCountInfo: true })` を呼び、`additionalInfo.currentUsersCount` / `currentActiveUsersCount` をそれぞれ observe。未取得時は 0。
+
+##### Service Interface
+```typescript
+export function addUserCountsMetrics(): void;
+```
+
+#### PageCountsMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | GROWI 上の総ページ数の継続観測 |
+| Requirements | 4.3, 4.4 |
+
+**Responsibilities & Constraints**
+- `growi.pages.total`(unit `'pages'`)の ObservableGauge 1 つ。
+- `growiInfoService.getGrowiInfo({ includePageCountInfo: true })` の `additionalInfo.currentPagesCount` を observe。未取得時は 0。
+
+##### Service Interface
+```typescript
+export function addPageCountsMetrics(): void;
+```
+
+#### SystemMetrics
+
+| Field | Detail |
+|-------|--------|
+| Intent | コンテナ / ホスト / プロセス / V8 ヒープのメモリ系統計を ObservableGauge で出力 |
+| Requirements | 5.1, 5.2, 5.3, 5.4, 5.5 |
+
+**Responsibilities & Constraints**
+- 単一 Meter `growi-system-metrics`(version `'1.0.0'`)で 6 つの ObservableGauge を作成。すべて単位 `'By'`。
+- 1 つの `addBatchObservableCallback` で `process.constrainedMemory()` / `os.totalmem()` / `process.memoryUsage()` / `v8.getHeapStatistics()` を 1 回ずつ呼び、ローカル変数経由で 6 個の gauge に観測値を割り振る。
+- `process.constrainedMemory()` が `> 0` のときのみ `system.memory.limit` を観測、`0` または falsy のときは当該 gauge のみスキップし他 5 個は観測する。
+- コールバック全体を try/catch で囲み、例外時は `loggerDiag.error('Failed to collect system metrics', { error })` を呼んで `result.observe` を一切呼ばずに return。
+
+##### Metric Schema
+| Metric Name | Unit | Source | Skip Condition |
+|-------------|------|--------|----------------|
+| `system.memory.limit` | `By` | `process.constrainedMemory()` | 値が `0` または falsy |
+| `system.host.memory.total` | `By` | `os.totalmem()` | — |
+| `process.memory.usage` | `By` | `process.memoryUsage().rss` | — |
+| `process.runtime.v8.heap.used` | `By` | `v8.getHeapStatistics().used_heap_size` | — |
+| `process.runtime.v8.heap.total` | `By` | `v8.getHeapStatistics().total_heap_size` | — |
+| `process.runtime.v8.heap.external` | `By` | `process.memoryUsage().external` | — |
+
+##### Service Interface
+```typescript
+export function addSystemMetrics(): void;
+```
+
+**Implementation Notes**
+- `process.constrainedMemory()` は Node.js 19.6 で導入 / 20.12 で stable。`apps/app` の `engines.node` は `^24` のためサポートされるが、防御的に `(process as NodeJS.Process & { constrainedMemory?(): number }).constrainedMemory?.() ?? 0` でアクセスする。
+- API 呼び出しの重複を避けるため、各 stdlib API は callback 内で 1 回ずつのみ呼ぶ。
+
+#### CustomMetricsIndex
+
+| Field | Detail |
+|-------|--------|
+| Intent | Custom Metric モジュール群の起動合成点 |
+| Requirements | 4.1–4.4, 10.1 |
+
+**Responsibilities & Constraints**
+- 各モジュールを dynamic import し、`addApplicationMetrics()` / `addUserCountsMetrics()` / `addPageCountsMetrics()` / `addSystemMetrics()` を順次呼ぶ。
+- 新規モジュール追加時はこの順序の末尾に append する(既存ダッシュボードに影響しない)。
+
+##### Service Interface
+```typescript
+export const setupCustomMetrics: () => Promise<void>;
+export { addApplicationMetrics, addPageCountsMetrics, addSystemMetrics, addUserCountsMetrics };
+```
+
+### Layer 4: HTTP Anonymization
+
+#### HttpInstrumentationConfig
+
+| Field | Detail |
+|-------|--------|
+| Intent | `@opentelemetry/instrumentation-http` の `startIncomingSpanHook` に注入し、登録された anonymization module を順次評価する |
+| Requirements | 6.1, 6.5 |
+
+**Responsibilities & Constraints**
+- `startIncomingSpanHook(request)` で URL を取り出し、`anonymizationModules` を `canHandle(url)` で順次フィルタ、マッチした module の `handle(request, url)` の戻り値(`{ [ATTR_HTTP_TARGET]: <anonymized> }` または `null`)を `Object.assign` で集約。
+- 注入は `node-sdk-configuration.ts` 経由で、`otel:anonymizeInBestEffort` が `true` のときのみ行う。
+
+##### Service Interface
+```typescript
+export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'];
+```
+
+#### AnonymizationModule(interface)
+
+| Field | Detail |
+|-------|--------|
+| Intent | 個別 anonymization handler の共通契約 |
+| Requirements | 6.1, 6.2, 6.3, 6.4, 10.2 |
+
+##### Interface
+```typescript
+export interface AnonymizationModule {
+  canHandle(url: string): boolean;
+  handle(request: IncomingMessage, url: string): Record<string, string> | null;
+}
+```
+
+#### AnonymizationHandlersIndex
+
+| Field | Detail |
+|-------|--------|
+| Intent | 登録済み handler のコレクション。**配列順 = 評価順**で、より具体的なパスから書く。 |
+| Requirements | 6.2, 6.3, 6.4, 10.2 |
+
+**Registration Order**
+1. `searchApiModule` — 検索 API
+2. `pageListingApiModule` — page-listing API
+3. `pageApiModule` — pages/list 系 API
+4. `pageAccessModule` — 非 API ページアクセス(最も汎用的なため最後)
+
+#### SearchApiModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | `/_api/search`, `/_search` の `q` クエリパラメータを匿名化 |
+| Requirements | 6.2 |
+
+**canHandle**: `/\/_api\/search(\?|$)/` または `/\/_search(\?|$)/` または `'/_api/search/'` / `'/_search/'` を含む。
+**handle**: `q` パラメータが含まれていれば `anonymizeQueryParams(url, ['q'])`。
+
+#### PageListingApiModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | `/_api/v3/page-listing/{ancestors-children,children,item}` の `path` パラメータを匿名化 |
+| Requirements | 6.3 |
+
+#### PageApiModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | `/_api/v3/pages/{list,subordinated-list}` および `/_api/v3/page/{check-page-existence,get-page-paths-with-descendant-count}` の `path` / `paths` パラメータを匿名化 |
+| Requirements | 6.3 |
+
+#### PageAccessModule
+
+| Field | Detail |
+|-------|--------|
+| Intent | API 以外のページアクセスのうち、`isCreatablePage` を満たすパスのみ匿名化。permalink(ObjectId)は素通し、user ページはユーザー名と残りパスを別々にハッシュ。 |
+| Requirements | 6.4 |
+
+**Behavior**:
+- ルート `/`、静的リソース(`/static/`, `/_next/`, `/favicon`, `/assets/`, 拡張子付き)、`/user`(users top page)、permalink を除外。
+- user page (`/user/<name>/...`) はユーザー名と残りパスを SHA-256 prefix(16 文字)で別々にハッシュ → `/user/[USERNAME_HASHED:<hash>][/?][HASHED:<hash>]`。
+- それ以外の通常 page はパス全体を SHA-256 prefix で 1 ハッシュ → `[HASHED:<hash>]`。
+
+#### AnonymizeQueryParams(utility)
+
+| Field | Detail |
+|-------|--------|
+| Intent | クエリパラメータの値を `[ANONYMIZED]` リテラルに置換する純粋関数 |
+| Requirements | 6.2, 6.3 |
+
+##### Service Interface
+```typescript
+export function anonymizeQueryParams(target: string, paramNames: string[]): string;
+```
+
+**Behavior**:
+- 通常パラメータは `[ANONYMIZED]` で置換。値が JSON 配列フォーマットなら `["[ANONYMIZED]"]` を返す。
+- `paramName[]` 形式の配列パラメータには `[ANONYMIZED]` を 1 つだけ残す。
+- 変更が無ければ入力をそのまま返す(無駄な URL 再構築を避ける)。
+
+### Layer 5: SemConv ローカルコピー
+
+#### SemConv
+
+| Field | Detail |
+|-------|--------|
+| Intent | OpenTelemetry incubating semconv を文字列定数として固定化 |
+| Requirements | 9.1, 9.2 |
+
+##### Definitions
+```typescript
+export const ATTR_SERVICE_INSTANCE_ID = 'service.instance.id';
+export const ATTR_HTTP_TARGET = 'http.target';
+```
+
+**Implementation Notes**
+- 上流の incubating entry-point は import 禁止。stable 化されたら本ファイルから削除し、stable 定数の import に切り替える(Revalidation Trigger)。
+
+## Error Handling
+
+### Error Strategy
+
+各レイヤで発生する例外は **その場で吸収し、上位レイヤや他メトリクスを巻き込まない** ことを基本方針とする。
+
+- **Layer 1(SDK lifecycle)**: SDK 構築失敗時のみ throw を許容(起動を継続できないため)。Resource 取得失敗は warn ログでスキップ。
+- **Layer 2(Resource Attribute)**: `growiInfoService` 失敗時は try/catch で空 `Attributes` を返す(SDK 起動は継続可能)。
+- **Layer 3(Custom Metric)**: 各 `addBatchObservableCallback` 内で try/catch。例外時は `diag.createComponentLogger(...).error(...)` を呼び、その collection cycle では observe を 1 回も呼ばない。次の cycle で再試行。
+- **Layer 4(Anonymization)**: 各 handler の `handle` / `canHandle` で try/catch、失敗時は anonymization をスキップし元の URL のまま span 属性に乗せる(または何もしない)。
+
+### Error Categories and Responses
+
+| Category | 例 | 振る舞い |
+|----------|-----|---------|
+| 起動時 SDK 構築失敗 | `OTLPTraceExporter()` のコンストラクタ例外 | プロセス起動継続不可。throw を上位に伝播。 |
+| Resource 取得失敗 | `growiInfoService.getGrowiInfo()` 例外 | `logger.error` で記録し空 `Attributes` 返却。 |
+| Metric collection 例外 | `growiInfoService` 失敗、stdlib 失敗(理論上発生しない) | `loggerDiag.error` で記録、当該 cycle の observe をスキップ。 |
+| Anonymization 失敗 | URL parse 失敗、handler 内部例外 | `diag` logger に warn / debug、URL は元のまま。 |
+| `process.constrainedMemory()` の戻り値が 0 | 非コンテナ環境 | `system.memory.limit` のみスキップ。他 5 メトリクスは observe。 |
+
+### Monitoring
+
+- Diag ログ namespace 規約:
+  - `growi:custom-metrics:application`
+  - `growi:custom-metrics:user-counts`
+  - `growi:custom-metrics:page-counts`
+  - `growi:custom-metrics:system`
+  - `growi:anonymization:<handler-name>`
+- アプリケーションログ(pino): `loggerFactory('growi:opentelemetry:<sub-namespace>')` で起動完了メッセージを info ログ出力。
+
+## Testing Strategy
+
+### Unit Tests
+
+各モジュールに `*.spec.ts` を co-locate する。テスト設計の指針:
+
+- **Layer 2 spec**(`os-resource-attributes.spec.ts`, `application-resource-attributes.spec.ts`): `vi.mock('node:os')` で stdlib を、`vi.mock` で `growiInfoService` をモックし、戻り値 attributes のキー集合を assert。
+- **Layer 3 spec**: `vi.mock('@opentelemetry/api')` で `metrics`, `diag` をモック、`vitest-mock-extended` の `mock<Meter>()` / `mock<ObservableGauge>()` で gauge を取得し、`meter.addBatchObservableCallback.mock.calls[0][0]` でコールバックを取り出して直接実行する。`result.observe` のモックを assert する。
+- **Layer 4 spec**: handler ごとに `canHandle` の境界条件(先頭一致 / クエリ有無 / 静的リソース除外 / permalink 除外)と `handle` の URL 変換を網羅。`anonymize-query-params.spec.ts` で JSON 配列 / `paramName[]` フォーマットを網羅。
+- **SystemMetrics spec**: `vi.mock('node:os')`, `vi.mock('node:v8')`, `vi.spyOn(process, 'constrainedMemory')`, `vi.spyOn(process, 'memoryUsage')` を組み合わせる。
+
+### Integration Tests
+
+- `node-sdk.spec.ts` が SDK 構築・初期化シーケンスを統合的に検証。
+- `node-sdk.testing.ts` がテストヘルパとして共通利用される。
+
+### Manual / E2E Verification
+
+- 開発 devcontainer で `OTEL_EXPORTER_OTLP_ENDPOINT` を `http://localhost:4317` 等に向け、Collector の receiver ログで以下を確認:
+  - Resource Attribute が identity セット 8 種のみであること。
+  - `growi.configs` の `attachment_type` ラベルが期待値(`aws` / `gcs` / `gridfs` / `local` / `mongodb` / `azure` 等)または空文字であること。
+  - `system.host.memory.total` / `process.memory.usage` / `process.runtime.v8.heap.*` が約 5 分間隔で届くこと。
+  - Docker container で `--memory=512m` を指定した場合に `system.memory.limit` が約 `536870912`、未指定時は emit されないこと。
+- `OPENTELEMETRY_ANONYMIZE_IN_BEST_EFFORT=true` のとき、検索 / 編集等の操作後 span の `http.target` が `[ANONYMIZED]` / `[HASHED:...]` で置換されていること。

+ 139 - 0
.kiro/specs/opentelemetry/requirements.md

@@ -0,0 +1,139 @@
+# Requirements Document
+
+## Introduction
+
+GROWI の OpenTelemetry 統合 (`apps/app/src/features/opentelemetry/`) を **メンテナンスするための大局的な仕様**。SDK ライフサイクル、Resource Attribute、Custom Metric、HTTP Anonymization の 4 レイヤがそれぞれ「何を担い、何を担わないか」を明文化し、新規メトリクスや anonymization handler の追加、SDK のバージョンアップ、設定キーの追加・改名といった将来のメンテナンス時に、本 spec を 1 か所の参照点として運用できる状態を目標とする。
+
+本 spec は新規実装 spec ではなく、既に実装・稼働している `features/opentelemetry/` の **現状の責務境界をスナップショットとして固定化する** 性格を持つ。個別機能の追加・変更は原則として本 spec の Boundary Commitments の範囲内で行われ、境界をまたぐ変更が必要なときは Revalidation Triggers として再評価される。
+
+## Boundary Context
+
+- **In scope**:
+  - NodeSDK の起動・有効化制御・Resource 2 段階初期化 (`node-sdk.ts`, `node-sdk-configuration.ts`, `node-sdk-resource.ts`)。
+  - Diag Logger の pino アダプタ (`logger.ts`)。
+  - SemConv の不安定 attribute のローカルコピー (`semconv.ts`)。
+  - identity 専用の Resource Attribute 供給 (`custom-resource-attributes/`)。
+  - Custom Metric の emit と合成 (`custom-metrics/`、合計 4 モジュール: application / user-counts / page-counts / system)。
+  - HTTP リクエストの best-effort anonymization (`anonymization/`、4 個の handler + utility)。
+  - `otel:*` 設定キー 4 種の利用ポリシー。
+- **Out of scope**:
+  - `growiInfoService` / `configManager` / `loggerFactory` などの上流サービスの設計や API 変更。
+  - 既存メトリクス(`growi.users.total` / `growi.users.active` / `growi.pages.total` / `growi.configs` / `system.*` / `process.*`)の名称変更や再構成。
+  - OpenTelemetry のログシグナル統合(log signal は現状未使用)。
+  - クライアント側(ブラウザ)からの telemetry 出力。
+  - Trace span への独自 attribute 追加(`http.target` 以外)。
+  - OTLP Exporter の wire 仕様や受信側ツールチェイン。
+- **Adjacent expectations**:
+  - 上流: `growiInfoService.getGrowiInfo({ includeAttachmentInfo, includeUserCountInfo, includePageCountInfo })` の API シグネチャ・返り値型が維持されることに依存する。破壊的変更があった場合は Revalidation Triggers として `custom-metrics/` および `custom-resource-attributes/application-resource-attributes.ts` を再評価する。
+  - 上流: `configManager.getConfig('otel:*')` の 4 キーが現状の意味で参照可能であることに依存する。
+  - 下流: OpenTelemetry Collector およびその先のダッシュボード/アラート群が本 spec の Metric Schema / Resource Attribute 表に整合した参照クエリを保持していること。変更時は PR 説明にて運用者へ通知する。
+
+## Requirements
+
+### Requirement 1: SDK ライフサイクルと有効化制御
+
+**Objective:** GROWI 運用者として、OpenTelemetry SDK を環境変数 1 つで有効/無効を切り替えられ、無効時にはランタイムオーバーヘッドや誤った OTLP 接続試行が発生しないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `otel:enabled` 設定が `false` のとき、NodeSDK インスタンスを生成せず Resource Attribute 取得や Custom Metric の登録も行わない。
+2. The GROWI server shall `otel:enabled` 設定値と `OTEL_SDK_DISABLED` 環境変数の値が矛盾している場合、`OTEL_SDK_DISABLED` を上書きして整合性を取り、その旨を warn ログとして出力する。
+3. The GROWI server shall NodeSDK 初期化を「SDK 構築・Resource 静的部分のセット」「DB 初期化後の Resource 追加注入」「`start()` 呼び出しと Custom Metric 登録」の 3 段階で行う。各段階は `otel:enabled` を再確認した上で実行する。
+4. The GROWI server shall 同一プロセス内で `initInstrumentation()` を二重に呼ばれても、SDK インスタンスを重複生成しない(再初期化は警告を出してスキップする)。
+
+### Requirement 2: identity 専用 Resource Attribute
+
+**Objective:** 受信側インフラ管理者として、GROWI が emit する Resource Attribute がテレメトリ発生元エンティティの identity 情報のみであり、測定値や設定値が紛れ込まないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall Resource Attribute として以下のみを emit する: `service.name`, `service.version`, `service.instance.id`(取得できた場合), `os.type`, `os.platform`, `os.arch`, `growi.service.type`, `growi.deployment.type`。
+2. The GROWI server shall 測定値(メモリ使用量・カウント等)および GROWI のサブシステム設定値(attachment / auth provider 種別等)を Resource Attribute として emit しない。
+3. The GROWI server shall Resource Attribute の取得を 2 段階に分け、1 段階目は DB 非依存(service.name / version / OS info)、2 段階目は DB 初期化後(`service.instance.id` / `growi.service.type` / `growi.deployment.type`)に行う。
+4. If Resource Attribute 取得処理で例外が発生した場合, the GROWI server shall 当該段階の Resource Attribute を空オブジェクトで返し、SDK 起動自体は継続する。
+
+### Requirement 3: GROWI 設定情報の info-gauge ラベル統合
+
+**Objective:** 運用者として、GROWI インスタンスの設定情報(site URL、wiki type、外部認証種別、添付ストレージ種別)を 1 つの info-gauge メトリクスから一覧できることを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `growi.configs` という ObservableGauge を Prometheus info パターン(値は常に 1、情報はラベルに格納)で emit する。
+2. The GROWI server shall `growi.configs` に以下のラベルを付与する: `site_url`, `site_url_hashed`, `wiki_type`, `external_auth_types`, `attachment_type`。
+3. If `otel:isAppSiteUrlHashed` が `true`, the GROWI server shall `site_url` を `[hashed]` リテラルにし、`site_url_hashed` に SHA-256 ハッシュ値を入れる。`false` のときは `site_url` に生 URL を入れ `site_url_hashed` は `undefined`(emit されない)。
+4. If `external_auth_types` / `attachment_type` の値が `growiInfoService` から取得できない場合, the GROWI server shall 当該ラベルを空文字 `''` でフォールバックする。
+5. The GROWI server shall ラベル名を snake_case で統一する。
+
+### Requirement 4: 業務カウントメトリクス
+
+**Objective:** 運用者として、GROWI 上の主要エンティティ(ユーザー、ページ)の総数とアクティビティ指標を継続的に観測したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `growi.users.total` メトリクスを総ユーザー数で観測する(単位 `users`)。
+2. The GROWI server shall `growi.users.active` メトリクスをアクティブユーザー数で観測する(単位 `users`)。
+3. The GROWI server shall `growi.pages.total` メトリクスを総ページ数で観測する(単位 `pages`)。
+4. If `growiInfoService` からのカウント値取得が失敗した場合, the GROWI server shall 0 で観測するか、当該収集サイクルでの観測をスキップし、`diag` ロガーに error を記録する。
+
+### Requirement 5: コンテナ運用に対応したメモリ系メトリクス
+
+**Objective:** コンテナ環境(Docker / Kubernetes)で GROWI を運用する管理者として、「コンテナに割り当てられたメモリ上限(cgroup limit)」「ホスト物理メモリ総量」「プロセス RSS」「V8 ヒープの使用/確保/外部メモリ」を別々のメトリクスとして観測できることを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `system.memory.limit` を `process.constrainedMemory()` の戻り値(>0 のとき)で観測する。値が `0` または falsy のときは当該メトリクスのみ観測をスキップする。
+2. The GROWI server shall `system.host.memory.total` を `os.totalmem()` の戻り値で常に観測する。
+3. The GROWI server shall `process.memory.usage` を `process.memoryUsage().rss` で観測する。
+4. The GROWI server shall `process.runtime.v8.heap.used` / `process.runtime.v8.heap.total` / `process.runtime.v8.heap.external` を `v8.getHeapStatistics()` および `process.memoryUsage().external` から観測する。
+5. The GROWI server shall 上記すべてのメトリクスを単位 `By`(bytes)で emit する。
+
+### Requirement 6: HTTP リクエストの best-effort anonymization
+
+**Objective:** プライバシ保護担当者として、`otel:anonymizeInBestEffort` が `true` のとき、ユーザーが入力した検索キーワード・ページパス・ユーザー名がトレース span の `http.target` に平文で残らないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `otel:anonymizeInBestEffort` が `true` のとき、HTTP instrumentation の `startIncomingSpanHook` で `anonymizationModules` を順次評価し、`canHandle(url)` が `true` を返した module の `handle()` 結果を span attribute としてマージする。
+2. The GROWI server shall 検索 API (`/_api/search`, `/_search`) の `q` クエリパラメータを `[ANONYMIZED]` に置換する。
+3. The GROWI server shall page-listing API (`/_api/v3/page-listing/{ancestors-children,children,item}`) および page API (`/_api/v3/pages/list`, `/_api/v3/pages/subordinated-list`, `/_api/v3/page/check-page-existence`, `/_api/v3/page/get-page-paths-with-descendant-count`) の `path` / `paths` パラメータを匿名化する。
+4. The GROWI server shall ページアクセス(非 API、permalink でない、創出可能なページパス)に対し、ユーザー名およびページパスを SHA-256 prefix(16 文字)でハッシュし `[USERNAME_HASHED:...]` / `[HASHED:...]` プレースホルダで置換する。permalink(ObjectId)と users top page(`/user`)はそのまま残す。
+5. If `otel:anonymizeInBestEffort` が `false`, the GROWI server shall `startIncomingSpanHook` を渡さず、HTTP instrumentation の標準動作のみを行う。
+
+### Requirement 7: Diag Logger と pino の統合
+
+**Objective:** 開発者として、OpenTelemetry 内部の `diag` ログが GROWI の通常のアプリケーションログ(pino)と同一フォーマット・同一出力先で観測できることを保証したい。
+
+#### Acceptance Criteria
+
+1. When `NODE_ENV === 'development'` かつ `otel:enabled` が `true`, the GROWI server shall `DiagLogger` を pino logger にアダプトする実装 (`DiagLoggerPinoAdapter`) をグローバルに登録する。
+2. The GROWI server shall `diag.error/warn/info/debug/verbose` で受け取ったメッセージが JSON 文字列の場合に parse して構造化 data に変換し、pino の引数規約(data 第 1 引数・message 第 2 引数)に整合する形で渡す。
+3. The GROWI server shall production 環境では `initLogger()` を呼ばない(OpenTelemetry の `diag` 既定動作に委ねる)。
+
+### Requirement 8: メトリクスエクスポートと SDK 設定
+
+**Objective:** 運用者として、OTLP メトリクス/トレースエクスポートが OpenTelemetry SDK 標準の環境変数(`OTEL_EXPORTER_OTLP_ENDPOINT` 等)で制御でき、内部で勝手な default endpoint が固定されないことを保証したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall `OTLPTraceExporter` および `OTLPMetricExporter` をコンストラクタ引数なしで生成し、エンドポイントなど exporter 設定は OpenTelemetry SDK 標準の環境変数で解決させる。
+2. The GROWI server shall `PeriodicExportingMetricReader` の `exportIntervalMillis` を 300000(5 分)で初期化する。
+3. The GROWI server shall auto-instrumentation のうち `@opentelemetry/instrumentation-pino` および `@opentelemetry/instrumentation-fs` を明示的に無効化する(pino: log signal を使用しないため、fs: トレース量が膨大すぎるため)。
+
+### Requirement 9: SemConv の不安定 attribute のローカルコピー
+
+**Objective:** 開発者として、`@opentelemetry/semantic-conventions` の incubating attribute をランタイムコードから直接 import せず、本モジュール内のローカルコピーを参照することで、上流の minor リリースでの破壊的変更からアプリケーションを保護したい。
+
+#### Acceptance Criteria
+
+1. The GROWI server shall incubating attribute(`service.instance.id`, `http.target`)を `semconv.ts` 内に文字列定数として定義し、ランタイムコードはこれを import する。
+2. The GROWI server shall `@opentelemetry/semantic-conventions/incubating` からの import をランタイムコードに含めない。
+
+### Requirement 10: 拡張・追加時の境界遵守
+
+**Objective:** 機能を追加・変更するエンジニアとして、新規 Custom Metric や新規 Anonymization Handler を本 spec の境界に従って実装し、レイヤ責務の汚染を回避したい。
+
+#### Acceptance Criteria
+
+1. When 新規 Custom Metric モジュールを追加する, the GROWI server shall `custom-metrics/` 配下にファイルを追加し、`addXxxMetrics(): void` をエクスポートし、`custom-metrics/index.ts` の `setupCustomMetrics()` から呼び出す。
+2. When 新規 Anonymization Handler を追加する, the GROWI server shall `anonymization/handlers/` 配下に `AnonymizationModule` 実装ファイルを追加し、`handlers/index.ts` の `anonymizationModules` 配列に登録する。
+3. The GROWI server shall identity 情報を Resource Attribute 経由で、設定値を `growi.configs` ラベル経由で、観測値を `growi.*` / `system.*` / `process.*` メトリクス経由でそれぞれ emit する責務分離を維持する。

+ 173 - 0
.kiro/specs/opentelemetry/research.md

@@ -0,0 +1,173 @@
+# Research & Design Decisions — opentelemetry
+
+## Summary
+
+- **Feature**: `opentelemetry`(`apps/app/src/features/opentelemetry/` の大局的メンテナンス spec)。
+- **Discovery Scope**: Extension/Refactor — 既存実装を保ったまま、4 レイヤの責務境界を明文化する。
+
+## Research Log
+
+### 既存 ObservableGauge 実装パターン
+
+- **Context**: 新規 Custom Metric を追加するときに既存パターンと整合させる必要がある。
+- **Sources Consulted**:
+  - `custom-metrics/application-metrics.ts`, `user-counts-metrics.ts`, `page-counts-metrics.ts`, `system-metrics.ts`。
+- **Findings**:
+  - 各モジュールは `addXxxMetrics(): void` を export する。
+  - `metrics.getMeter('growi-<scope>-metrics', '1.0.0')` で Meter を取得し、`meter.createObservableGauge(name, { description, unit })` で gauge を作る。
+  - 観測は `meter.addBatchObservableCallback(async (result) => { try { ... } catch (e) { loggerDiag.error(...) } }, [gauge, ...])` で登録。
+  - ロガー初期化: `loggerFactory('growi:opentelemetry:custom-metrics:<scope>')`(pino)と `diag.createComponentLogger({ namespace: 'growi:custom-metrics:<scope>' })`(OTel diag)の 2 つ。
+- **Implications**: 拡張テンプレートとしてこのパターンを design.md に記載済み(File Structure Plan の "Extension Templates")。
+
+### Anonymization Handler の登録順とパターン
+
+- **Context**: 新規 anonymization handler を追加するとき、`canHandle` の衝突を避ける必要がある。
+- **Sources Consulted**: `anonymization/handlers/index.ts`、各 handler の `canHandle` 実装。
+- **Findings**:
+  - 配列順 = 評価順だが、すべてが OR で集約される(複数 module が同一 URL を匿名化することは現状無いが、可能性としては存在する)。
+  - より具体的なパス(API 系)を先、汎用パス(page access)を最後に配置するのが現状の慣習。
+  - `canHandle` は副作用無しで判定のみ、`handle` は失敗時に `null` を返すか元の URL を維持する。
+- **Implications**: 新規 handler 追加時は、既存 4 handler の対象 URL と衝突しないかを `canHandle` ロジックで確認する。
+
+### `process.constrainedMemory()` の挙動
+
+- **Context**: コンテナ環境とそれ以外で挙動が異なるため、`system.memory.limit` の skip 条件を確定する必要がある。
+- **Sources Consulted**: Node.js v20.12 / v24 公式ドキュメント。
+- **Findings**:
+  - 戻り値: cgroup v1 / v2 から取得した「プロセスに割り当てられたメモリ上限のバイト数」。
+  - cgroup が未設定 / detection 失敗時 / macOS・Windows では `0` を返す(v24 でも継続)。
+  - Node.js v19.6 で導入、v20.12 で stable。
+- **Implications**: `value > 0`(falsy)で判定すれば、macOS・Windows・cgroup なし Linux すべてで一貫した「skip」挙動になる。
+
+### NodeSDK `_resource` への private アクセス
+
+- **Context**: 2 段階目の Resource を NodeSDK に注入する必要があり、public API が見当たらない。
+- **Sources Consulted**: `@opentelemetry/sdk-node` の TypeScript 型定義、`node-sdk-resource.ts`。
+- **Findings**:
+  - NodeSDK は constructor で受け取った resource を内部に保持するが、外部から書き換える public API は存在しない(`sdk-node 0.217.0` 時点)。
+  - `_resource` プロパティを直接書き換えることで、`start()` 前に Resource を差し替えられる。
+- **Implications**: `(sdk as any)._resource` への reflective アクセスを `getResource` / `setResource` で隔離。SDK のメジャー更新時に public API が出ていないか Revalidation Trigger として確認する。
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Custom ObservableGauge per layer | 自前で 4 Meter / 7+ gauge を実装し、`@opentelemetry/host-metrics` を採用しない | 完全制御、cgroup / V8 対応、追加 dep ゼロ、Meter ごとに spec 単位でテスト可能 | コード量増(〜500 行) | **採用** |
+| `@opentelemetry/host-metrics` 採用 | system / process メトリクスをコミュニティパッケージで自動 emit | 既製、ネットワーク・CPU も追加 | cgroup 未対応、V8 ヒープ非対応、不要メトリクス強制 emit、semconv 古い | 不採用(要件 5 未充足) |
+| Single Meter, all metrics | 全 7+ メトリクスを単一 Meter で束ねる | コードが小さい | 観測スコープ(business vs system)の責務が混在、テスト分離困難 | 不採用 |
+| 2-stage Resource initialization | DB 非依存 → DB 初期化後 の 2 段階で Resource を構築 | 循環依存回避、DB 接続前に SDK 部分起動可能 | `_resource` private アクセス必要 | **採用** |
+| Single-stage Resource | すべての Resource を DB 初期化後に作る | private アクセス不要 | OpenTelemetry の起動が DB 接続まで遅延、`service.name` などの基本属性も遅れる | 不採用 |
+| Module-based anonymization | `AnonymizationModule` interface + 配列順評価 | 新規パス追加が局所変更で済む、handler ごとに spec | 配列順への暗黙依存 | **採用** |
+| Centralized anonymization (switch / regex map) | 1 ファイルで if/else または map で振り分け | フローが見やすい | 拡張ごとに 1 ファイルが肥大化、spec が結合 | 不採用 |
+
+## Design Decisions
+
+### Decision: 4 レイヤの責務分離(identity / 設定 / 観測 / anonymization)
+
+- **Context**: Resource Attribute / Metric / Span Attribute それぞれの本来の用途を運用ガイドラインとして固定したい。
+- **Selected Approach**: 以下の 4 分類で責務を分離する。
+  - **identity**(不変または起動時固定) → Resource Attribute
+  - **設定値**(インスタンス設定の確認用、ラベル次元として参照する) → `growi.configs` info gauge ラベル
+  - **観測値**(時間と共に変化するスカラー) → `growi.*` / `system.*` / `process.*` ObservableGauge
+  - **span attribute**(リクエスト単位の情報、必要なら匿名化) → `http.target` 等 incubating semconv
+- **Rationale**: OpenTelemetry の data model(Resource / Metric / Span)に対する公式の意味論に沿う。Resource に measurement や設定値を載せると receiving side でカーディナリティ爆発・誤った集計の原因になる(特に Resource に乗ったホストメモリ量はコンテナ環境で「ホストの値」を返してしまい運用上の判断を誤らせる典型例)。
+- **Trade-offs**: 設計時の判断分岐が増えるが、ダッシュボード保守の堅牢性が大きく上がる。
+
+### Decision: `system.memory.limit` と `system.host.memory.total` を別メトリクスに分離
+
+- **Context**: コンテナ環境で「コンテナの上限」と「ホストの物理メモリ」のどちらを参照したいかは運用観点が異なる。
+- **Alternatives Considered**:
+  1. 単一メトリクス `system.memory.limit` を cgroup → fallback で `os.totalmem` にする。
+  2. `system.memory.limit` と `system.host.memory.total` を別メトリクスにする。
+- **Selected Approach**: 2。`system.memory.limit` は cgroup limit が取れたときのみ観測、`system.host.memory.total` は常に観測。
+- **Rationale**: 「コンテナ上限の有無」自体が運用上の情報。fallback されると bare-metal でも cgroup でも同じシリーズに混在し、ダッシュボードで見分けが付かない。
+- **Trade-offs**: 出力メトリクス数が 1 つ増えるが、運用観点での明瞭さが勝る。
+- **Follow-up**: ダッシュボード移行時の運用者向け説明に「cgroup limit 未設定では `system.memory.limit` が emit されない」を明記する。
+
+### Decision: サブシステム設定値(`attachment.type` 等)は `growi.configs` のラベルへ統合
+
+- **Context**: GROWI インスタンスの設定値(`wiki_type`, `external_auth_types`, `attachment_type` 等)を Resource Attribute に載せるか、専用 info-gauge のラベルに載せるかという選択。
+- **Alternatives Considered**:
+  1. Resource Attribute として emit する。
+  2. `growi.configs` ObservableGauge(値は常に 1)のラベルへ統合(Prometheus info パターン)。
+  3. 設定値ごとに独立した info gauge を新設する。
+- **Selected Approach**: 2。snake_case 統一の単一 info-gauge のラベル群として集約する。
+- **Rationale**: identity(Resource)と設定値を分離することで Resource を「テレメトリ発生元の不変識別子」として清潔に保てる。複数の設定値を 1 つの info-gauge に集約することで「インスタンス設定を 1 か所で見られる」運用が成立する。
+- **Trade-offs**: `growi.configs` のラベル数は機能追加と共に増える。各値が固定 enum 由来のためカーディナリティ影響は限定的。
+- **Follow-up**: 値の取得不能時は空文字 `''` フォールバックで統一する(`undefined` ラベル attribute が emit されないことを利用しない)。
+
+### Decision: `growi.deployment.type` は OTel 標準 `deployment.environment.name` に寄せない
+
+- **Context**: OTel 標準には `deployment.environment.name`("production"/"staging" 等)があるが、GROWI の `growi.deployment.type`("docker"/"k8s"/"growi-docker-compose" 等)はランタイム形態を表し、環境分類とは別概念。
+- **Selected Approach**: `growi.deployment.type` のまま据え置く(Resource Attribute)。
+- **Rationale**: 値の意味が semconv 標準と乖離するため、無理に標準名を当てると誤解を招く。
+- **Follow-up**: 将来的に「環境(prod/stg)」の表現が必要になった時点で、別途 `deployment.environment.name` を追加導入する。
+
+### Decision: 単一 Meter `growi-system-metrics` で system / process / V8 を束ねる
+
+- **Context**: 既存パターンでは目的別に Meter を分けている(application / user-counts / page-counts)。System / Process / V8 のメトリクス群も同様に分けるか統合するかの判断が必要。
+- **Selected Approach**: System / Process / V8 を `growi-system-metrics` 単一 Meter で束ねる。
+- **Rationale**: いずれも「ランタイム / ホストのリソース観測」という単一目的で、`system.*`/`process.*` の prefix で十分名前空間が分離できる。Meter を分けると `addBatchObservableCallback` の呼び出しと spec も二重になり管理コスト増。
+- **Trade-offs**: 将来「process 系のみオフにする」のような細かい制御が困難になるが、現時点で必要性なし。
+
+### Decision: Anonymization は best-effort, module-based, opt-in
+
+- **Context**: 個人情報(検索クエリ・ページパス・ユーザー名)が `http.target` 経由でトレースに残るリスクを下げたいが、auto-instrumentation の挙動を完全に制御することはできない。
+- **Selected Approach**:
+  1. `otel:anonymizeInBestEffort` が `true` のときのみ `startIncomingSpanHook` を注入。
+  2. handler は `AnonymizationModule` interface に従い、`canHandle` で対象選別 / `handle` で attribute を返す。
+  3. 4 つの handler を配列順で評価し、複数 module がマッチしたら `Object.assign` でマージ。
+- **Rationale**: opt-in にすることで導入リスクを抑え、module 化により拡張時の差分が局所化される。
+- **Trade-offs**: 配列順への暗黙依存があり、追加時に既存 handler との衝突確認が必要。
+
+### Decision: SemConv の不安定 attribute は `semconv.ts` にコピー
+
+- **Context**: `@opentelemetry/semantic-conventions/incubating` は minor リリースで破壊的変更を含む可能性があるとアナウンスされている。
+- **Selected Approach**: `service.instance.id`, `http.target` をローカル定数として保持し、ランタイムコードからは local file のみを import する。
+- **Rationale**: OpenTelemetry の[公式推奨](https://opentelemetry.io/docs/specs/semconv/non-normative/code-generation/#stability-and-versioning)に沿う。
+- **Follow-up**: 該当 attribute が stable promotion されたら、stable import に切り替えて local 定数を撤去(Revalidation Trigger)。
+
+### Decision: Metric export interval は 5 分
+
+- **Context**: メトリクス export 頻度は OTLP 帯域と receiving side の負荷、観測解像度のトレードオフ。
+- **Selected Approach**: `PeriodicExportingMetricReader` の `exportIntervalMillis` を 300000(5 分)に設定。
+- **Rationale**: GROWI のメトリクスは business カウント(users / pages)と config 情報が中心で、秒オーダーの解像度は不要。export 頻度を下げることで OTLP 帯域と receiving side の負荷を抑える。
+- **Trade-offs**: メモリ使用量の急変は最大 5 分遅れて観測される。OOM 直前検知などの用途には不十分だが、本 spec の範囲ではトレードオフを受容する。
+
+### Decision: Auto-instrumentation は pino と fs を除外
+
+- **Context**: `getNodeAutoInstrumentations()` を全有効化すると pino log と fs operation がトレース化される。
+- **Selected Approach**: `@opentelemetry/instrumentation-pino` と `@opentelemetry/instrumentation-fs` を `enabled: false` で明示的に無効化。
+- **Rationale**:
+  - **pino**: GROWI は log signal を OTel に送らない。pino instrumentation はトレースに log を相関させる目的だが、現状は使用しない。
+  - **fs**: ファイル I/O が極めて頻繁で、有効化すると span 量が膨大になる。OpenTelemetry 公式の[ガイド](https://opentelemetry.io/docs/languages/js/libraries/#registration)も無効化を推奨。
+
+### Decision: `service.instance.id` は config 値の passthrough、自動生成しない
+
+- **Context**: OTel SDK には `service.instance.id` を UUID 等で自動生成する resource detector があるが、GROWI ではどう扱うか。
+- **Selected Approach**: `otel:serviceInstanceId`(env: `OPENTELEMETRY_SERVICE_INSTANCE_ID`)を優先、フォールバックで `app:serviceInstanceId`(DB 由来)を使用。両方 undefined の場合は emit しない。
+- **Rationale**: 自動生成すると再起動ごとに ID が変わり「同じ GROWI インスタンス」の経時観測が困難になる。明示的に与えられた ID のみを passthrough することで、運用者がレプリカの境界を制御できる。
+- **Trade-offs**: ID 未指定時に emit されないため、レプリカ識別が必要なクエリは値の有無を考慮する必要がある。
+
+## Risks & Mitigations
+
+- **下流ダッシュボードの参照切れ**: 既存 Resource Attribute / Metric を将来変更した場合、receiving side のクエリが値を返さなくなる。**Mitigation**: PR 説明とリリースノートに「Removed → Replaced by」の対応表を記載する慣習を維持する。
+- **`process.constrainedMemory()` のプラットフォーム依存**: Linux cgroup v1/v2 のみサポートで、macOS/Windows では常に 0 を返す。**Mitigation**: 0 のときは `system.memory.limit` を観測しない挙動が、そのまま非対応プラットフォームの振る舞いと一致するため追加対策不要。
+- **新規メトリクスのカーディナリティ**: 観測値メトリクスは label を持たない gauge であり、追加カーディナリティ寄与はインスタンス分のみ。**Mitigation**: 設計上、観測値メトリクスには attribute を付与しないことを徹底(identity は Resource、設定値は `growi.configs` ラベル経由)。
+- **NodeSDK private アクセスの破綻**: `_resource` プロパティが SDK メジャー更新で消滅する可能性。**Mitigation**: Revalidation Trigger として SDK バージョンアップ時にチェック。public API が出たら即座に切り替え。
+- **Anonymization の網羅性不足**: 新規 API パスが追加されたとき、対応する handler を忘れると平文の URL が span に残る。**Mitigation**: 新規 API 追加時のレビューで `anonymization/handlers/` の更新有無を確認する文化を維持。`handlers/index.ts` の `anonymizationModules` 配列が単一の真実ソース。
+- **SemConv 不安定 attribute の stable promotion 漏れ**: `service.instance.id` / `http.target` が stable 化されているのに local 定数を放置すると、最新 OTLP 受信側との互換性が崩れる可能性。**Mitigation**: `@opentelemetry/semantic-conventions` メジャー / minor 更新時に Revalidation Trigger で見直す。
+
+## References
+
+- [OpenTelemetry Node.js SDK](https://open-telemetry.github.io/opentelemetry-js/)
+- [Custom Metrics Documentation](https://opentelemetry.io/docs/instrumentation/js/manual/#creating-metrics)
+- [HTTP Instrumentation Configuration](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http#configuration)
+- [Semantic Conventions for System Metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/system/system-metrics.md)
+- [Semantic Conventions for Process](https://opentelemetry.io/docs/specs/semconv/runtime-environment/process/)
+- [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md)
+- [SemConv Stability and Versioning](https://opentelemetry.io/docs/specs/semconv/non-normative/code-generation/#stability-and-versioning) — incubating attribute のローカルコピー推奨。
+- [Node.js process.constrainedMemory()](https://nodejs.org/api/process.html#processconstrainedmemory) — cgroup ベースのメモリ上限取得 API。
+- [Node.js v8.getHeapStatistics()](https://nodejs.org/api/v8.html#v8getheapstatistics) — V8 ヒープ統計取得 API。
+- [OpenTelemetry — disabling instrumentations](https://opentelemetry.io/docs/languages/js/libraries/#registration) — fs instrumentation の無効化推奨。
+- 既存実装: `apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts` — ObservableGauge + addBatchObservableCallback のリファレンス実装。

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

@@ -0,0 +1,23 @@
+{
+  "feature_name": "opentelemetry",
+  "created_at": "2026-05-21T00:00:00.000Z",
+  "updated_at": "2026-05-21T13:20:00.000Z",
+  "language": "ja",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleaned_up_at": "2026-05-21T13:20:00.000Z"
+}

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

@@ -0,0 +1,29 @@
+# Implementation Plan
+
+本 spec は `features/opentelemetry/` の **大局的なメンテナンス spec** であり、新規実装タスクを抱えないドキュメント spec として扱う。実装は既に完了しており、本ファイルは「将来の拡張時に踏むテンプレート」と「Revalidation の手順」を記録する。
+
+## Implementation Notes
+
+### 拡張時のテンプレート参照
+
+新規 Custom Metric / Anonymization Handler の追加手順は [design.md](./design.md) の **File Structure Plan → Extension Templates** を参照する。テンプレートに沿って実装することで、レビューでの差分が局所化され、本 spec の Boundary Commitments を逸脱しない。
+
+### Revalidation 必要時の対応フロー
+
+[design.md](./design.md) の **Boundary Commitments → Revalidation Triggers** に列挙された条件のいずれかが発生したら、以下を順次実施する:
+
+1. 該当する Boundary Commitments セクションを読み返し、変更が境界内で完結するかを評価。
+2. 境界をまたぐ場合は新規 spec として切り出すか、本 spec の Revalidation Triggers と Design Decisions を更新する。
+3. 受信側ダッシュボード / クエリへの影響がある場合は、PR 説明 / リリースノートに「Removed → Replaced by」の対応表を添える。
+
+### 将来の取り扱い候補(Out of Boundary の再評価候補)
+
+以下は本 spec の Out of Boundary に該当するが、将来の要望次第で別 spec として切り出す候補:
+
+- OpenTelemetry Log Signal の利用開始(pino との統合)。
+- CPU / network / GC / event-loop lag メトリクスの追加。
+- `deployment.environment.name`(OTel 標準)への対応。
+- ブラウザ telemetry(Web SDK)の導入。
+- `@opentelemetry/host-metrics` への置き換え(要件 5 を満たせる版がリリースされたら)。
+
+これらは現時点では要件として上がっていないため、追加要望時に新規 spec を起こす。

+ 100 - 0
apps/app/.claude/skills/app-commands/SKILL.md

@@ -187,6 +187,106 @@ Development uses `dotenv-flow`:
 
 See `.env.example` for available variables.
 
+## Smoke Testing
+
+The devcontainer always has MongoDB and other services running (see `.claude/rules/devcontainer.md`). The dev server **can and should** be started for smoke verification — never claim the runtime environment is unavailable.
+
+### Workflow
+
+**Step 1 — Override env vars without touching committed files**
+
+Create `apps/app/.env.development.local` (highest dotenv-flow priority; gitignored):
+
+```bash
+# Example: disable vault feature to test 404 behaviour
+cat > apps/app/.env.development.local << 'EOF'
+VAULT_ENABLED=false
+EOF
+```
+
+dotenv-flow load order (first definition wins):
+1. `.env.development.local` ← your override
+2. `.env.local`
+3. `.env.development` ← committed defaults
+4. `.env`
+
+> **Note:** nodemon watches `*.*` but does **not** reliably pick up dotfile changes (files starting with `.`). After editing `.env.development.local`, kill the ts-node process manually so nodemon restarts it with the new env:
+> ```bash
+> kill $(ss -tlnp | grep ':3000' | grep -o 'pid=[0-9]*' | cut -d= -f2)
+> ```
+
+**Step 2 — Start the dev server in background**
+
+```bash
+turbo run dev --filter @growi/app &
+```
+
+Wait for the ready message:
+```bash
+until curl -s http://localhost:3000/ > /dev/null 2>&1; do sleep 1; done
+echo "Server ready"
+```
+
+Or watch the log for `Express server is listening on port 3000`.
+
+**Step 3 — Curl the endpoints**
+
+```bash
+# Feature disabled → 404 (no Retry-After)
+curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/_vault/repo.git/info/refs?service=git-upload-pack
+
+# Push attempt → always 403
+curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3000/_vault/repo.git/git-receive-pack
+
+# Check response body
+curl -s http://localhost:3000/_vault/repo.git/info/refs?service=git-upload-pack
+
+# Check specific headers
+curl -sI http://localhost:3000/_vault/repo.git/info/refs?service=git-upload-pack | grep -i retry-after
+```
+
+**Step 4 — Switch env and retest**
+
+Edit `.env.development.local`, then kill and wait for nodemon to restart:
+
+```bash
+echo "VAULT_ENABLED=true" > apps/app/.env.development.local
+kill $(ss -tlnp | grep ':3000' | grep -o 'pid=[0-9]*' | cut -d= -f2)
+until curl -s http://localhost:3000/ > /dev/null 2>&1; do sleep 1; done
+```
+
+**Step 5 — Manipulate MongoDB state if needed**
+
+```bash
+node -e "
+const { MongoClient } = require('/workspace/growi-vault/node_modules/.pnpm/mongodb@6.8.0_@aws-sdk+credential-providers@3.600.0_@aws-sdk+client-sso-oidc@3.600.0__socks@2.8.3/node_modules/mongodb');
+async function main() {
+  const client = new MongoClient('mongodb://mongo:27017/growi?replicaSet=rs0');
+  await client.connect();
+  // e.g. reset bootstrap state
+  await client.db('growi').collection('vault_sync_state').updateOne(
+    { _id: 'singleton' },
+    { \$set: { bootstrapState: 'pending' } },
+    { upsert: true }
+  );
+  await client.close();
+}
+main().catch(console.error);
+"
+```
+
+**Step 6 — Stop the server**
+
+```bash
+kill $(pgrep -f "nodemon|src/server/app.ts") 2>/dev/null
+```
+
+### What counts as a passing smoke test
+
+- The Express server starts without throwing on import (`Express server is listening on port 3000` in logs)
+- Feature-flag–gated endpoints return the correct status code for each flag state (404 when disabled, 503 with the right message when bootstrap incomplete, 403 for read-only enforcement)
+- No unhandled exception in server startup logs
+
 ## Troubleshooting
 
 ### Migration Issues

+ 3 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.5.3",
+  "version": "7.5.4-RC.0",
   "license": "MIT",
   "private": true,
   "scripts": {
@@ -78,8 +78,8 @@
     "@codemirror/language": "^6.12.1",
     "@codemirror/language-data": "^6.5.1",
     "@codemirror/merge": "^6.8.0",
-    "@codemirror/state": "^6.5.2",
-    "@codemirror/view": "^6.39.14",
+    "@codemirror/state": "^6.6.0",
+    "@codemirror/view": "^6.42.1",
     "@cspell/dynamic-import": "^8.15.4",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.4",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.18.2",

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

+ 11 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "Your upgrade has been successfully completed! Exit maintenance mode and GROWI can be used.",
     "migration_failed": "Upgrade failed. Please refer to the GROWI docs for information on what to do in the event of failure."
   },
+  "news_delivery": {
+    "section_title": "News delivery",
+    "label": "News delivery",
+    "enable": "Enable news delivery",
+    "description": "Controls whether the cron job pulls the news feed and updates the local cache. Existing cached items remain visible while delivery is disabled.",
+    "update_succeeded": "News delivery setting updated"
+  },
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
     "under_maintenance_mode": "Under Maintenance Mode",
@@ -369,6 +376,10 @@
     "page_bulk_export_explanation": "Enables a feature that allows all users to export a page and all it's child pages at once from the menu. Exported data will be automatically deleted after the storage period has passed.",
     "page_bulk_export_warning": "The bulk page export feature is available to all users. In order to maintain system resources, we ask for your cooperation in using the minimum amount necessary. If you are an administrator, please inform all users of this.",
     "page_bulk_export_storage_period": "Storage period",
+    "page_bulk_export_reload_title": "Reload page",
+    "page_bulk_export_reload_prompt": "You need to reload the page to reflect the setting change. Reload now?",
+    "page_bulk_export_reload_dismiss": "Not now",
+    "reload_page": "Reload",
     "update": "Update",
     "mail_settings": "E-mail Settings",
     "mailer_is_not_set_up": "E-mail setting is not set up.",

+ 9 - 3
apps/app/public/static/locales/en_US/commons.json

@@ -58,7 +58,11 @@
     "unopend": "Unread",
     "mark_all_as_read": "Mark all as read",
     "no_unread_messages": "no_unread_messages",
-    "only_unread": "Only unread"
+    "only_unread": "Only unread",
+    "news": "News",
+    "notifications": "Notifications",
+    "filter_all": "All",
+    "no_news": "No news available"
   },
   "personal_dropdown": {
     "home": "Home",
@@ -167,7 +171,8 @@
         "share_link": "Grants permission to view share link features.",
         "bookmark": "Grants permission to view bookmark features.",
         "attachment": "Grants permission to view attachment features.",
-        "page_bulk_export": "Grants permission to view page bulk export features."
+        "page_bulk_export": "Grants permission to view page bulk export features.",
+        "in_app_notification": "Grants permission to view in-app notification features."
       }
     },
     "write": {
@@ -212,7 +217,8 @@
         "share_link": "Grants permission to edit share link features.",
         "bookmark": "Grants permission to edit bookmark features.",
         "attachment": "Grants permission to edit attachment features.",
-        "page_bulk_export": "Grants permission to edit page bulk export features."
+        "page_bulk_export": "Grants permission to edit page bulk export features.",
+        "in_app_notification": "Grants permission to edit in-app notification features."
       }
     }
   }

+ 75 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -1093,6 +1093,7 @@
     "checklist": "Checklist",
     "code": "Code",
     "diagram": "Diagram",
+    "editor_guide": "Editor Guide",
     "emoji": "Emoji",
     "heading": "Heading",
     "italic": "Italic",
@@ -1102,5 +1103,79 @@
     "table": "Table",
     "template": "Template",
     "text_formatting": "Text Formatting"
+  },
+  "editor_guide": {
+    "title": "Editor Guide",
+    "tabs": {
+      "textstyle": "Text Style",
+      "layout": "Layout",
+      "decoration": "Decoration"
+    },
+    "textstyle": {
+      "copy_done": "Copied!",
+      "this": "This is",
+      "is": "",
+      "bold": "bold",
+      "italic": "italic",
+      "strikethrough": "strikethrough",
+      "inline_code": "inline code",
+      "bold_italic": "Bold or italic all",
+      "emoji": "Emoji",
+      "sub_sup": "Subscript / Superscript",
+      "link_label": "Link with label",
+      "link_docs": "GROWI Documentation",
+      "link_growi": "GROWI Link",
+      "link_sandbox": "Sandbox page is here",
+      "all_important": "This text is all\nimportant",
+      "sub_text": "subscript",
+      "sup_text": "superscript",
+      "is_text": "This is {{val}}"
+    },
+    "layout": {
+      "copy_done": "Copied!",
+      "header": "Headers",
+      "header_text": "Heading",
+      "list": "Unordered List",
+      "list_text": "This is a list item",
+      "ordered_list": "Ordered List",
+      "ordered_list_text": "This is a numbered item",
+      "checkbox": "Checkboxes",
+      "task": "Task ",
+      "quote": "Blockquotes",
+      "quote_text": "Quote",
+      "multi_quote": "Nested Quote",
+      "hr": "Horizontal Rule",
+      "br": "Line Break",
+      "br_code": "Add two spaces  to break\nthe line",
+      "br_preview_1": "Add two spaces to",
+      "br_preview_2": "break the line",
+      "code_block": "Code Blocks",
+      "code_block_text": "Insert code here",
+      "table": "Tables",
+      "left": "Align Left",
+      "right": "Align Right",
+      "center": "Align Center",
+      "row_text": "This column is",
+      "row_display": "aligned",
+      "footnote": "Footnotes",
+      "footnote_label": "Footnote text",
+      "footnote_desc": "Write the note content like this"
+    },
+    "decoration": {
+      "copy_done": "Copied to clipboard!",
+      "style": "Style",
+      "alert": "Alert",
+      "alert_with_custom_title": "Alert with label",
+      "alert_with_custom_title_text": "Custom Title",
+      "alert_unavailable": "Unavailable in this style",
+      "badge": "Badge",
+      "text_color": "Text Color",
+      "back_color": "Background Color",
+      "placeholder": "Sample text goes here",
+      "docs_title": "Bootstrap 5 Official Documentation",
+      "docs_badge": "Learn more about Badges",
+      "docs_color": "Learn more about Colors",
+      "docs_alert": "Learn more about Alert Blocks"
+    }
   }
 }

+ 11 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "Conversion réussie! Le mode maintenance peut être désactivée et GROWI utilisé.",
     "migration_failed": "Conversion échouée. Lire la documentation GROWI pour des informations supplémentaires."
   },
+  "news_delivery": {
+    "section_title": "Diffusion des actualités",
+    "label": "Diffusion des actualités",
+    "enable": "Activer la diffusion des actualités",
+    "description": "Contrôle si la tâche cron récupère le flux d'actualités et met à jour le cache local. Les actualités déjà en cache restent visibles lorsque la diffusion est désactivée.",
+    "update_succeeded": "Paramètre de diffusion des actualités mis à jour"
+  },
   "maintenance_mode": {
     "maintenance_mode": "Mode maintenance",
     "under_maintenance_mode": "Mode maintenance activé",
@@ -369,6 +376,10 @@
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
     "page_bulk_export_warning": "La fonctionnalité d’exportation de pages en masse est disponible pour tous les utilisateurs. Afin de maintenir les ressources du système, nous demandons votre coopération pour utiliser le montant minimum nécessaire. Si vous êtes administrateur, veuillez en informer tous les utilisateurs.",
     "page_bulk_export_storage_period": "Date limite de téléchargement",
+    "page_bulk_export_reload_title": "Recharger la page",
+    "page_bulk_export_reload_prompt": "Un rechargement de la page est nécessaire pour appliquer le changement de paramètre. Recharger maintenant ?",
+    "page_bulk_export_reload_dismiss": "Pas maintenant",
+    "reload_page": "Recharger",
     "update": "Sauvegarder",
     "mail_settings": "SMTP",
     "mailer_is_not_set_up": "Paramètres d'envoi de courriels non configurés.",

+ 9 - 3
apps/app/public/static/locales/fr_FR/commons.json

@@ -59,7 +59,11 @@
     "all": "Toutes",
     "unopend": "Non-lues",
     "mark_all_as_read": "Tout marquer comme lu",
-    "no_unread_messages": "aucun message non lu"
+    "no_unread_messages": "aucun message non lu",
+    "news": "Actualités",
+    "notifications": "Notifications",
+    "filter_all": "Tout",
+    "no_news": "Aucune actualité disponible"
   },
   "personal_dropdown": {
     "home": "Accueil",
@@ -168,7 +172,8 @@
         "share_link": "Accorde la permission de voir les fonctionnalités de lien de partage.",
         "bookmark": "Accorde la permission de voir les fonctionnalités de signet.",
         "attachment": "Accorde la permission de voir les fonctionnalités de pièce jointe.",
-        "page_bulk_export": "Accorde la permission de voir les fonctionnalités d'exportation en masse de pages."
+        "page_bulk_export": "Accorde la permission de voir les fonctionnalités d'exportation en masse de pages.",
+        "in_app_notification": "Accorde la permission de voir les fonctionnalités de notification intégrée à l'application."
       }
     },
     "write": {
@@ -213,7 +218,8 @@
         "share_link": "Accorde la permission de modifier les fonctionnalités de lien de partage.",
         "bookmark": "Accorde la permission de modifier les fonctionnalités de signet.",
         "attachment": "Accorde la permission de modifier les fonctionnalités de pièce jointe.",
-        "page_bulk_export": "Accorde la permission de modifier les fonctionnalités d'exportation en masse de pages."
+        "page_bulk_export": "Accorde la permission de modifier les fonctionnalités d'exportation en masse de pages.",
+        "in_app_notification": "Accorde la permission de modifier les fonctionnalités de notification intégrée à l'application."
       }
     }
   }

+ 76 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -1085,6 +1085,7 @@
     "checklist": "Liste de contrôle",
     "code": "Code",
     "diagram": "Diagramme",
+    "editor_guide": "Guide de l'Éditeur",
     "emoji": "Emoji",
     "heading": "Titre",
     "italic": "Italique",
@@ -1094,5 +1095,80 @@
     "table": "Tableau",
     "template": "Modèle",
     "text_formatting": "Mise en forme du texte"
+  },
+  "editor_guide": {
+    "title": "Guide de l'Éditeur",
+    "tabs": {
+      "textstyle": "Style de Texte",
+      "layout": "Mise en Page",
+      "decoration": "Décoration"
+    },
+    "textstyle": {
+      "copy_done": "Copié !",
+      "this": "Ceci est du",
+      "is": "",
+      "bold": "gras",
+      "italic": "italique",
+      "strikethrough": "barré",
+      "inline_code": "code en ligne",
+      "bold_italic": "Tout en gras ou italique",
+      "emoji": "Émoticône",
+      "sub_sup": "Indice / Exposant",
+      "link_label": "Lien avec étiquette",
+      "link_docs": "Documentation GROWI",
+      "link_growi": "Lien GROWI",
+      "link_sandbox": "La page bac à sable est ici",
+      "all_important": "Tout ce texte est\nimportant",
+      "sub_text": "indice",
+      "sup_text": "exposant",
+      "is_text": "Ceci est du {{val}}"
+    },
+    "layout": {
+      "copy_done": "Copié !",
+      "header": "En-têtes",
+      "header_text": "Titre",
+      "list": "Liste à puces",
+      "list_text": "Ceci est un élément de liste",
+      "ordered_list": "Liste ordonnée",
+      "ordered_list_text": "Ceci est un élément numéroté",
+      "checkbox": "Cases à cocher",
+      "task": "Tâche ",
+      "quote": "Citations",
+      "quote_text": "Citation",
+      "multi_quote": "Citation imbriquée",
+      "hr": "Ligne horizontale",
+      "br": "Saut de ligne",
+      "br_code": "Ajoutez deux espaces  pour\nrompre la ligne",
+      "br_preview_1": "Ajoutez deux espaces pour",
+      "br_preview_2": "rompre la ligne",
+      "code_block": "Blocs de code",
+      "code_block_text": "Insérez le code ici",
+      "table": "Tableaux",
+      "left": "Aligner à gauche",
+      "right": "Aligner à droite",
+      "center": "Aligner au centre",
+      "row_text": "Cette colonne est",
+      "row_display": "alignée",
+      "footnote": "Notes de bas de page",
+      "footnote_label": "Texte avec note",
+      "footnote_desc": "Écrivez le contenu de la note comme ceci"
+    },
+    "decoration": {
+      "copy_done": "Copié dans le presse-papiers !",
+      "style": "Style",
+      "alert": "Alerte",
+      "alert_with_custom_title": "Alerte avec étiquette",
+      "alert_with_custom_title_text": "Titre personnalisé",
+      "alert_unavailable": "Non disponible pour ce style",
+      "badge": "Badge",
+      "text_color": "Couleur du texte",
+      "back_color": "Couleur d'arrière-plan",
+
+      "placeholder": "Le texte s'affiche ici",
+      "docs_title": "Documentation officielle de Bootstrap 5",
+      "docs_badge": "En savoir plus sur les Badges",
+      "docs_color": "En savoir plus sur les Couleurs",
+      "docs_alert": "En savoir plus sur les Blocs d'alerte"
+    }
   }
 }

+ 11 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -341,6 +341,13 @@
     "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
     "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
   },
+  "news_delivery": {
+    "section_title": "ニュース配信",
+    "label": "ニュース配信",
+    "enable": "ニュース配信を有効にする",
+    "description": "外部フィードからニュースを取得してローカルにキャッシュする cron ジョブの動作を制御します。無効にしてもキャッシュ済みのニュースは引き続き表示されます。",
+    "update_succeeded": "ニュース配信設定を更新しました"
+  },
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
     "under_maintenance_mode": "メンテナンスモード中",
@@ -378,6 +385,10 @@
     "page_bulk_export_explanation": "すべてのユーザーが、ページメニューから選択したページとその配下ページをまとめてエクスポートできる機能を有効化します。エクスポートされたデータは保存期間経過後に自動的に削除されます。",
     "page_bulk_export_warning": "ページ一括エクスポート機能は全ユーザーが利用可能です。システムリソースの維持のため、必要最小限の利用にご協力をお願いいたします。管理者の方は、この旨をユーザーの皆様にご周知ください。",
     "page_bulk_export_storage_period": "保存期間",
+    "page_bulk_export_reload_title": "ページの再読み込み",
+    "page_bulk_export_reload_prompt": "設定を反映するにはページの再読み込みが必要です。今すぐ再読み込みしますか?",
+    "page_bulk_export_reload_dismiss": "今はしない",
+    "reload_page": "再読み込み",
     "update": "更新",
     "mail_settings": "メールの設定",
     "mailer_is_not_set_up": "メール設定がセットアップされていません。",

+ 9 - 3
apps/app/public/static/locales/ja_JP/commons.json

@@ -61,7 +61,11 @@
     "unopend": "未読",
     "mark_all_as_read": "全て既読にする",
     "no_unread_messages": "未読はありません",
-    "only_unread": "未読のみ"
+    "only_unread": "未読のみ",
+    "news": "お知らせ",
+    "notifications": "通知",
+    "filter_all": "すべて",
+    "no_news": "ニュースはありません"
   },
   "personal_dropdown": {
     "home": "ホーム",
@@ -171,7 +175,8 @@
         "share_link": "共有リンク機能の閲覧権限を付与できます。",
         "bookmark": "ブックマーク機能の閲覧権限を付与できます。",
         "attachment": "添付ファイル機能の閲覧権限を付与できます。",
-        "page_bulk_export": "ページの一括エクスポート機能の閲覧権限を付与できます。"
+        "page_bulk_export": "ページの一括エクスポート機能の閲覧権限を付与できます。",
+        "in_app_notification": "アプリ内通知機能の閲覧権限を付与できます。"
       }
     },
     "write": {
@@ -216,7 +221,8 @@
         "share_link": "共有リンク機能の編集権限を付与できます。",
         "bookmark": "ブックマーク機能の編集権限を付与できます。",
         "attachment": "添付ファイル機能の編集権限を付与できます。",
-        "page_bulk_export": "ページの一括エクスポート機能の編集権限を付与できます。"
+        "page_bulk_export": "ページの一括エクスポート機能の編集権限を付与できます。",
+        "in_app_notification": "アプリ内通知機能の編集権限を付与できます。"
       }
     }
   }

+ 76 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -1126,6 +1126,7 @@
     "checklist": "チェックリスト",
     "code": "コード",
     "diagram": "ダイアグラム",
+    "editor_guide": "エディターガイド",
     "emoji": "絵文字",
     "heading": "見出し",
     "italic": "イタリック",
@@ -1135,5 +1136,80 @@
     "table": "テーブル",
     "template": "テンプレート",
     "text_formatting": "テキスト書式"
+  },
+  "editor_guide": {
+    "title": "エディターガイド",
+    "tabs": {
+      "textstyle": "テキストスタイル",
+      "layout": "レイアウト",
+      "decoration": "装飾"
+    },
+    "textstyle": {
+      "copy_done": "コピーしました!",
+      "this": "これは",
+      "is": "です",
+      "bold": "太字",
+      "italic": "斜体",
+      "strikethrough": "取り消し線",
+      "inline_code": "インラインコード",
+      "bold_italic": "全体が太字か斜体",
+      "emoji": "絵文字",
+      "sub_sup": "下付き・上付き",
+      "link_label": "ラベル付きリンク",
+      "link_docs": "GROWI ドキュメント",
+      "link_growi": "GROWIのリンク",
+      "link_sandbox": "砂場ページはこちら",
+      "all_important": "このテキストはすべて\n重要です",
+      "sub_text": "下付き",
+      "sup_text": "上付き",
+      "is_text": "これは{{val}}です"
+    },
+    "layout": {
+      "copy_done": "コピーしました!",
+      "header": "ヘッダー",
+      "header_text": "見出し",
+      "list": "箇条書きリスト",
+      "list_text": "箇条書きリストです",
+      "ordered_list": "番号付きリスト",
+      "ordered_list_text": "番号付きリストです",
+      "checkbox": "チェックボックス",
+      "task": "タスク",
+      "quote": "引用",
+      "quote_text": "引用",
+      "multi_quote": "多重引用",
+      "hr": "水平線",
+      "br": "改行",
+      "br_code": "スペース2つ入れると  改行\nできます",
+      "br_preview_1": "スペース2つ入れると",
+      "br_preview_2": "改行できます",
+      "code_block": "コードブロック",
+      "code_block_text": "ここにコードを追加",
+      "table": "表",
+      "left": "左揃え",
+      "right": "右揃え",
+      "center": "中央揃え",
+      "row_text": "この列は",
+      "row_display": "で表示されます",
+      "footnote": "脚注",
+      "footnote_label": "脚注つきテキスト",
+      "footnote_desc": "注記はこのように書きます"
+    },
+    "decoration": {
+      "copy_done": "コピーしました!",
+      "style": "スタイル",
+      "alert": "アラート",
+      "alert_with_custom_title": "ラベル付きアラート",
+      "alert_with_custom_title_text": "カスタムタイトル",
+      "alert_unavailable": "このスタイルでは使用できません",
+      "badge": "バッジ",
+      "text_color": "テキストカラー",
+      "back_color": "背景色",
+
+      "placeholder": "テキストが入ります",
+      "docs_title": "Bootstrap5 公式ドキュメント",
+      "docs_badge": "バッジの詳細はこちら",
+      "docs_color": "カラーの詳細はこちら",
+      "docs_alert": "アラートブロックの詳細はこちら"
+    }
   }
 }

+ 11 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "업그레이드가 성공적으로 완료되었습니다! 유지 보수 모드를 종료하면 GROWI를 사용할 수 있습니다.",
     "migration_failed": "업그레이드 실패. 실패 시 수행할 작업에 대한 정보는 GROWI 문서를 참조하십시오."
   },
+  "news_delivery": {
+    "section_title": "뉴스 배포",
+    "label": "뉴스 배포",
+    "enable": "뉴스 배포 활성화",
+    "description": "외부 피드에서 뉴스를 가져와 로컬 캐시를 업데이트하는 cron 작업의 동작을 제어합니다. 비활성화해도 캐시된 뉴스는 계속 표시됩니다.",
+    "update_succeeded": "뉴스 배포 설정이 업데이트되었습니다"
+  },
   "maintenance_mode": {
     "maintenance_mode": "유지 보수 모드",
     "under_maintenance_mode": "유지 보수 모드 중",
@@ -369,6 +376,10 @@
     "page_bulk_export_explanation": "모든 사용자가 메뉴에서 한 번에 페이지와 모든 하위 페이지를 내보낼 수 있는 기능을 활성화합니다. 내보낸 데이터는 저장 기간이 지나면 자동으로 삭제됩니다.",
     "page_bulk_export_warning": "대량 페이지 내보내기 기능은 모든 사용자에게 제공됩니다. 시스템 리소스 유지를 위해 최소한의 사용을 부탁드립니다. 관리자라면 모든 사용자에게 이 사실을 알려주십시오.",
     "page_bulk_export_storage_period": "저장 기간",
+    "page_bulk_export_reload_title": "페이지 다시 로드",
+    "page_bulk_export_reload_prompt": "설정을 반영하려면 페이지를 다시 로드해야 합니다. 지금 다시 로드하시겠습니까?",
+    "page_bulk_export_reload_dismiss": "지금 하지 않음",
+    "reload_page": "다시 로드",
     "update": "업데이트",
     "mail_settings": "이메일 설정",
     "mailer_is_not_set_up": "이메일 설정이 되어 있지 않습니다.",

+ 5 - 1
apps/app/public/static/locales/ko_KR/commons.json

@@ -58,7 +58,11 @@
     "unopend": "읽지 않음",
     "mark_all_as_read": "모두 읽음으로 표시",
     "no_unread_messages": "읽지 않은 메시지 없음",
-    "only_unread": "읽지 않은 메시지만"
+    "only_unread": "읽지 않은 메시지만",
+    "news": "공지사항",
+    "notifications": "알림",
+    "filter_all": "전체",
+    "no_news": "공지사항이 없습니다"
   },
   "personal_dropdown": {
     "home": "홈",

+ 75 - 0
apps/app/public/static/locales/ko_KR/translation.json

@@ -1062,5 +1062,80 @@
     "table": "표",
     "template": "템플릿",
     "text_formatting": "텍스트 서식"
+  },
+  "editor_guide": {
+    "title": "에디터 가이드",
+    "tabs": {
+      "textstyle": "텍스트 스타일",
+      "layout": "레이아웃",
+      "decoration": "데코레이션"
+    },
+    "textstyle": {
+      "copy_done": "복사되었습니다!",
+      "this": "이것은",
+      "is": "입니다",
+      "bold": "굵게",
+      "italic": "기울임꼴",
+      "strikethrough": "취소선",
+      "inline_code": "인라인 코드",
+      "bold_italic": "전체가 굵게 또는 기울임꼴",
+      "emoji": "이모지",
+      "sub_sup": "아래 첨자 / 위 첨자",
+      "link_label": "라벨이 있는 링크",
+      "link_docs": "GROWI 문서",
+      "link_growi": "GROWI 링크",
+      "link_sandbox": "연습장 페이지는 여기입니다",
+      "all_important": "이 텍스트는 모두\n중요합니다",
+      "sub_text": "아래 첨자",
+      "sup_text": "위 첨자",
+      "is_text": "이것은 {{val}}입니다"
+    },
+    "layout": {
+      "copy_done": "복사되었습니다!",
+      "header": "헤더",
+      "header_text": "제목",
+      "list": "글머리 기호 목록",
+      "list_text": "목록 항목입니다",
+      "ordered_list": "번호 매기기 목록",
+      "ordered_list_text": "번호 항목입니다",
+      "checkbox": "체크박스",
+      "task": "할 일 ",
+      "quote": "인용구",
+      "quote_text": "인용",
+      "multi_quote": "중첩 인용",
+      "hr": "가로줄",
+      "br": "줄바꿈",
+      "br_code": "스페이스를 두 번 입력하면  줄바꿈을\n할 수 있습니다",
+      "br_preview_1": "스페이스를 두 번 입력하면",
+      "br_preview_2": "줄바꿈을 할 수 있습니다",
+      "code_block": "코드 블록",
+      "code_block_text": "여기에 코드를 추가하세요",
+      "table": "표",
+      "left": "왼쪽 정렬",
+      "right": "오른쪽 정렬",
+      "center": "가운데 정렬",
+      "row_text": "이 열은",
+      "row_display": "됩니다",
+      "footnote": "각주",
+      "footnote_label": "각주가 있는 텍스트",
+      "footnote_desc": "주석은 이와 같이 작성합니다"
+    },
+    "decoration": {
+      "copy_done": "클립보드에 복사되었습니다!",
+      "style": "스타일",
+      "alert": "알림",
+      "alert_with_custom_title": "레이블이 있는 알림",
+      "alert_with_custom_title_text": "사용자 정의 제목",
+      "alert_unavailable": "이 스타일에서는 사용할 수 없습니다",
+      "badge": "배지",
+      "text_color": "텍스트 색상",
+      "back_color": "배경 색상",
+
+      "placeholder": "텍스트가 입력됩니다",
+      "docs_title": "Bootstrap 5 공식 문서",
+      "docs_badge": "배지 상세 정보",
+      "docs_color": "색상 상세 정보",
+      "docs_alert": "알림 블록 상세 정보"
+    }
   }
 }

+ 11 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -341,6 +341,13 @@
     "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
     "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
   },
+  "news_delivery": {
+    "section_title": "新闻推送",
+    "label": "新闻推送",
+    "enable": "启用新闻推送",
+    "description": "控制 cron 任务是否拉取新闻订阅源并更新本地缓存。停用期间,已缓存的新闻仍可显示。",
+    "update_succeeded": "新闻推送设置已更新"
+  },
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
     "under_maintenance_mode": "在维护模式下",
@@ -378,6 +385,10 @@
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
     "page_bulk_export_warning": "批量页面导出功能可供所有用户使用。为了维护系统资源,请您配合使用最低限度的资源。如果您是管理员,请将此事实告知所有用户。",
     "page_bulk_export_storage_period": "储存期限",
+    "page_bulk_export_reload_title": "重新加载页面",
+    "page_bulk_export_reload_prompt": "您需要重新加载页面以反映设置更改。现在重新加载吗?",
+    "page_bulk_export_reload_dismiss": "暂不",
+    "reload_page": "重新加载",
     "update": "更新",
     "mail_settings": "邮件设置",
     "mailer_is_not_set_up": "邮件设置尚未完成。",

+ 9 - 3
apps/app/public/static/locales/zh_CN/commons.json

@@ -61,7 +61,11 @@
     "unopend": "未读",
     "mark_all_as_read": "标记为已读",
     "no_unread_messages": "no_unread_messages",
-    "only_unread": "Only unread"
+    "only_unread": "Only unread",
+    "news": "公告",
+    "notifications": "通知",
+    "filter_all": "全部",
+    "no_news": "暂无公告"
   },
   "personal_dropdown": {
     "home": "家",
@@ -170,7 +174,8 @@
         "share_link": "授予查看共享链接功能的权限。",
         "bookmark": "授予查看书签功能的权限。",
         "attachment": "授予查看附件功能的权限。",
-        "page_bulk_export": "授予查看页面批量导出功能的权限。"
+        "page_bulk_export": "授予查看页面批量导出功能的权限。",
+        "in_app_notification": "授予查看应用内通知功能的权限。"
       }
     },
     "write": {
@@ -215,7 +220,8 @@
         "share_link": "授予编辑共享链接功能的权限。",
         "bookmark": "授予编辑书签功能的权限。",
         "attachment": "授予编辑附件功能的权限。",
-        "page_bulk_export": "授予编辑页面批量导出功能的权限。"
+        "page_bulk_export": "授予编辑页面批量导出功能的权限。",
+        "in_app_notification": "授予编辑应用内通知功能的权限。"
       }
     }
   }

+ 76 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -1098,6 +1098,7 @@
     "checklist": "清单",
     "code": "代码",
     "diagram": "图表",
+    "editor_guide": "编辑器指南",
     "emoji": "表情符号",
     "heading": "标题",
     "italic": "斜体",
@@ -1107,5 +1108,80 @@
     "table": "表格",
     "template": "模板",
     "text_formatting": "文本格式"
+  },
+  "editor_guide": {
+    "title": "编辑器指南",
+    "tabs": {
+      "textstyle": "文本样式",
+      "layout": "页面布局",
+      "decoration": "装饰"
+    },
+    "textstyle": {
+      "copy_done": "已复制!",
+      "this": "这是",
+      "is": "效果",
+      "bold": "加粗",
+      "italic": "斜体",
+      "strikethrough": "删除线",
+      "inline_code": "行内代码",
+      "bold_italic": "全体加粗或斜体",
+      "emoji": "表情符号",
+      "sub_sup": "下标 / 上标",
+      "link_label": "带有标签的链接",
+      "link_docs": "GROWI 文档",
+      "link_growi": "GROWI 链接",
+      "link_sandbox": "沙盒页面在这里",
+      "all_important": "这段文字非常\n重要",
+      "sub_text": "下标",
+      "sup_text": "上标",
+      "is_text": "这是{{val}}效果"
+    },
+    "layout": {
+      "copy_done": "已复制!",
+      "header": "标题",
+      "header_text": "标题",
+      "list": "无序列表",
+      "list_text": "这是一个列表项",
+      "ordered_list": "有序列表",
+      "ordered_list_text": "这是一个编号项",
+      "checkbox": "复选框",
+      "task": "任务 ",
+      "quote": "引用",
+      "quote_text": "引用",
+      "multi_quote": "多重引用",
+      "hr": "分割线",
+      "br": "换行",
+      "br_code": "输入两个空格  即可\n换行",
+      "br_preview_1": "输入两个空格即可",
+      "br_preview_2": "换行",
+      "code_block": "代码块",
+      "code_block_text": "在此处插入代码",
+      "table": "表格",
+      "left": "左对齐",
+      "right": "右对齐",
+      "center": "居中对齐",
+      "row_text": "本列将",
+      "row_display": "显示",
+      "footnote": "脚注",
+      "footnote_label": "带有脚注的文本",
+      "footnote_desc": "注记的写法如下"
+    },
+    "decoration": {
+      "copy_done": "已复制到剪贴板!",
+      "style": "样式",
+      "alert": "提示",
+      "alert_with_custom_title": "带标签的提示",
+      "alert_with_custom_title_text": "自定义标题",
+      "alert_unavailable": "此样式不可用",
+      "badge": "徽章",
+      "text_color": "文本颜色",
+      "back_color": "背景颜色",
+
+      "placeholder": "此处显示文本内容",
+      "docs_title": "Bootstrap 5 官方文档",
+      "docs_badge": "了解更多关于徽章的信息",
+      "docs_color": "了解更多关于颜色的信息",
+      "docs_alert": "了解更多关于警告框的信息"
+    }
   }
 }

+ 12 - 0
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/toastr';
+import { NewsDeliverySetting } from '~/features/news/client/components/admin/NewsDeliverySetting';
 import { useIsMaintenanceMode } from '~/states/global';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { toArrayIfNot } from '~/utils/array-utils';
@@ -133,6 +134,17 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header" id="news-delivery">
+            {t('admin:news_delivery.section_title', {
+              defaultValue: 'News delivery',
+            })}
+          </h2>
+          <NewsDeliverySetting />
+        </div>
+      </div>
+
       <div className="row">
         <div className="col-lg-12">
           <h2 className="admin-setting-header" id="maintenance-mode">

+ 17 - 4
apps/app/src/client/components/Admin/App/ConfirmModal.tsx

@@ -8,6 +8,12 @@ type ConfirmModalProps = {
   warningMessage: string;
   supplymentaryMessage: string | null;
   confirmButtonTitle: string;
+  // Optional overrides; defaults keep the original "Warning" appearance so
+  // existing callers are unaffected.
+  title?: string;
+  cancelButtonTitle?: string;
+  headerClassName?: string;
+  iconName?: string;
   onConfirm?: () => Promise<void>;
   onCancel?: () => void;
 };
@@ -17,6 +23,13 @@ export const ConfirmModal: FC<ConfirmModalProps> = (
 ) => {
   const { t } = useTranslation();
 
+  const {
+    title,
+    cancelButtonTitle,
+    headerClassName = 'text-danger',
+    iconName = 'warning',
+  } = props;
+
   const onCancel = () => {
     if (props.onCancel != null) {
       props.onCancel();
@@ -31,9 +44,9 @@ export const ConfirmModal: FC<ConfirmModalProps> = (
 
   return (
     <Modal isOpen={props.isModalOpen} toggle={onCancel}>
-      <ModalHeader tag="h4" toggle={onCancel} className="text-danger">
-        <span className="material-symbols-outlined me-1">warning</span>
-        {t('Warning')}
+      <ModalHeader tag="h4" toggle={onCancel} className={headerClassName}>
+        <span className="material-symbols-outlined me-1">{iconName}</span>
+        {title ?? t('Warning')}
       </ModalHeader>
       <ModalBody>
         {props.warningMessage}
@@ -56,7 +69,7 @@ export const ConfirmModal: FC<ConfirmModalProps> = (
           className="btn btn-outline-secondary"
           onClick={onCancel}
         >
-          {t('Cancel')}
+          {cancelButtonTitle ?? t('Cancel')}
         </button>
         <button
           type="button"

+ 30 - 0
apps/app/src/client/components/Admin/App/PageBulkExportSettings.tsx

@@ -7,6 +7,7 @@ import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import { ConfirmModal } from './ConfirmModal';
 
 const PageBulkExportSettings = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
@@ -21,6 +22,8 @@ const PageBulkExportSettings = (): JSX.Element => {
     setBulkExportDownloadExpirationSeconds,
   ] = useState(data?.bulkExportDownloadExpirationSeconds);
 
+  const [isReloadModalOpen, setReloadModalOpen] = useState(false);
+
   const changeBulkExportDownloadExpirationSeconds = (
     bulkExportDownloadExpirationDays: number,
   ) => {
@@ -30,6 +33,11 @@ const PageBulkExportSettings = (): JSX.Element => {
   };
 
   const onSubmitHandler = useCallback(async () => {
+    // Only the enable flag affects the page-side export menu visibility, which
+    // is hydrated once per page load and therefore needs a reload to reflect.
+    // The expiration period is read server-side, so a reload prompt is unneeded.
+    const isEnabledFlagChanged =
+      isBulkExportPagesEnabled !== data?.isBulkExportPagesEnabled;
     try {
       await apiv3Put('/app-settings/page-bulk-export-settings', {
         isBulkExportPagesEnabled,
@@ -40,6 +48,9 @@ const PageBulkExportSettings = (): JSX.Element => {
           target: t('app_setting.page_bulk_export_settings'),
         }),
       );
+      if (isEnabledFlagChanged) {
+        setReloadModalOpen(true);
+      }
     } catch (err) {
       toastError(err);
     }
@@ -47,6 +58,7 @@ const PageBulkExportSettings = (): JSX.Element => {
   }, [
     isBulkExportPagesEnabled,
     bulkExportDownloadExpirationSeconds,
+    data,
     mutate,
     t,
   ]);
@@ -157,6 +169,24 @@ const PageBulkExportSettings = (): JSX.Element => {
           <AdminUpdateButtonRow onClick={onSubmitHandler} />
         </>
       )}
+
+      <ConfirmModal
+        isModalOpen={isReloadModalOpen}
+        title={t('admin:app_setting.page_bulk_export_reload_title')}
+        headerClassName="text-primary"
+        iconName="refresh"
+        warningMessage={t('admin:app_setting.page_bulk_export_reload_prompt')}
+        supplymentaryMessage={null}
+        confirmButtonTitle={t('admin:app_setting.reload_page')}
+        cancelButtonTitle={t(
+          'admin:app_setting.page_bulk_export_reload_dismiss',
+        )}
+        onConfirm={() => {
+          window.location.reload();
+          return Promise.resolve();
+        }}
+        onCancel={() => setReloadModalOpen(false)}
+      />
     </>
   );
 };

+ 8 - 5
apps/app/src/client/components/Admin/G2GDataTransfer.tsx

@@ -54,13 +54,16 @@ const G2GDataTransfer = (): JSX.Element => {
   // const [gcsBucket, setGcsBucket] = useState('');
   // const [gcsUploadNamespace, setGcsUploadNamespace] = useState('');
 
-  const updateSelectedCollections = (newSelectedCollections: Set<string>) => {
-    setSelectedCollections(newSelectedCollections);
-  };
+  const updateSelectedCollections = useCallback(
+    (newSelectedCollections: Set<string>) => {
+      setSelectedCollections(newSelectedCollections);
+    },
+    [],
+  );
 
-  const updateOptionsMap = (newOptionsMap: any) => {
+  const updateOptionsMap = useCallback((newOptionsMap: any) => {
     setOptionsMap(newOptionsMap);
-  };
+  }, []);
 
   const onChangeTransferKeyHandler = useCallback((e) => {
     setStartTransferKey(e.target.value);

+ 6 - 4
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -10,6 +10,8 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 import { useModelNotification } from './ModelNotification';
 
+import unreadDotStyles from './UnreadDot.module.scss';
+
 interface Props {
   notification: IInAppNotification & HasObjectId;
   onUnopenedNotificationOpend?: () => void;
@@ -77,10 +79,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
           <span
             className={`${
               notification.status === InAppNotificationStatuses.STATUS_UNOPENED
-                ? 'grw-unopend-notification'
-                : 'ms-2'
-            } rounded-circle me-3`}
-          ></span>
+                ? 'bg-primary'
+                : ''
+            } rounded-circle me-3 ${unreadDotStyles['unread-dot']}`}
+          />
 
           {renderActionUserPictures()}
 

+ 6 - 0
apps/app/src/client/components/InAppNotification/UnreadDot.module.scss

@@ -0,0 +1,6 @@
+.unread-dot {
+  display: inline-block;
+  width: 8px;
+  min-width: 8px;
+  height: 8px;
+}

+ 50 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.module.scss

@@ -0,0 +1,50 @@
+.editor-guide-modal {
+  position: fixed !important;
+  top: var(--egm-top) !important;
+  left: var(--egm-left) !important;
+  display: flex !important;
+  align-items: center;
+  justify-content: center;
+  width: var(--egm-width) !important;
+  height: var(--egm-height) !important;
+  padding: 0 !important;
+  overflow: hidden !important;
+}
+
+.editor-guide-backdrop {
+  position: fixed !important;
+  top: var(--egm-top) !important;
+  left: var(--egm-left) !important;
+  width: var(--egm-width) !important;
+  height: var(--egm-height) !important;
+}
+
+.card-body-scrollable {
+  overflow-y: auto;
+}
+
+.editor-guide-tabs-container {
+  &:not(#_) {
+    .nav-tabs, ul {
+      display: flex;
+      flex-wrap: nowrap;
+      justify-content: space-between;
+      width: 100%;
+      padding: 0;
+      margin: 0;
+      list-style: none;
+    }
+
+    .nav-item, li {
+      flex: 1 1 0;
+      min-width: 100px;
+      text-align: center;
+    }
+
+    .nav-link, button, a {
+      display: block;
+      width: 100%;
+      white-space: nowrap;
+    }
+  }
+}

+ 143 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/EditorGuideModal.tsx

@@ -0,0 +1,143 @@
+import {
+  type JSX,
+  type RefObject,
+  useLayoutEffect,
+  useMemo,
+  useState,
+} from 'react';
+import {
+  useEditorGuideModalActions,
+  useEditorGuideModalStatus,
+} from '@growi/editor/dist/states/modal/editor-guide';
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody, CardHeader, Modal } from 'reactstrap';
+
+import { CustomNavTab } from '../../CustomNavigation/CustomNav';
+import CustomTabContent from '../../CustomNavigation/CustomTabContent';
+import { DecorationTab } from './contents/DecorationTab';
+import { LayoutTab } from './contents/LayoutTab';
+import { TextStyleTab } from './contents/TextStyleTab';
+
+import styles from './EditorGuideModal.module.scss';
+
+// Bootstrap $spacer (1rem = 16px) * 2 — matches calc(100% - 32px) padding on both sides
+const MODAL_MARGIN_PX = 32;
+// Bootstrap $modal-lg
+const MODAL_MAX_WIDTH = '800px';
+
+const TAB_TYPES = ['textstyle', 'layout', 'decoration'] as const;
+type TabType = (typeof TAB_TYPES)[number];
+type Props = {
+  containerRef: RefObject<HTMLDivElement | null>;
+};
+const isTabType = (key: string): key is TabType => {
+  return (TAB_TYPES as readonly string[]).includes(key);
+};
+
+const TextStyleTabPane = (): React.JSX.Element => <TextStyleTab />;
+const LayoutTabPane = (): React.JSX.Element => <LayoutTab />;
+const DecorationTabPane = (): React.JSX.Element => <DecorationTab />;
+
+export const EditorGuideModal = ({
+  containerRef,
+}: Props): JSX.Element | null => {
+  const { t } = useTranslation();
+  const { isOpened } = useEditorGuideModalStatus();
+  const { close } = useEditorGuideModalActions();
+  const [rect, setRect] = useState<DOMRect | null>(null);
+
+  const [activeTab, setActiveTab] = useState<TabType>('textstyle');
+  const navTabMapping = useMemo(() => {
+    return {
+      textstyle: {
+        i18n: t('editor_guide.tabs.textstyle'),
+        Content: TextStyleTabPane,
+      },
+      layout: {
+        i18n: t('editor_guide.tabs.layout'),
+        Content: LayoutTabPane,
+      },
+      decoration: {
+        i18n: t('editor_guide.tabs.decoration'),
+        Content: DecorationTabPane,
+      },
+    };
+  }, [t]);
+
+  useLayoutEffect(() => {
+    if (!isOpened || containerRef.current == null) return;
+
+    const updateRect = () => {
+      const r = containerRef.current?.getBoundingClientRect() ?? null;
+      setRect(r);
+      if (r != null) {
+        document.body.style.setProperty('--egm-top', `${r.top}px`);
+        document.body.style.setProperty('--egm-left', `${r.left}px`);
+        document.body.style.setProperty('--egm-width', `${r.width}px`);
+        document.body.style.setProperty('--egm-height', `${r.height}px`);
+      }
+    };
+    updateRect();
+    window.addEventListener('resize', updateRect);
+    return () => {
+      window.removeEventListener('resize', updateRect);
+      document.body.style.removeProperty('--egm-top');
+      document.body.style.removeProperty('--egm-left');
+      document.body.style.removeProperty('--egm-width');
+      document.body.style.removeProperty('--egm-height');
+    };
+  }, [isOpened, containerRef]);
+
+  if (!isOpened || rect == null) return null;
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={close}
+      keyboard
+      modalClassName={styles['editor-guide-modal']}
+      backdropClassName={styles['editor-guide-backdrop']}
+      contentClassName="bg-transparent border-0 shadow-none"
+      style={{
+        margin: 0,
+        maxWidth: MODAL_MAX_WIDTH,
+        width: `calc(100% - ${MODAL_MARGIN_PX}px)`,
+      }}
+    >
+      <Card
+        className="shadow-lg border-0"
+        style={{ maxHeight: rect.height - MODAL_MARGIN_PX }}
+      >
+        <CardHeader className="d-flex justify-content-between align-items-center bg-transparent border-bottom-0 pt-3">
+          <h5 className="mb-0 text-body">{t('editor_guide.title')}</h5>
+          <button
+            type="button"
+            className="btn-close"
+            onClick={close}
+            aria-label="Close"
+          />
+        </CardHeader>
+        <div className={`mt-2 px-3 ${styles['editor-guide-tabs-container']}`}>
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={(tabKey) => {
+              if (isTabType(tabKey)) {
+                setActiveTab(tabKey);
+              }
+            }}
+            hideBorderBottom
+          />
+        </div>
+        <CardBody
+          className={`pt-0 flex-fill ${styles['card-body-scrollable']}`}
+        >
+          <CustomTabContent
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+          />
+        </CardBody>
+      </Card>
+    </Modal>
+  );
+};

+ 21 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.module.scss

@@ -0,0 +1,21 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.copyButton {
+  display: block;
+  padding: 0;
+  text-align: left;
+  cursor: pointer;
+  background-color: transparent;
+  border: 0;
+}
+
+.codeBox {
+  width: fit-content;
+}
+
+.copyBadge {
+  top: map.get(bs.$spacers, 1);
+  right: map.get(bs.$spacers, 1);
+  font-size: bs.$badge-font-size;
+}

+ 89 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/components/GuideRow.tsx

@@ -0,0 +1,89 @@
+import type { ReactNode } from 'react';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+
+import styles from './GuideRow.module.scss';
+
+export interface LayoutGuideItem {
+  id: string;
+  title?: string;
+  code?: string;
+  preview?: ReactNode;
+  minWidth?: string;
+  underContent?: ReactNode;
+}
+
+export type GuideRowProps = Omit<LayoutGuideItem, 'id'>;
+
+export const GuideRow = ({
+  title,
+  code,
+  preview,
+  minWidth = '230px',
+  underContent,
+}: GuideRowProps) => {
+  const { t } = useTranslation();
+  const handleCopy = useCallback(async () => {
+    try {
+      if (code == null) return;
+
+      await navigator.clipboard.writeText(code);
+      toastSuccess(t('editor_guide.textstyle.copy_done'));
+    } catch (_err) {
+      toastError(t('common:failed_to_copy'));
+    }
+  }, [code, t]);
+
+  const isFullWidth = minWidth === '100%' || !preview;
+
+  return (
+    <section className={title ? 'mt-4 mb-2' : 'mb-2'}>
+      {title && <h3 className="fw-bold mb-2 fs-5 text-body">{title}</h3>}
+      <div className="d-flex flex-row flex-wrap align-items-center gap-4 py-1">
+        <button
+          type="button"
+          onClick={handleCopy}
+          className={`${styles.copyButton} ${isFullWidth ? 'w-100 flex-grow-1' : 'flex-grow-0 flex-shrink-0'}`}
+          style={{ minWidth: isFullWidth ? '100%' : minWidth }}
+        >
+          {code != null && (
+            <div
+              className={`${styles.codeBox} rounded overflow-hidden position-relative ${isFullWidth ? 'w-100' : ''}`}
+            >
+              <PrismAsyncLight
+                style={oneDark}
+                language="markdown"
+                customStyle={{ margin: 0 }}
+                wrapLongLines={isFullWidth}
+              >
+                {code}
+              </PrismAsyncLight>
+              <small
+                className={`position-absolute badge bg-secondary opacity-50 ${styles.copyBadge}`}
+              >
+                Copy
+              </small>
+            </div>
+          )}
+          {code == null && (
+            <span className="text-secondary">
+              ({t('editor_guide.decoration.alert_unavailable')})
+            </span>
+          )}
+        </button>
+
+        {preview && (
+          <div className="flex-grow-1">
+            <div className="wiki-content small">{preview}</div>
+          </div>
+        )}
+      </div>
+
+      {underContent && <div className="mt-2 w-100">{underContent}</div>}
+    </section>
+  );
+};

+ 19 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.module.scss

@@ -0,0 +1,19 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.decorationTab {
+  min-width: map.get(bs.$grid-breakpoints, 'sm');
+}
+
+// Override the margin of callout in the preview
+.decorationBody {
+  :global(.callout) {
+    margin: 0.2rem 0;
+  }
+}
+
+.dropdownMenu {
+  max-height: 300px;
+  margin-top: bs.$dropdown-spacer;
+  overflow-y: auto;
+}

+ 214 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/DecorationTab.tsx

@@ -0,0 +1,214 @@
+import type React from 'react';
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import ReactMarkdown from 'react-markdown';
+import {
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from 'reactstrap';
+
+import { usePreviewOptions } from '~/stores/renderer';
+
+import type { LayoutGuideItem } from '../components/GuideRow';
+import { GuideRow } from '../components/GuideRow';
+
+import styles from './DecorationTab.module.scss';
+
+const BOOTSTRAP_STYLES = [
+  'primary',
+  'secondary',
+  'info',
+  'success',
+  'warning',
+  'danger',
+] as const;
+type BOOTSTRAP_STYLES = (typeof BOOTSTRAP_STYLES)[number];
+
+const BOOTSTRAP_STYLES_TO_CONFIGS_MAPPINGS: Record<
+  BOOTSTRAP_STYLES,
+  { icon: string; calloutType?: string }
+> = {
+  primary: {
+    icon: 'feedback',
+    calloutType: 'important',
+  },
+  secondary: { icon: 'label' },
+  info: { icon: 'info', calloutType: 'note' },
+  success: { icon: 'lightbulb', calloutType: 'tip' },
+  warning: { icon: 'warning', calloutType: 'warning' },
+  danger: { icon: 'report', calloutType: 'caution' },
+};
+
+export const DecorationTab: React.FC = () => {
+  const { t } = useTranslation();
+  const i18nKey = 'editor_guide.decoration';
+  const [currentStyle, setCurrentStyle] = useState<BOOTSTRAP_STYLES>('primary');
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: previewOptions } = usePreviewOptions();
+
+  const calloutConfig: { icon: string; calloutType?: string } =
+    BOOTSTRAP_STYLES_TO_CONFIGS_MAPPINGS[currentStyle];
+  const displayName =
+    currentStyle.charAt(0).toUpperCase() + currentStyle.slice(1);
+
+  const LAYOUT_GUIDES: LayoutGuideItem[] = useMemo(
+    () =>
+      [
+        currentStyle !== 'secondary' && {
+          id: 'alert',
+          title: t(`${i18nKey}.alert`),
+          code: `> [!${calloutConfig.calloutType?.toUpperCase()}]\n> ${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}`,
+          preview: (
+            <ReactMarkdown
+              {...previewOptions}
+            >{`> [!${calloutConfig.calloutType?.toUpperCase()}]\n> ${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}`}</ReactMarkdown>
+          ),
+        },
+        currentStyle !== 'secondary' && {
+          id: 'alert2',
+          code: `:::${calloutConfig.calloutType}\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`,
+          preview: (
+            <ReactMarkdown
+              {...previewOptions}
+            >{`:::${calloutConfig.calloutType}\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`}</ReactMarkdown>
+          ),
+        },
+        currentStyle !== 'secondary' && {
+          id: 'alert3',
+          title: t(`${i18nKey}.alert_with_custom_title`),
+          code: `:::${calloutConfig.calloutType}[${t(`${i18nKey}.alert_with_custom_title_text`)}]\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`,
+          preview: (
+            <ReactMarkdown
+              {...previewOptions}
+            >{`:::${calloutConfig.calloutType}[${t(`${i18nKey}.alert_with_custom_title_text`)}]\n${t(`${i18nKey}.${currentStyle}_text`, { defaultValue: t(`${i18nKey}.placeholder`) })}\n:::`}</ReactMarkdown>
+          ),
+        },
+        currentStyle === 'secondary' && {
+          id: 'alert_empty',
+          title: t(`${i18nKey}.alert`),
+        },
+        {
+          id: 'badge',
+          title: t(`${i18nKey}.badge`),
+          code: `<span class="badge text-bg-${currentStyle}">${t(`${i18nKey}.badge`)}</span>`,
+          preview: (
+            <span className={`badge text-bg-${currentStyle}`}>
+              {t(`${i18nKey}.badge`)}
+            </span>
+          ),
+        },
+        {
+          id: 'text-color',
+          title: t(`${i18nKey}.text_color`),
+          code: `<p class="text-${currentStyle}">${t(`${i18nKey}.placeholder`)}</p>`,
+          underContent: (
+            <p className={`text-${currentStyle} m-0`}>
+              {t(`${i18nKey}.placeholder`)}
+            </p>
+          ),
+        },
+        {
+          id: 'back-color',
+          title: t(`${i18nKey}.back_color`),
+          code: `<p class="text-bg-${currentStyle}">${t(`${i18nKey}.placeholder`)}</p>`,
+          underContent: (
+            <p className={`text-bg-${currentStyle} px-2 m-0`}>
+              {t(`${i18nKey}.placeholder`)}
+            </p>
+          ),
+        },
+        {
+          id: 'alert-block',
+          title: t(`${i18nKey}.alert_block`),
+          code: `<div class="alert alert-${currentStyle}" role="alert">\n  ${t(`${i18nKey}.placeholder`)}\n</div>`,
+          underContent: (
+            <div className={`alert alert-${currentStyle} m-0`}>
+              {t(`${i18nKey}.placeholder`)}
+            </div>
+          ),
+        },
+      ].filter((item) => item !== false) as LayoutGuideItem[],
+    [currentStyle, t, previewOptions, calloutConfig.calloutType],
+  );
+
+  return (
+    <div className={`px-4 py-3 ${styles.decorationTab}`}>
+      <section className="mb-4">
+        <h3 className="fw-bold mb-2 fs-5">{t(`${i18nKey}.style`)}</h3>
+        <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+          <DropdownToggle
+            outline
+            color="body"
+            caret
+            className={`border d-flex align-items-center gap-2 text-${currentStyle}`}
+            style={{ minWidth: '160px' }}
+          >
+            <span className="flex-grow-1 justify-content-start d-flex align-items-center gap-1">
+              <span className="material-symbols-outlined align-middle fs-6">
+                {calloutConfig.icon}
+              </span>
+              {displayName}
+            </span>
+          </DropdownToggle>
+          <DropdownMenu className={styles.dropdownMenu}>
+            {BOOTSTRAP_STYLES.map((style) => (
+              <DropdownItem
+                key={style}
+                active={currentStyle === style}
+                className="d-flex align-items-center gap-2"
+                onClick={() => setCurrentStyle(style)}
+              >
+                <span className="material-symbols-outlined">
+                  {BOOTSTRAP_STYLES_TO_CONFIGS_MAPPINGS[style].icon}
+                </span>
+                {style.charAt(0).toUpperCase() + style.slice(1)}
+              </DropdownItem>
+            ))}
+          </DropdownMenu>
+        </Dropdown>
+      </section>
+
+      <hr />
+
+      <div key={currentStyle} className={styles.decorationBody}>
+        {LAYOUT_GUIDES.map((item) => (
+          <GuideRow key={item.id} {...item} minWidth="280px" />
+        ))}
+      </div>
+
+      <div className="mt-5 pt-3 border-top">
+        <h3 className="fw-bold fs-5 mb-3">{t(`${i18nKey}.docs_title`)}</h3>
+        <div className="d-flex flex-column gap-2">
+          {[
+            {
+              key: 'badge',
+              url: 'https://getbootstrap.com/docs/5.3/components/badge/',
+            },
+            {
+              key: 'color',
+              url: 'https://getbootstrap.com/docs/5.3/utilities/colors/',
+            },
+            {
+              key: 'alert',
+              url: 'https://getbootstrap.com/docs/5.3/components/alerts/',
+            },
+          ].map(({ key, url }) => (
+            <a
+              key={key}
+              href={url}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="text-decoration-none text-secondary small d-flex align-items-center"
+            >
+              {t(`${i18nKey}.docs_${key}`)}
+              <span className="material-symbols-outlined">open_in_new</span>
+            </a>
+          ))}
+        </div>
+      </div>
+    </div>
+  );
+};

+ 18 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.module.scss

@@ -0,0 +1,18 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.layoutTabContainer {
+  min-width: map.get(bs.$grid-breakpoints, 'sm');
+}
+
+.checkboxMock {
+  width: bs.$form-check-input-width;
+  height: bs.$form-check-input-width;
+}
+
+.tableContainer {
+  width: fit-content;
+  table {
+    min-width: map.get(bs.$grid-breakpoints, 'sm');
+  }
+}

+ 230 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/LayoutTab.tsx

@@ -0,0 +1,230 @@
+import type React from 'react';
+import { useTranslation } from 'react-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+import type { LayoutGuideItem } from '../components/GuideRow';
+import { GuideRow } from '../components/GuideRow';
+
+import styles from './LayoutTab.module.scss';
+
+export const LayoutTab: React.FC = () => {
+  const { t } = useTranslation();
+  const i18nKey = 'editor_guide.layout';
+
+  const LAYOUT_GUIDES: LayoutGuideItem[] = [
+    {
+      id: 'header',
+      title: t(`${i18nKey}.header`),
+      code: [
+        `# ${t(`${i18nKey}.header_text`)}1`,
+        `## ${t(`${i18nKey}.header_text`)}2`,
+        `### ${t(`${i18nKey}.header_text`)}3`,
+        `#### ${t(`${i18nKey}.header_text`)}4`,
+        `##### ${t(`${i18nKey}.header_text`)}5`,
+        `###### ${t(`${i18nKey}.header_text`)}6`,
+      ].join('\n'),
+      preview: (
+        <div className="text-body lh-base">
+          <h1 className="h2 border-bottom pb-1 mb-2 fw-normal">
+            {t(`${i18nKey}.header_text`)}1
+          </h1>
+          <h2 className="h3 border-bottom pb-1 mb-2 fw-bold">
+            {t(`${i18nKey}.header_text`)}2
+          </h2>
+          <h3 className="fs-5 mb-2 fw-bold">{t(`${i18nKey}.header_text`)}3</h3>
+          <h4 className="fs-5 border-start border-4 ps-2 mb-2 fw-normal border-secondary-subtle">
+            {t(`${i18nKey}.header_text`)}4
+          </h4>
+          <h5 className="fs-5 border-start border-4 ps-2 mb-2 fw-normal border-secondary-subtle">
+            {t(`${i18nKey}.header_text`)}5
+          </h5>
+          <h6 className="fs-6 border-start border-4 ps-2 mb-0 fw-normal border-secondary-subtle">
+            {t(`${i18nKey}.header_text`)}6
+          </h6>
+        </div>
+      ),
+    },
+    {
+      id: 'list',
+      title: t(`${i18nKey}.list`),
+      code: `- ${t(`${i18nKey}.list_text`)}\n  * ${t(`${i18nKey}.list_text`)}\n    + ${t(`${i18nKey}.list_text`)}`,
+      preview: (
+        <ul className="mb-0" style={{ listStyleType: 'disc' }}>
+          <li>
+            {t(`${i18nKey}.list_text`)}
+            <ul className="mt-1" style={{ listStyleType: 'disc' }}>
+              <li>
+                {t(`${i18nKey}.list_text`)}
+                <ul className="mt-1" style={{ listStyleType: 'disc' }}>
+                  <li>{t(`${i18nKey}.list_text`)}</li>
+                </ul>
+              </li>
+            </ul>
+          </li>
+        </ul>
+      ),
+    },
+    {
+      id: 'ordered-list',
+      title: t(`${i18nKey}.ordered_list`),
+      code: `1. ${t(`${i18nKey}.ordered_list_text`)}\n1. ${t(`${i18nKey}.ordered_list_text`)}\n1. ${t(`${i18nKey}.ordered_list_text`)}`,
+      preview: (
+        <ol className="ps-3 mb-0 text-body">
+          <li className="mb-2">{t(`${i18nKey}.ordered_list_text`)}</li>
+          <li className="mb-2">{t(`${i18nKey}.ordered_list_text`)}</li>
+          <li className="mb-0">{t(`${i18nKey}.ordered_list_text`)}</li>
+        </ol>
+      ),
+    },
+    {
+      id: 'checkbox',
+      title: t(`${i18nKey}.checkbox`),
+      code: `[x] ${t(`${i18nKey}.task`)}1\n  [] ${t(`${i18nKey}.task`)}1-1\n  [x] ${t(`${i18nKey}.task`)}1-2`,
+      preview: (
+        <div className="text-body fs-6 lh-lg">
+          <div className="d-flex align-items-center mb-1">
+            <span className="me-2 user-select-none">☑️</span>
+            <span>{t(`${i18nKey}.task`)}1</span>
+          </div>
+          <div className="d-flex align-items-center mb-1 ps-4">
+            <span
+              className={`d-inline-block border border-secondary rounded me-2 ${styles.checkboxMock}`}
+            />
+            <span>{t(`${i18nKey}.task`)}1-1</span>
+          </div>
+          <div className="d-flex align-items-center ps-4">
+            <span className="me-2 user-select-none">☑️</span>
+            <span>{t(`${i18nKey}.task`)}1-2</span>
+          </div>
+        </div>
+      ),
+    },
+    {
+      id: 'quote',
+      title: t(`${i18nKey}.quote`),
+      code: `> ${t(`${i18nKey}.quote_text`)}\n>> ${t(`${i18nKey}.multi_quote`)}`,
+      preview: (
+        <blockquote className="border-start border-4 ps-3 text-muted fst-italic border-secondary-subtle">
+          {t(`${i18nKey}.quote_text`)}
+          <blockquote className="border-start border-2 ps-3 mt-2">
+            {t(`${i18nKey}.multi_quote`)}
+          </blockquote>
+        </blockquote>
+      ),
+    },
+    {
+      id: 'hr',
+      title: t(`${i18nKey}.hr`),
+      code: '***\n\n―――\n\n---',
+      preview: (
+        <div className="d-flex flex-column gap-4 w-100">
+          <hr className="my-0 opacity-25" />
+          <hr className="my-0 opacity-25" />
+          <hr className="my-0 opacity-25" />
+        </div>
+      ),
+    },
+    {
+      id: 'br',
+      title: t(`${i18nKey}.br`),
+      code: t(`${i18nKey}.br_code`),
+      preview: (
+        <div className="text-body lh-base">
+          {t(`${i18nKey}.br_preview_1`)}
+          <br />
+          {t(`${i18nKey}.br_preview_2`)}
+        </div>
+      ),
+    },
+    {
+      id: 'code-block',
+      title: t(`${i18nKey}.code_block`),
+      code: `\`\`\`\n${t(`${i18nKey}.code_block_text`)}\n\`\`\``,
+      preview: (
+        <PrismAsyncLight
+          style={oneDark}
+          language="markdown"
+          customStyle={{ margin: 0 }}
+        >
+          {t(`${i18nKey}.code_block_text`)}
+        </PrismAsyncLight>
+      ),
+    },
+    {
+      id: 'table1',
+      title: t(`${i18nKey}.table`),
+      code: [
+        `| ${t(`${i18nKey}.left`)} | ${t(`${i18nKey}.right`)} | ${t(`${i18nKey}.center`)} |`,
+        '|:-------------- | --------------:| :--------------: |',
+        `| ${t(`${i18nKey}.row_text`)} | ${t(`${i18nKey}.row_text`)} | ${t(`${i18nKey}.row_text`)} |`,
+        `| ${t(`${i18nKey}.left`)}${t(`${i18nKey}.row_display`)} | ${t(`${i18nKey}.right`)}${t(`${i18nKey}.row_display`)} | ` +
+          `${t(`${i18nKey}.center`)}${t(`${i18nKey}.row_display`)} |`,
+      ].join('\n'),
+      underContent: (
+        <div className={`table-responsive mt-2 ${styles.tableContainer}`}>
+          <table className="table table-sm table-bordered mb-0 small text-body">
+            <thead>
+              <tr className="table-light">
+                <th className="text-start fw-bold p-2 align-middle">
+                  {t(`${i18nKey}.left`)}
+                </th>
+                <th className="text-end fw-bold p-2 align-middle">
+                  {t(`${i18nKey}.right`)}
+                </th>
+                <th className="text-center fw-bold p-2 align-middle">
+                  {t(`${i18nKey}.center`)}
+                </th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td className="text-start p-2">{t(`${i18nKey}.row_text`)}</td>
+                <td className="text-end p-2">{t(`${i18nKey}.row_text`)}</td>
+                <td className="text-center p-2">{t(`${i18nKey}.row_text`)}</td>
+              </tr>
+              <tr>
+                <td className="text-start p-2">
+                  {t(`${i18nKey}.left`)}
+                  {t(`${i18nKey}.row_display`)}
+                </td>
+                <td className="text-end p-2">
+                  {t(`${i18nKey}.right`)}
+                  {t(`${i18nKey}.row_display`)}
+                </td>
+                <td className="text-center p-2">
+                  {t(`${i18nKey}.center`)}
+                  {t(`${i18nKey}.row_display`)}
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      ),
+    },
+    {
+      id: 'footnote',
+      title: t(`${i18nKey}.footnote`),
+      code: `${t(`${i18nKey}.footnote_label`)}[^1].\n\n[^1]: ${t(`${i18nKey}.footnote_desc`)}.`,
+      preview: (
+        <div className="text-body fs-6 lh-base">
+          {t(`${i18nKey}.footnote_label`)}
+          <sup className="ms-1 text-body small">[1]</sup>
+        </div>
+      ),
+      underContent: (
+        <div className="text-body-secondary small mt-1">
+          1. {t(`${i18nKey}.footnote_desc`)}
+        </div>
+      ),
+    },
+  ];
+
+  return (
+    <div className={`px-4 py-3 overflow-y-auto ${styles.layoutTabContainer}`}>
+      {LAYOUT_GUIDES.map((item) => (
+        <GuideRow key={item.id} {...item} />
+      ))}
+    </div>
+  );
+};

+ 20 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.module.scss

@@ -0,0 +1,20 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use 'sass:map';
+
+.codeBlockWrapper {
+  width: fit-content;
+}
+
+.copyBadge {
+  top: bs.$dropdown-spacer;
+  right: map.get(bs.$spacers, 1);
+  font-size: bs.$badge-font-size;
+}
+
+.wikiPreview {
+  font-size: bs.$font-size-sm;
+}
+
+.inlineCodeLabel {
+  border: 1px solid currentColor;
+}

+ 219 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/contents/TextStyleTab.tsx

@@ -0,0 +1,219 @@
+import type React from 'react';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useGrowiDocumentationUrl } from '~/states/context';
+import { getLocale } from '~/utils/locale-utils';
+
+import styles from './TextStyleTab.module.scss';
+
+const GuideRow = ({
+  title,
+  code,
+  preview,
+}: {
+  title: string;
+  code: string;
+  preview: React.ReactNode;
+}) => {
+  const { t } = useTranslation();
+  const handleCopy = useCallback(async () => {
+    try {
+      await navigator.clipboard.writeText(code);
+      toastSuccess(t('editor_guide.textstyle.copy_done'));
+    } catch (_err) {
+      toastError(t('common:failed_to_copy'));
+    }
+  }, [code, t]);
+
+  return (
+    <section className={title !== '' ? 'mt-4 mb-1' : 'mb-1'}>
+      {title !== '' && <h3 className="h6 fw-bold mb-2">{title}</h3>}
+      <div className="d-flex flex-row align-items-center gap-3 py-1 flex-nowrap">
+        <button
+          type="button"
+          onClick={handleCopy}
+          className="flex-shrink-0 border-0 p-0 bg-transparent text-start cursor-pointer"
+        >
+          <div
+            className={`rounded position-relative overflow-hidden ${styles.codeBlockWrapper}`}
+          >
+            <PrismAsyncLight
+              style={oneDark}
+              language="markdown"
+              customStyle={{ margin: 0 }}
+            >
+              {code}
+            </PrismAsyncLight>
+            <small
+              className={`position-absolute badge bg-secondary opacity-50 ${styles.copyBadge}`}
+            >
+              Copy
+            </small>
+          </div>
+        </button>
+        <div className="flex-grow-1 text-nowrap">
+          <div className={`wiki-content fw-normal ${styles.wikiPreview}`}>
+            {preview}
+          </div>
+        </div>
+      </div>
+    </section>
+  );
+};
+
+export const TextStyleTab: React.FC = () => {
+  const { t, i18n } = useTranslation();
+  const documentationUrl = useGrowiDocumentationUrl();
+  const docsLang = getLocale(i18n.language).code === 'ja' ? 'ja' : 'en';
+  const i18nKey = 'editor_guide.textstyle';
+
+  const TEXT_STYLE_GUIDES = [
+    {
+      id: 'bold',
+      title: t(`${i18nKey}.bold`),
+      code: `${t(
+        `${i18nKey}.this`,
+      )} **${t(`${i18nKey}.bold`)}** ${t(`${i18nKey}.is`)}\n${t(`${i18nKey}.this`)} __${t(`${i18nKey}.bold`)}__ ${t(`${i18nKey}.is`)}`,
+      preview: (
+        <div className="lh-base">
+          {t(`${i18nKey}.this`)} <strong>{t(`${i18nKey}.bold`)}</strong>{' '}
+          {t(`${i18nKey}.is`)}
+          <br />
+          {t(`${i18nKey}.this`)} <strong>{t(`${i18nKey}.bold`)}</strong>{' '}
+          {t(`${i18nKey}.is`)}
+        </div>
+      ),
+    },
+    {
+      id: 'italic',
+      title: t(`${i18nKey}.italic`),
+      code: `${t(
+        `${i18nKey}.this`,
+      )} *${t(`${i18nKey}.italic`)}*${t(`${i18nKey}.is`)}\n${t(`${i18nKey}.this`)} _${t(`${i18nKey}.italic`)}_${t(`${i18nKey}.is`)}`,
+      preview: (
+        <div className="lh-base">
+          {t(`${i18nKey}.this`)} <em>{t(`${i18nKey}.italic`)}</em>{' '}
+          {t(`${i18nKey}.is`)}
+          <br />
+          {t(`${i18nKey}.this`)} <em>{t(`${i18nKey}.italic`)}</em>{' '}
+          {t(`${i18nKey}.is`)}
+        </div>
+      ),
+    },
+    {
+      id: 'strikethrough',
+      title: t(`${i18nKey}.strikethrough`),
+      code: `~~${t(`${i18nKey}.strikethrough`)}~~`,
+      preview: <del>{t(`${i18nKey}.strikethrough`)}</del>,
+    },
+    {
+      id: 'inline-code',
+      title: t(`${i18nKey}.inline_code`),
+      code: `\`${t(`${i18nKey}.inline_code`)}\` \n~~~${t(`${i18nKey}.inline_code`)}~~~`,
+      preview: (
+        <div className="d-flex flex-column gap-2 align-items-start">
+          <code
+            className={`rounded px-1 d-inline-block bg-transparent ${styles.inlineCodeLabel}`}
+          >
+            {t(`${i18nKey}.inline_code`)}
+          </code>
+          <code
+            className={`rounded px-1 d-inline-block bg-transparent ${styles.inlineCodeLabel}`}
+          >
+            {t(`${i18nKey}.inline_code`)}
+          </code>
+        </div>
+      ),
+    },
+    {
+      id: 'bold-italic',
+      title: t(`${i18nKey}.bold_italic`),
+      code: `***${t(`${i18nKey}.all_important`)}***`,
+      preview: (
+        <strong>
+          <em>{t(`${i18nKey}.all_important`).replace('\n', '')}</em>
+        </strong>
+      ),
+    },
+    {
+      id: 'emoji',
+      title: t(`${i18nKey}.emoji`),
+      code: ':+1:\n:white_check_mark:\n:lock:',
+      preview: <span style={{ fontSize: '1.2rem' }}>👍✅🔒</span>,
+    },
+    {
+      id: 'sub',
+      title: t(`${i18nKey}.sub_sup`),
+      code: t(`${i18nKey}.is_text`, {
+        val: `<sub>${t(`${i18nKey}.sub_text`)}</sub>`,
+      }),
+      preview: (
+        <span>
+          {t(`${i18nKey}.this`)} <sub>{t(`${i18nKey}.sub_text`)}</sub>{' '}
+          {t(`${i18nKey}.is`)}
+        </span>
+      ),
+    },
+    {
+      id: 'sup',
+      title: '',
+      code: t(`${i18nKey}.is_text`, {
+        val: `<sup>${t(`${i18nKey}.sup_text`)}</sup>`,
+      }),
+      preview: (
+        <span>
+          {t(`${i18nKey}.this`)} <sup>{t(`${i18nKey}.sup_text`)}</sup>{' '}
+          {t(`${i18nKey}.is`)}
+        </span>
+      ),
+    },
+    {
+      id: 'link-docs',
+      title: t(`${i18nKey}.link_label`),
+      code: `[${t(`${i18nKey}.link_docs`)}](${documentationUrl}/${docsLang})`,
+      preview: (
+        <a
+          href={`${documentationUrl}/${docsLang}`}
+          target="_blank"
+          rel="noreferrer"
+          className="text-secondary text-decoration-underline"
+          onClick={(e) => e.stopPropagation()}
+        >
+          {t(`${i18nKey}.link_growi`)}
+          <span className="material-symbols-outlined">open_in_new</span>
+        </a>
+      ),
+    },
+    {
+      id: 'link-sandbox',
+      title: '',
+      code: `[${t(`${i18nKey}.link_sandbox`)}](/Sandbox)`,
+      preview: (
+        <a
+          href="/Sandbox"
+          className="text-secondary text-decoration-underline"
+          onClick={(e) => e.stopPropagation()}
+        >
+          {t(`${i18nKey}.link_sandbox`)}
+          <span className="material-symbols-outlined">open_in_new</span>
+        </a>
+      ),
+    },
+  ];
+  return (
+    <div className="px-4 py-2">
+      {TEXT_STYLE_GUIDES.map((item) => (
+        <GuideRow
+          key={item.id}
+          title={item.title}
+          code={item.code}
+          preview={item.preview}
+        />
+      ))}
+    </div>
+  );
+};

+ 29 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/dynamic.tsx

@@ -0,0 +1,29 @@
+import type { JSX, RefObject } from 'react';
+import { useEditorGuideModalStatus } from '@growi/editor/dist/states/modal/editor-guide';
+
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+
+type Props = {
+  containerRef: RefObject<HTMLDivElement | null>;
+};
+
+export const EditorGuideModalLazyLoaded = ({
+  containerRef,
+}: Props): JSX.Element => {
+  const { isOpened } = useEditorGuideModalStatus();
+
+  const EditorGuideModal = useLazyLoader(
+    'editor-guide-modal',
+    () =>
+      import('./EditorGuideModal').then((mod) => ({
+        default: mod.EditorGuideModal,
+      })),
+    isOpened,
+  );
+
+  return EditorGuideModal != null ? (
+    <EditorGuideModal containerRef={containerRef} />
+  ) : (
+    <></>
+  );
+};

+ 1 - 0
apps/app/src/client/components/PageEditor/EditorGuideModal/index.ts

@@ -0,0 +1 @@
+export { EditorGuideModalLazyLoaded } from './dynamic';

+ 35 - 31
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -70,6 +70,7 @@ import {
   useConflictEffect,
   useConflictResolver,
 } from './conflict';
+import { EditorGuideModalLazyLoaded } from './EditorGuideModal/dynamic';
 import { EditorNavbar } from './EditorNavbar';
 import { EditorNavbarBottom } from './EditorNavbarBottom';
 import Preview from './Preview';
@@ -462,38 +463,41 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   }
 
   return (
-    <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
-      <div className="page-editor-editor-container flex-expand-vert border-end">
-        <CodeMirrorEditorMain
-          enableUnifiedMergeView={isEnableUnifiedMergeView}
-          enableCollaboration={editorMode === EditorMode.Editor}
-          onSave={saveWithShortcut}
-          onUpload={uploadHandler}
-          acceptedUploadFileType={acceptedUploadFileType}
-          onScroll={scrollEditorHandlerThrottle}
-          indentSize={currentIndentSize ?? defaultIndentSize}
-          user={user ?? undefined}
-          pageId={pageId ?? undefined}
-          editorSettings={editorSettings}
-          onEditorsUpdated={setEditingClients}
-          onScrollToRemoteCursorReady={setScrollToRemoteCursor}
-          cmProps={cmProps}
-        />
+    <>
+      <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
+        <div className="page-editor-editor-container flex-expand-vert border-end">
+          <CodeMirrorEditorMain
+            enableUnifiedMergeView={isEnableUnifiedMergeView}
+            enableCollaboration={editorMode === EditorMode.Editor}
+            onSave={saveWithShortcut}
+            onUpload={uploadHandler}
+            acceptedUploadFileType={acceptedUploadFileType}
+            onScroll={scrollEditorHandlerThrottle}
+            indentSize={currentIndentSize ?? defaultIndentSize}
+            user={user ?? undefined}
+            pageId={pageId ?? undefined}
+            editorSettings={editorSettings}
+            onEditorsUpdated={setEditingClients}
+            onScrollToRemoteCursorReady={setScrollToRemoteCursor}
+            cmProps={cmProps}
+          />
+        </div>
+        <div
+          ref={previewRef}
+          onScroll={scrollPreviewHandlerThrottle}
+          className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex position-relative"
+        >
+          <Preview
+            rendererOptions={rendererOptions}
+            markdown={markdownToPreview}
+            pagePath={currentPagePath}
+            expandContentWidth={shouldExpandContent}
+            style={pastEndStyle}
+          />
+        </div>
       </div>
-      <div
-        ref={previewRef}
-        onScroll={scrollPreviewHandlerThrottle}
-        className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
-      >
-        <Preview
-          rendererOptions={rendererOptions}
-          markdown={markdownToPreview}
-          pagePath={currentPagePath}
-          expandContentWidth={shouldExpandContent}
-          style={pastEndStyle}
-        />
-      </div>
-    </div>
+      <EditorGuideModalLazyLoaded containerRef={previewRef} />
+    </>
   );
 };
 

+ 9 - 3
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -1,13 +1,15 @@
-import React, { type JSX, Suspense, useState } from 'react';
+import { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
-import { InAppNotificationForms } from './InAppNotificationSubstance';
+import { InAppNotificationForms } from './InAppNotificationForms';
+
+export type FilterType = 'all' | 'news' | 'notifications';
 
 const InAppNotificationContent = dynamic(
   () =>
-    import('./InAppNotificationSubstance').then(
+    import('./InAppNotificationContent').then(
       (mod) => mod.InAppNotificationContent,
     ),
   { ssr: false },
@@ -18,6 +20,7 @@ export const InAppNotification = (): JSX.Element => {
 
   const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] =
     useState(false);
+  const [activeFilter, setActiveFilter] = useState<FilterType>('all');
 
   return (
     <div className="px-3">
@@ -30,11 +33,14 @@ export const InAppNotification = (): JSX.Element => {
         onChangeUnopendNotificationsVisible={() => {
           setUnopendNotificationsVisible(!isUnopendNotificationsVisible);
         }}
+        activeFilter={activeFilter}
+        onChangeFilter={setActiveFilter}
       />
 
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
         <InAppNotificationContent
           isUnopendNotificationsVisible={isUnopendNotificationsVisible}
+          activeFilter={activeFilter}
         />
       </Suspense>
     </div>

+ 138 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationContent.tsx

@@ -0,0 +1,138 @@
+import type { JSX } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import InAppNotificationElm from '~/client/components/InAppNotification/InAppNotificationElm';
+import InfiniteScroll from '~/client/components/InfiniteScroll';
+import { NewsItem } from '~/features/news/client/components/NewsItem';
+import { useSidebarMode } from '~/states/ui/sidebar';
+
+import { useMergedInAppNotifications } from './hooks/useMergedInAppNotifications';
+import type { FilterType } from './InAppNotification';
+
+type InAppNotificationContentProps = {
+  isUnopendNotificationsVisible: boolean;
+  activeFilter: FilterType;
+};
+
+export const InAppNotificationContent = (
+  props: InAppNotificationContentProps,
+): JSX.Element => {
+  const { isUnopendNotificationsVisible, activeFilter } = props;
+  const { t } = useTranslation('commons');
+  const { isCollapsedMode } = useSidebarMode();
+
+  // In collapsed mode (hover panel): constrain height + own scrollbar.
+  // In dock/drawer mode: no constraints — outer SimpleBar handles all scrolling.
+  const collapsed = isCollapsedMode();
+  const scrollAreaClassName = collapsed ? 'overflow-auto' : undefined;
+  const scrollAreaStyle = collapsed ? { maxHeight: '60vh' } : undefined;
+
+  const {
+    newsResponse,
+    allNewsItems,
+    newsExhausted,
+    notificationResponse,
+    allNotificationItems,
+    notifExhausted,
+    allModeSWRResponse,
+    mergedItems,
+    handleReadMutate,
+    handleNotificationRead,
+  } = useMergedInAppNotifications(isUnopendNotificationsVisible);
+
+  if (activeFilter === 'news') {
+    if (allNewsItems.length === 0 && !newsResponse.isValidating) {
+      return <>{t('in_app_notification.no_news')}</>;
+    }
+
+    return (
+      <div className={scrollAreaClassName} style={scrollAreaStyle}>
+        <InfiniteScroll
+          swrInifiniteResponse={newsResponse}
+          isReachingEnd={newsExhausted}
+        >
+          <div className="list-group">
+            {allNewsItems.map((item) => (
+              <NewsItem
+                key={item._id.toString()}
+                item={item}
+                onReadMutate={handleReadMutate}
+              />
+            ))}
+          </div>
+        </InfiniteScroll>
+      </div>
+    );
+  }
+
+  if (activeFilter === 'notifications') {
+    if (
+      allNotificationItems.length === 0 &&
+      !notificationResponse.isValidating
+    ) {
+      return <>{t('in_app_notification.no_notification')}</>;
+    }
+
+    return (
+      <div className={scrollAreaClassName} style={scrollAreaStyle}>
+        <InfiniteScroll
+          swrInifiniteResponse={notificationResponse}
+          isReachingEnd={notifExhausted}
+        >
+          <div className="list-group">
+            {allNotificationItems.map((notification) => {
+              const id = notification._id.toString();
+              return (
+                <InAppNotificationElm
+                  key={id}
+                  notification={notification}
+                  onUnopenedNotificationOpend={() => handleNotificationRead(id)}
+                />
+              );
+            })}
+          </div>
+        </InfiniteScroll>
+      </div>
+    );
+  }
+
+  // 'all' filter: merged view
+  if (
+    mergedItems.length === 0 &&
+    !newsResponse.isValidating &&
+    !notificationResponse.isValidating
+  ) {
+    return <>{t('in_app_notification.no_notification')}</>;
+  }
+
+  return (
+    <div className={scrollAreaClassName} style={scrollAreaStyle}>
+      <InfiniteScroll
+        swrInifiniteResponse={allModeSWRResponse}
+        isReachingEnd={newsExhausted && notifExhausted}
+      >
+        <div className="list-group">
+          {mergedItems.map((entry) => {
+            if (entry.type === 'news') {
+              return (
+                <NewsItem
+                  key={`news-${entry.item._id.toString()}`}
+                  item={entry.item}
+                  onReadMutate={handleReadMutate}
+                />
+              );
+            }
+            const id = entry.item._id.toString();
+            return (
+              <InAppNotificationElm
+                key={`notif-${id}`}
+                notification={entry.item}
+                onUnopenedNotificationOpend={() => handleNotificationRead(id)}
+              />
+            );
+          })}
+        </div>
+      </InfiniteScroll>
+    </div>
+  );
+};

+ 97 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.spec.tsx

@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}));
+
+import { InAppNotificationForms } from './InAppNotificationForms';
+
+describe('InAppNotificationForms', () => {
+  const defaultProps = {
+    isUnopendNotificationsVisible: false,
+    onChangeUnopendNotificationsVisible: vi.fn(),
+    activeFilter: 'all' as const,
+    onChangeFilter: vi.fn(),
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  test('should render three filter buttons', () => {
+    render(<InAppNotificationForms {...defaultProps} />);
+    expect(screen.getByText('in_app_notification.filter_all')).toBeTruthy();
+    expect(screen.getByText('in_app_notification.notifications')).toBeTruthy();
+    expect(screen.getByText('in_app_notification.news')).toBeTruthy();
+  });
+
+  test('should call onChangeFilter with "news" when news button clicked', () => {
+    const onChangeFilter = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        onChangeFilter={onChangeFilter}
+      />,
+    );
+    fireEvent.click(screen.getByText('in_app_notification.news'));
+    expect(onChangeFilter).toHaveBeenCalledWith('news');
+  });
+
+  test('should call onChangeFilter with "notifications" when notifications button clicked', () => {
+    const onChangeFilter = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        onChangeFilter={onChangeFilter}
+      />,
+    );
+    fireEvent.click(screen.getByText('in_app_notification.notifications'));
+    expect(onChangeFilter).toHaveBeenCalledWith('notifications');
+  });
+
+  test('should call onChangeFilter with "all" when all button clicked', () => {
+    const onChangeFilter = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        activeFilter="news"
+        onChangeFilter={onChangeFilter}
+      />,
+    );
+    fireEvent.click(screen.getByText('in_app_notification.filter_all'));
+    expect(onChangeFilter).toHaveBeenCalledWith('all');
+  });
+
+  test('should render unread toggle', () => {
+    render(<InAppNotificationForms {...defaultProps} />);
+    const toggle = screen.getByRole('switch');
+    expect(toggle).toBeTruthy();
+  });
+
+  test('should call onChangeUnopendNotificationsVisible when toggle changes', () => {
+    const onChange = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        onChangeUnopendNotificationsVisible={onChange}
+      />,
+    );
+    const toggle = screen.getByRole('switch');
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenCalled();
+  });
+
+  test('active filter button should have btn-primary class', () => {
+    render(<InAppNotificationForms {...defaultProps} activeFilter="news" />);
+    const newsBtn = screen
+      .getByText('in_app_notification.news')
+      .closest('button');
+    expect(newsBtn?.classList.contains('btn-primary')).toBe(true);
+    const allBtn = screen
+      .getByText('in_app_notification.filter_all')
+      .closest('button');
+    expect(allBtn?.classList.contains('btn-outline-secondary')).toBe(true);
+  });
+});

+ 69 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.tsx

@@ -0,0 +1,69 @@
+import { type JSX, useId } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import type { FilterType } from './InAppNotification';
+
+type InAppNotificationFormsProps = {
+  isUnopendNotificationsVisible: boolean;
+  onChangeUnopendNotificationsVisible: () => void;
+  activeFilter: FilterType;
+  onChangeFilter: (filter: FilterType) => void;
+};
+
+export const InAppNotificationForms = (
+  props: InAppNotificationFormsProps,
+): JSX.Element => {
+  const {
+    isUnopendNotificationsVisible,
+    onChangeUnopendNotificationsVisible,
+    activeFilter,
+    onChangeFilter,
+  } = props;
+  const { t } = useTranslation('commons');
+  const toggleId = useId();
+
+  return (
+    <div className="my-2">
+      {/* Filter tabs */}
+      <fieldset className="btn-group w-100 mb-2">
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('all')}
+        >
+          {t('in_app_notification.filter_all')}
+        </button>
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'notifications' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('notifications')}
+        >
+          {t('in_app_notification.notifications')}
+        </button>
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'news' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('news')}
+        >
+          {t('in_app_notification.news')}
+        </button>
+      </fieldset>
+
+      {/* Unread-only toggle */}
+      <div className="form-check form-switch">
+        <label className="form-check-label" htmlFor={toggleId}>
+          {t('in_app_notification.only_unread')}
+        </label>
+        <input
+          id={toggleId}
+          className="form-check-input"
+          type="checkbox"
+          role="switch"
+          aria-checked={isUnopendNotificationsVisible}
+          checked={isUnopendNotificationsVisible}
+          onChange={onChangeUnopendNotificationsVisible}
+        />
+      </div>
+    </div>
+  );
+};

+ 0 - 74
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -1,74 +0,0 @@
-import React, { type JSX } from 'react';
-import { useTranslation } from 'next-i18next';
-
-import InAppNotificationList from '~/client/components/InAppNotification/InAppNotificationList';
-import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
-import { useSWRxInAppNotifications } from '~/stores/in-app-notification';
-
-type InAppNotificationFormsProps = {
-  isUnopendNotificationsVisible: boolean;
-  onChangeUnopendNotificationsVisible: () => void;
-};
-export const InAppNotificationForms = (
-  props: InAppNotificationFormsProps,
-): JSX.Element => {
-  const { isUnopendNotificationsVisible, onChangeUnopendNotificationsVisible } =
-    props;
-  const { t } = useTranslation('commons');
-
-  return (
-    <div className="my-2">
-      <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">
-          {t('in_app_notification.only_unread')}
-        </label>
-        <input
-          id="flexSwitchCheckDefault"
-          className="form-check-input"
-          type="checkbox"
-          role="switch"
-          aria-checked={isUnopendNotificationsVisible}
-          checked={isUnopendNotificationsVisible}
-          onChange={onChangeUnopendNotificationsVisible}
-        />
-      </div>
-    </div>
-  );
-};
-
-type InAppNotificationContentProps = {
-  isUnopendNotificationsVisible: boolean;
-};
-export const InAppNotificationContent = (
-  props: InAppNotificationContentProps,
-): JSX.Element => {
-  const { isUnopendNotificationsVisible } = props;
-  const { t } = useTranslation('commons');
-
-  // TODO: Infinite scroll implemented (https://redmine.weseek.co.jp/issues/138057)
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } =
-    useSWRxInAppNotifications(
-      6,
-      undefined,
-      isUnopendNotificationsVisible
-        ? InAppNotificationStatuses.STATUS_UNOPENED
-        : undefined,
-      { keepPreviousData: true },
-    );
-
-  return (
-    <>
-      {inAppNotificationData != null &&
-      inAppNotificationData.docs.length === 0 ? (
-        // no items
-        t('in_app_notification.no_notification')
-      ) : (
-        // render list-group
-        <InAppNotificationList
-          inAppNotificationData={inAppNotificationData}
-          onUnopenedNotificationOpend={mutateInAppNotificationData}
-        />
-      )}
-    </>
-  );
-};

+ 5 - 4
apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx

@@ -1,5 +1,6 @@
 import { memo, useCallback, useEffect } from 'react';
 
+import { useSWRxNewsUnreadCount } from '~/features/news/client/hooks/use-news';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useGlobalSocket } from '~/states/socket-io';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
@@ -20,10 +21,10 @@ export const PrimaryItemForNotification = memo(
     const { data: notificationCount, mutate: mutateNotificationCount } =
       useSWRxInAppNotificationStatus();
 
-    const badgeContents =
-      notificationCount != null && notificationCount > 0
-        ? notificationCount
-        : undefined;
+    const { data: newsUnreadCount } = useSWRxNewsUnreadCount();
+
+    const totalUnread = (notificationCount ?? 0) + (newsUnreadCount ?? 0);
+    const badgeContents = totalUnread > 0 ? totalUnread : undefined;
 
     const itemHoverHandler = useCallback(
       (contents: SidebarContentsType) => {

+ 201 - 0
apps/app/src/client/components/Sidebar/InAppNotification/hooks/useMergedInAppNotifications.ts

@@ -0,0 +1,201 @@
+import { useCallback, useMemo } from 'react';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+
+import {
+  useSWRINFxNews,
+  useSWRxNewsUnreadCount,
+} from '~/features/news/client/hooks/use-news';
+import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
+import type {
+  IInAppNotificationHasId,
+  PaginateResult,
+} from '~/interfaces/in-app-notification';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { useSWRINFxInAppNotifications } from '~/stores/in-app-notification';
+
+const PER_PAGE = 10;
+
+export type MergedItem =
+  | { type: 'news'; item: INewsItemWithReadStatus; sortKey: Date }
+  | { type: 'notification'; item: IInAppNotificationHasId; sortKey: Date };
+
+export type UseMergedInAppNotificationsResult = {
+  newsResponse: SWRInfiniteResponse<
+    PaginateResult<INewsItemWithReadStatus>,
+    Error
+  >;
+  allNewsItems: INewsItemWithReadStatus[];
+  newsExhausted: boolean;
+
+  notificationResponse: SWRInfiniteResponse<
+    PaginateResult<IInAppNotificationHasId>,
+    Error
+  >;
+  allNotificationItems: IInAppNotificationHasId[];
+  notifExhausted: boolean;
+
+  allModeSWRResponse: SWRInfiniteResponse<
+    PaginateResult<INewsItemWithReadStatus>,
+    Error
+  >;
+  mergedItems: MergedItem[];
+
+  handleReadMutate: () => void;
+  handleNotificationRead: (notificationId: string) => void;
+};
+
+/**
+ * Encapsulates the data layer for the InAppNotification sidebar panel:
+ * - Two SWRInfinite streams (news + notifications)
+ * - Pagination exhaustion detection
+ * - A synthetic SWRInfiniteResponse for the merged "all" view
+ * - Client-side merge + sort by time
+ * - Read-state mutation handlers (SWR-native optimistic update)
+ */
+export const useMergedInAppNotifications = (
+  isUnopendNotificationsVisible: boolean,
+): UseMergedInAppNotificationsResult => {
+  const notificationStatus = isUnopendNotificationsVisible
+    ? InAppNotificationStatuses.STATUS_UNOPENED
+    : undefined;
+
+  const newsResponse = useSWRINFxNews(
+    PER_PAGE,
+    { onlyUnread: isUnopendNotificationsVisible },
+    { keepPreviousData: true },
+  );
+  const { mutate: mutateNewsUnreadCount } = useSWRxNewsUnreadCount();
+
+  const notificationResponse = useSWRINFxInAppNotifications(
+    PER_PAGE,
+    { status: notificationStatus },
+    { keepPreviousData: true },
+  );
+
+  const allNewsItems: INewsItemWithReadStatus[] = useMemo(() => {
+    if (!newsResponse.data) return [];
+    return newsResponse.data.flatMap((page) => page.docs);
+  }, [newsResponse.data]);
+
+  const allNotificationItems: IInAppNotificationHasId[] = useMemo(() => {
+    if (!notificationResponse.data) return [];
+    return notificationResponse.data.flatMap((page) => page.docs);
+  }, [notificationResponse.data]);
+
+  const newsExhausted = useMemo(
+    () =>
+      newsResponse.data != null &&
+      newsResponse.data.length > 0 &&
+      !newsResponse.data[newsResponse.data.length - 1].hasNextPage,
+    [newsResponse.data],
+  );
+
+  const notifExhausted = useMemo(
+    () =>
+      notificationResponse.data != null &&
+      notificationResponse.data.length > 0 &&
+      !notificationResponse.data[notificationResponse.data.length - 1]
+        .hasNextPage,
+    [notificationResponse.data],
+  );
+
+  // Synthetic SWRInfiniteResponse for InfiniteScroll in 'all' mode.
+  // Typed to match newsResponse's shape so InfiniteScroll<E> receives a
+  // well-typed response without `as unknown as` casts.
+  const allModeSWRResponse = useMemo<
+    SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>
+  >(
+    () => ({
+      data: newsResponse.data,
+      error: newsResponse.error ?? notificationResponse.error,
+      isValidating:
+        newsResponse.isValidating || notificationResponse.isValidating,
+      isLoading: newsResponse.isLoading || notificationResponse.isLoading,
+      mutate: newsResponse.mutate,
+      setSize: async (updater) => {
+        const nextNewsSize =
+          typeof updater === 'function' ? updater(newsResponse.size) : updater;
+        const nextNotifSize =
+          typeof updater === 'function'
+            ? updater(notificationResponse.size)
+            : updater;
+        const [newsResult] = await Promise.all([
+          newsExhausted
+            ? Promise.resolve(newsResponse.data)
+            : newsResponse.setSize(nextNewsSize),
+          notifExhausted
+            ? Promise.resolve(notificationResponse.data)
+            : notificationResponse.setSize(nextNotifSize),
+        ]);
+        return newsResult;
+      },
+      size: Math.max(newsResponse.size, notificationResponse.size),
+    }),
+    [newsResponse, notificationResponse, newsExhausted, notifExhausted],
+  );
+
+  const mergedItems: MergedItem[] = useMemo(() => {
+    const newsEntries: MergedItem[] = allNewsItems.map((item) => ({
+      type: 'news',
+      item,
+      sortKey:
+        item.publishedAt instanceof Date
+          ? item.publishedAt
+          : new Date(item.publishedAt),
+    }));
+    const notifEntries: MergedItem[] = allNotificationItems.map((item) => ({
+      type: 'notification',
+      item,
+      sortKey:
+        item.createdAt instanceof Date
+          ? item.createdAt
+          : new Date(item.createdAt),
+    }));
+    return [...newsEntries, ...notifEntries].sort(
+      (a, b) => b.sortKey.getTime() - a.sortKey.getTime(),
+    );
+  }, [allNewsItems, allNotificationItems]);
+
+  // SWR's mutate is stable per cache key — destructure once and depend on it
+  // rather than the whole response object (which may carry unstable identity).
+  const { mutate: mutateNews } = newsResponse;
+  const { mutate: mutateNotifications } = notificationResponse;
+
+  const handleReadMutate = useCallback(() => {
+    mutateNews();
+    mutateNewsUnreadCount();
+  }, [mutateNews, mutateNewsUnreadCount]);
+
+  // SWR-idiomatic optimistic update: rewrite the per-page cache in place and
+  // suppress revalidation so the dot stays removed across unmount/remount.
+  const handleNotificationRead = useCallback(
+    (notificationId: string) => {
+      mutateNotifications(
+        (pages) =>
+          pages?.map((page) => ({
+            ...page,
+            docs: page.docs.map((doc) =>
+              doc._id.toString() === notificationId
+                ? { ...doc, status: InAppNotificationStatuses.STATUS_OPENED }
+                : doc,
+            ),
+          })),
+        { revalidate: false },
+      );
+    },
+    [mutateNotifications],
+  );
+
+  return {
+    newsResponse,
+    allNewsItems,
+    newsExhausted,
+    notificationResponse,
+    allNotificationItems,
+    notifExhausted,
+    allModeSWRResponse,
+    mergedItems,
+    handleReadMutate,
+    handleNotificationRead,
+  };
+};

+ 2 - 1
apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantAlert.tsx

@@ -71,7 +71,8 @@ export const FixPageGrantAlert = (): JSX.Element => {
   if (
     pageId == null ||
     !hasParent ||
-    !dataIsGrantNormalized?.isGrantNormalized
+    dataIsGrantNormalized?.isGrantNormalized == null ||
+    dataIsGrantNormalized.isGrantNormalized
   ) {
     // biome-ignore lint/complexity/noUselessFragments: ignore
     return <></>;

+ 14 - 1
apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx

@@ -2,16 +2,20 @@ import { type JSX, useCallback } from 'react';
 import Script from 'next/script';
 import type { IGraphViewerGlobal } from '@growi/remark-drawio';
 
+import { patchStencilRegistryUrls } from './patch-stencil-registry-urls';
 import { generateViewerMinJsUrl } from './use-viewer-min-js-url';
 
 declare global {
   var GraphViewer: IGraphViewerGlobal;
+  var mxStencilRegistry: { libraries: Record<string, string[]> } | undefined;
 }
 
 type Props = {
   drawioUri: string;
 };
 
+const DEFAULT_DRAWIO_ORIGIN = 'https://embed.diagrams.net';
+
 export const DrawioViewerScript = ({ drawioUri }: Props): JSX.Element => {
   const loadedHandler = useCallback(() => {
     // disable useResizeSensor and checkVisibleState
@@ -29,8 +33,17 @@ export const DrawioViewerScript = ({ drawioUri }: Props): JSX.Element => {
     GraphViewer.prototype.lightboxZIndex = 1200;
     GraphViewer.prototype.toolbarZIndex = 1200;
 
+    try {
+      const origin = new URL(drawioUri).origin;
+      if (origin !== DEFAULT_DRAWIO_ORIGIN) {
+        patchStencilRegistryUrls(mxStencilRegistry?.libraries, origin);
+      }
+    } catch {
+      // skip patching if drawioUri cannot be parsed
+    }
+
     GraphViewer.processElements();
-  }, []);
+  }, [drawioUri]);
 
   // Return empty element if drawioUri is not provided to avoid Invalid URL error
   if (!drawioUri) {

+ 140 - 0
apps/app/src/components/Script/DrawioViewerScript/patch-stencil-registry-urls.spec.ts

@@ -0,0 +1,140 @@
+import { patchStencilRegistryUrls } from './patch-stencil-registry-urls';
+
+describe('patchStencilRegistryUrls', () => {
+  it('should replace viewer.diagrams.net origin with the local origin', () => {
+    // Arrange
+    const libraries: Record<string, string[]> = {
+      basic: [
+        'https://viewer.diagrams.net/shapes/basic/cube.xml',
+        'https://viewer.diagrams.net/stencils/basic/sphere.xml',
+      ],
+      arrows: ['https://viewer.diagrams.net/shapes/arrows/arrow.xml'],
+    };
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+
+    // Assert
+    expect(libraries).toEqual({
+      basic: [
+        'http://localhost:8080/shapes/basic/cube.xml',
+        'http://localhost:8080/stencils/basic/sphere.xml',
+      ],
+      arrows: ['http://localhost:8080/shapes/arrows/arrow.xml'],
+    });
+  });
+
+  it('should mutate libraries in place', () => {
+    // Arrange
+    const libraries: Record<string, string[]> = {
+      basic: ['https://viewer.diagrams.net/shapes/basic/cube.xml'],
+    };
+    const ref = libraries;
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+
+    // Assert
+    expect(ref).toBe(libraries);
+    expect(ref.basic[0]).toBe('http://localhost:8080/shapes/basic/cube.xml');
+  });
+
+  it('should leave URLs without the viewer.diagrams.net origin unchanged', () => {
+    // Arrange
+    const libraries: Record<string, string[]> = {
+      mixed: [
+        'https://viewer.diagrams.net/shapes/a.xml',
+        'https://example.com/shapes/b.xml',
+        '/relative/path/c.xml',
+      ],
+    };
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+
+    // Assert
+    expect(libraries.mixed).toEqual([
+      'http://localhost:8080/shapes/a.xml',
+      'https://example.com/shapes/b.xml',
+      '/relative/path/c.xml',
+    ]);
+  });
+
+  it('should be idempotent on a second invocation', () => {
+    // Arrange
+    const libraries: Record<string, string[]> = {
+      basic: ['https://viewer.diagrams.net/shapes/basic/cube.xml'],
+    };
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+
+    // Assert
+    expect(libraries.basic).toEqual([
+      'http://localhost:8080/shapes/basic/cube.xml',
+    ]);
+  });
+
+  it('should not throw when libraries is undefined', () => {
+    // Act & Assert
+    expect(() =>
+      patchStencilRegistryUrls(undefined, 'http://localhost:8080'),
+    ).not.toThrow();
+  });
+
+  it('should handle an empty libraries object', () => {
+    // Arrange
+    const libraries: Record<string, string[]> = {};
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+
+    // Assert
+    expect(libraries).toEqual({});
+  });
+
+  it('should handle an empty url array', () => {
+    // Arrange
+    const libraries: Record<string, string[]> = { basic: [] };
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+
+    // Assert
+    expect(libraries.basic).toEqual([]);
+  });
+
+  it('should skip non-string entries defensively', () => {
+    // Arrange — simulates an unexpected runtime value from the third-party script
+    const libraries = {
+      basic: ['https://viewer.diagrams.net/shapes/a.xml', null, undefined, 123],
+    } as unknown as Record<string, string[]>;
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'http://localhost:8080');
+
+    // Assert
+    expect(libraries.basic).toEqual([
+      'http://localhost:8080/shapes/a.xml',
+      null,
+      undefined,
+      123,
+    ]);
+  });
+
+  it('should support custom origins with ports and paths', () => {
+    // Arrange
+    const libraries: Record<string, string[]> = {
+      basic: ['https://viewer.diagrams.net/shapes/basic/cube.xml'],
+    };
+
+    // Act
+    patchStencilRegistryUrls(libraries, 'https://drawio.example.com:8443');
+
+    // Assert
+    expect(libraries.basic).toEqual([
+      'https://drawio.example.com:8443/shapes/basic/cube.xml',
+    ]);
+  });
+});

+ 20 - 0
apps/app/src/components/Script/DrawioViewerScript/patch-stencil-registry-urls.ts

@@ -0,0 +1,20 @@
+// viewer-static.min.js hardcodes stencil resource URLs to https://viewer.diagrams.net.
+// For local draw.io instances that origin is unreachable, so we rewrite the URLs
+// in mxStencilRegistry.libraries (fetched lazily on first diagram render) to point
+// to the configured local origin before any diagram is rendered.
+// refs: https://github.com/growilabs/growi/issues/10726
+export const VIEWER_DIAGRAMS_NET_ORIGIN = 'https://viewer.diagrams.net';
+
+export const patchStencilRegistryUrls = (
+  libraries: Record<string, string[]> | undefined,
+  localOrigin: string,
+): void => {
+  if (libraries == null) return;
+  for (const key of Object.keys(libraries)) {
+    libraries[key] = libraries[key].map((url) =>
+      typeof url === 'string'
+        ? url.replace(VIEWER_DIAGRAMS_NET_ORIGIN, localOrigin)
+        : url,
+    );
+  }
+};

+ 191 - 0
apps/app/src/features/news/client/components/NewsItem.spec.tsx

@@ -0,0 +1,191 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import mongoose from 'mongoose';
+
+const mocks = vi.hoisted(() => {
+  const apiv3Post = vi.fn().mockResolvedValue({});
+  const mutate = vi.fn();
+  const i18nLanguage = { current: 'ja_JP' };
+  return { apiv3Post, mutate, i18nLanguage };
+});
+
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Post: mocks.apiv3Post,
+}));
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+    i18n: {
+      get language() {
+        return mocks.i18nLanguage.current;
+      },
+    },
+  }),
+}));
+
+// Mock window.open
+const mockOpen = vi.fn();
+vi.stubGlobal('open', mockOpen);
+
+import type { INewsItemWithReadStatus } from '../../interfaces/news-item';
+import { NewsItem } from './NewsItem';
+
+const makeNewsItem = (
+  overrides: Partial<INewsItemWithReadStatus> = {},
+): INewsItemWithReadStatus => ({
+  _id: new mongoose.Types.ObjectId(),
+  externalId: 'test-001',
+  title: { ja_JP: 'テストニュース', en_US: 'Test News' },
+  publishedAt: new Date('2026-01-01T00:00:00Z'),
+  fetchedAt: new Date(),
+  isRead: false,
+  ...overrides,
+});
+
+describe('NewsItem', () => {
+  const onReadMutate = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mocks.i18nLanguage.current = 'ja_JP';
+  });
+
+  describe('emoji display', () => {
+    test('should display emoji when provided', () => {
+      const item = makeNewsItem({ emoji: '🚀' });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('🚀')).toBeTruthy();
+    });
+
+    test('should display 📢 fallback when emoji is not set', () => {
+      const item = makeNewsItem({ emoji: undefined });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('📢')).toBeTruthy();
+    });
+  });
+
+  describe('locale fallback', () => {
+    test('should display ja_JP title when i18n language is ja_JP', () => {
+      mocks.i18nLanguage.current = 'ja_JP';
+      const item = makeNewsItem({
+        title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
+      });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('日本語タイトル')).toBeTruthy();
+    });
+
+    test('should fallback to ja_JP when i18n language has no match', () => {
+      mocks.i18nLanguage.current = 'de_DE';
+      const item = makeNewsItem({
+        title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
+      });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('日本語タイトル')).toBeTruthy();
+    });
+
+    test('should fallback to en_US when ja_JP is not available', () => {
+      mocks.i18nLanguage.current = 'de_DE';
+      const item = makeNewsItem({ title: { en_US: 'English Only' } });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('English Only')).toBeTruthy();
+    });
+
+    test('should fallback to first available key when neither ja_JP nor en_US', () => {
+      mocks.i18nLanguage.current = 'de_DE';
+      const item = makeNewsItem({ title: { fr_FR: 'Titre Français' } });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('Titre Français')).toBeTruthy();
+    });
+  });
+
+  describe('unread/read visual styling', () => {
+    test('should apply fw-bold class for unread items', () => {
+      const item = makeNewsItem({ isRead: false });
+      const { container } = render(
+        <NewsItem item={item} onReadMutate={onReadMutate} />,
+      );
+      // Title should have fw-bold
+      const title = container.querySelector('.fw-bold');
+      expect(title).not.toBeNull();
+    });
+
+    test('should apply fw-normal class for read items', () => {
+      const item = makeNewsItem({ isRead: true });
+      const { container } = render(
+        <NewsItem item={item} onReadMutate={onReadMutate} />,
+      );
+      const title = container.querySelector('.fw-normal');
+      expect(title).not.toBeNull();
+    });
+
+    test('should show unread dot for unread items', () => {
+      const item = makeNewsItem({ isRead: false });
+      const { container } = render(
+        <NewsItem item={item} onReadMutate={onReadMutate} />,
+      );
+      const dot = container.querySelector('.bg-primary.rounded-circle');
+      expect(dot).not.toBeNull();
+    });
+  });
+
+  describe('click handling', () => {
+    test('should call mark-read API when clicked', async () => {
+      const item = makeNewsItem({ isRead: false });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      const element = screen.getByRole('button');
+      fireEvent.click(element);
+
+      // Wait for async
+      await vi.waitFor(() => {
+        expect(mocks.apiv3Post).toHaveBeenCalledWith(
+          '/news/mark-read',
+          expect.objectContaining({ newsItemId: item._id.toString() }),
+        );
+      });
+    });
+
+    test('should open URL in new tab when url is set', async () => {
+      const item = makeNewsItem({
+        url: 'https://github.com/growi',
+        isRead: false,
+      });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      const element = screen.getByRole('button');
+      fireEvent.click(element);
+
+      await vi.waitFor(() => {
+        expect(mockOpen).toHaveBeenCalledWith(
+          'https://github.com/growi',
+          '_blank',
+          'noopener,noreferrer',
+        );
+      });
+    });
+
+    test('should NOT open URL when url is not set', async () => {
+      const item = makeNewsItem({ url: undefined, isRead: false });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      const element = screen.getByRole('button');
+      fireEvent.click(element);
+
+      await vi.waitFor(() => {
+        expect(mocks.apiv3Post).toHaveBeenCalled();
+      });
+      expect(mockOpen).not.toHaveBeenCalled();
+    });
+
+    test('should call onReadMutate after marking as read', async () => {
+      const item = makeNewsItem({ isRead: false });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      fireEvent.click(screen.getByRole('button'));
+
+      await vi.waitFor(() => {
+        expect(onReadMutate).toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 83 - 0
apps/app/src/features/news/client/components/NewsItem.tsx

@@ -0,0 +1,83 @@
+import type { FC } from 'react';
+import { memo } from 'react';
+import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+
+import unreadDotStyles from '~/client/components/InAppNotification/UnreadDot.module.scss';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { getLocale } from '~/utils/locale-utils';
+
+import type { INewsItemWithReadStatus } from '../../interfaces/news-item';
+
+const DEFAULT_EMOJI = '📢';
+
+/**
+ * Resolve the title for the given locale with fallback chain:
+ * browserLocale → ja_JP → en_US → first available key
+ */
+const resolveTitle = (
+  title: Record<string, string>,
+  locale: string,
+): string => {
+  if (title[locale]) return title[locale];
+  if (title.ja_JP) return title.ja_JP;
+  if (title.en_US) return title.en_US;
+  const keys = Object.keys(title);
+  return keys.length > 0 ? title[keys[0]] : '';
+};
+
+type Props = {
+  item: INewsItemWithReadStatus;
+  onReadMutate: () => void;
+};
+
+const NewsItemInner: FC<Props> = ({ item, onReadMutate }) => {
+  const { i18n } = useTranslation();
+  const locale = i18n.language;
+  const title = resolveTitle(item.title, locale);
+  const emoji = item.emoji ?? DEFAULT_EMOJI;
+
+  const publishedDate =
+    item.publishedAt instanceof Date
+      ? item.publishedAt
+      : new Date(item.publishedAt);
+  const formattedDate = format(publishedDate, 'PP', {
+    locale: getLocale(locale),
+  });
+
+  const handleClick = async () => {
+    try {
+      await apiv3Post('/news/mark-read', { newsItemId: item._id.toString() });
+      onReadMutate();
+    } catch {
+      // silently ignore mark-read failures
+    }
+
+    if (item.url) {
+      window.open(item.url, '_blank', 'noopener,noreferrer');
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      className="list-group-item list-group-item-action w-100 text-start bg-transparent"
+      onClick={handleClick}
+    >
+      <div className="d-flex align-items-center">
+        <span
+          className={`${item.isRead ? '' : 'bg-primary'} rounded-circle me-3 ${unreadDotStyles['unread-dot']}`}
+        />
+
+        <span className="me-2 fs-5 lh-1">{emoji}</span>
+
+        <div>
+          <span className={item.isRead ? 'fw-normal' : 'fw-bold'}>{title}</span>
+          <div className="text-muted small">{formattedDate}</div>
+        </div>
+      </div>
+    </button>
+  );
+};
+
+export const NewsItem = memo(NewsItemInner);

+ 77 - 0
apps/app/src/features/news/client/components/admin/NewsDeliverySetting.tsx

@@ -0,0 +1,77 @@
+import type { FC } from 'react';
+import { useCallback } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import loggerFactory from '~/utils/logger';
+
+import {
+  useSWRxNewsDeliverySetting,
+  useUpdateNewsDeliverySetting,
+} from '../../services/news-delivery-setting';
+
+const logger = loggerFactory('growi:feature:news:admin:NewsDeliverySetting');
+
+export const NewsDeliverySetting: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data, isLoading } = useSWRxNewsDeliverySetting();
+  const updateDeliverySetting = useUpdateNewsDeliverySetting();
+
+  const isEnabled = data?.isDeliveryEnabled ?? true;
+
+  const onChange = useCallback(
+    async (event: React.ChangeEvent<HTMLInputElement>) => {
+      const next = event.target.checked;
+      try {
+        await updateDeliverySetting(next);
+        toastSuccess(
+          t('admin:news_delivery.update_succeeded', {
+            defaultValue: 'News delivery setting updated',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [updateDeliverySetting, t],
+  );
+
+  return (
+    <div className="row mb-3">
+      <label
+        className="text-start text-md-end col-md-3 col-form-label"
+        htmlFor="checkbox-news-is-delivery-enabled"
+      >
+        {t('admin:news_delivery.label', { defaultValue: 'News delivery' })}
+      </label>
+      <div className="col-md-6 py-2">
+        <div className="form-check form-switch">
+          <input
+            type="checkbox"
+            id="checkbox-news-is-delivery-enabled"
+            className="form-check-input"
+            checked={isEnabled}
+            disabled={isLoading}
+            onChange={onChange}
+          />
+          <label
+            className="form-label form-check-label"
+            htmlFor="checkbox-news-is-delivery-enabled"
+          >
+            {t('admin:news_delivery.enable', {
+              defaultValue: 'Enable news delivery',
+            })}
+          </label>
+        </div>
+        <p className="form-text text-muted">
+          {t('admin:news_delivery.description', {
+            defaultValue:
+              'Controls whether the cron job pulls the news feed and updates the local cache. Existing cached items remain visible while delivery is disabled.',
+          })}
+        </p>
+      </div>
+    </div>
+  );
+};

+ 59 - 0
apps/app/src/features/news/client/hooks/use-news.ts

@@ -0,0 +1,59 @@
+import type { SWRConfiguration, SWRResponse } from 'swr';
+import useSWR from 'swr';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+import useSWRInfinite from 'swr/infinite';
+
+import type { PaginateResult } from '~/interfaces/in-app-notification';
+
+import { apiv3Get } from '../../../../client/util/apiv3-client';
+import type { INewsItemWithReadStatus } from '../../interfaces/news-item';
+
+const NEWS_PER_PAGE = 10;
+
+type NewsListKey = [string, number, number, boolean] | null;
+
+/**
+ * SWRInfinite hook for paginated news items
+ */
+export const useSWRINFxNews = (
+  limit: number = NEWS_PER_PAGE,
+  options?: { onlyUnread?: boolean },
+  config?: SWRConfiguration,
+): SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error> => {
+  const onlyUnread = options?.onlyUnread ?? false;
+
+  return useSWRInfinite<PaginateResult<INewsItemWithReadStatus>, Error>(
+    (pageIndex, previousPageData): NewsListKey => {
+      if (previousPageData != null && !previousPageData.hasNextPage)
+        return null;
+      const offset = pageIndex * limit;
+      return ['/news/list', limit, offset, onlyUnread];
+    },
+    ([endpoint, limit, offset, onlyUnread]) =>
+      apiv3Get<PaginateResult<INewsItemWithReadStatus>>(endpoint, {
+        limit,
+        offset,
+        onlyUnread,
+      }).then((response) => response.data),
+    {
+      ...config,
+      revalidateFirstPage: false,
+    },
+  );
+};
+
+/**
+ * SWR hook for news unread count
+ */
+export const useSWRxNewsUnreadCount = (
+  config?: SWRConfiguration,
+): SWRResponse<number, Error> => {
+  return useSWR<number, Error>(
+    '/news/unread-count',
+    (endpoint) =>
+      apiv3Get<{ count: number }>(endpoint).then(
+        (response) => response.data.count,
+      ),
+    config,
+  );
+};

+ 42 - 0
apps/app/src/features/news/client/services/news-delivery-setting.ts

@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+import useSWR, { type SWRResponse } from 'swr';
+
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+
+const ENDPOINT = '/news/admin/delivery-setting';
+
+type DeliverySettingResponse = {
+  isDeliveryEnabled: boolean;
+};
+
+/**
+ * Fetch the current value of `news:isDeliveryEnabled` (admin only).
+ */
+export const useSWRxNewsDeliverySetting = (): SWRResponse<
+  DeliverySettingResponse,
+  Error
+> => {
+  return useSWR(
+    ENDPOINT,
+    async (endpoint) =>
+      (await apiv3Get<DeliverySettingResponse>(endpoint)).data,
+  );
+};
+
+/**
+ * Returns a callback that updates the news delivery flag on the server and
+ * revalidates the SWR cache so the UI reflects the new value.
+ */
+export const useUpdateNewsDeliverySetting = (): ((
+  flag: boolean,
+) => Promise<void>) => {
+  const { mutate } = useSWRxNewsDeliverySetting();
+
+  return useCallback(
+    async (flag: boolean) => {
+      await apiv3Post(ENDPOINT, { flag });
+      await mutate({ isDeliveryEnabled: flag }, { revalidate: false });
+    },
+    [mutate],
+  );
+};

+ 34 - 0
apps/app/src/features/news/interfaces/news-item.ts

@@ -0,0 +1,34 @@
+import type { Types } from 'mongoose';
+
+export interface INewsItem {
+  externalId: string;
+  title: Record<string, string>;
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: Date;
+  fetchedAt: Date;
+  conditions?: {
+    targetRoles?: string[];
+  };
+}
+
+export interface INewsItemHasId extends INewsItem {
+  _id: Types.ObjectId;
+}
+
+export interface INewsItemWithReadStatus extends INewsItemHasId {
+  isRead: boolean;
+}
+
+export interface INewsItemInput {
+  id: string;
+  title: Record<string, string>;
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: string | Date;
+  conditions?: {
+    targetRoles?: string[];
+  };
+}

+ 11 - 0
apps/app/src/features/news/interfaces/news-read-status.ts

@@ -0,0 +1,11 @@
+import type { Types } from 'mongoose';
+
+export interface INewsReadStatus {
+  userId: Types.ObjectId;
+  newsItemId: Types.ObjectId;
+  readAt: Date;
+}
+
+export interface INewsReadStatusHasId extends INewsReadStatus {
+  _id: Types.ObjectId;
+}

+ 56 - 0
apps/app/src/features/news/server/models/news-item.spec.ts

@@ -0,0 +1,56 @@
+import type { INewsItemHasId } from '../../interfaces/news-item';
+import { NewsItem } from './news-item';
+
+describe('NewsItem model', () => {
+  describe('schema structure', () => {
+    test('should have required fields defined in schema', () => {
+      const schemaPaths = NewsItem.schema.paths;
+      expect(schemaPaths.externalId).toBeDefined();
+      expect(schemaPaths.title).toBeDefined();
+      expect(schemaPaths.publishedAt).toBeDefined();
+      expect(schemaPaths.fetchedAt).toBeDefined();
+    });
+
+    test('should have optional fields defined in schema', () => {
+      const schemaPaths = NewsItem.schema.paths;
+      expect(schemaPaths.body).toBeDefined();
+      expect(schemaPaths.emoji).toBeDefined();
+      expect(schemaPaths.url).toBeDefined();
+      expect(schemaPaths['conditions.targetRoles']).toBeDefined();
+    });
+
+    test('externalId should have unique index', () => {
+      const externalIdPath = NewsItem.schema.paths.externalId as unknown as {
+        options: Record<string, unknown>;
+      };
+      expect(externalIdPath.options.unique).toBe(true);
+    });
+
+    test('fetchedAt should have TTL expires option', () => {
+      const schema = NewsItem.schema;
+      // Verify TTL index exists by checking index definitions
+      const indexes = schema.indexes() as unknown as Array<
+        [Record<string, unknown>, Record<string, unknown>]
+      >;
+      const ttlIndex = indexes.find(
+        (indexDef) =>
+          indexDef[0].fetchedAt !== undefined &&
+          indexDef[1].expireAfterSeconds !== undefined,
+      );
+      expect(ttlIndex).toBeDefined();
+    });
+  });
+
+  describe('type compatibility', () => {
+    test('should be assignable to INewsItemHasId', () => {
+      const item = new NewsItem({
+        externalId: 'test-001',
+        title: { ja_JP: 'テスト' },
+        publishedAt: new Date(),
+        fetchedAt: new Date(),
+      });
+      const typed: INewsItemHasId = item as unknown as INewsItemHasId;
+      expect(typed.externalId).toBe('test-001');
+    });
+  });
+});

+ 56 - 0
apps/app/src/features/news/server/models/news-item.ts

@@ -0,0 +1,56 @@
+import type { Document, Model, Types } from 'mongoose';
+import { Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type { INewsItem, INewsItemHasId } from '../../interfaces/news-item';
+
+// 90 days in seconds
+const TTL_90_DAYS = 60 * 60 * 24 * 90;
+
+export interface NewsItemDocument extends INewsItem, Document {
+  _id: Types.ObjectId;
+}
+
+export interface NewsItemModel extends Model<NewsItemDocument> {}
+
+const NewsItemSchema = new Schema<NewsItemDocument, NewsItemModel>({
+  externalId: {
+    type: String,
+    required: true,
+    unique: true,
+  },
+  title: {
+    type: Map,
+    of: String,
+    required: true,
+  },
+  body: {
+    type: Map,
+    of: String,
+  },
+  emoji: {
+    type: String,
+  },
+  url: {
+    type: String,
+  },
+  publishedAt: {
+    type: Date,
+    required: true,
+    index: true,
+  },
+  fetchedAt: {
+    type: Date,
+    required: true,
+    index: { expireAfterSeconds: TTL_90_DAYS },
+  },
+  conditions: {
+    targetRoles: [{ type: String }],
+  },
+});
+
+export const NewsItem = getOrCreateModel<INewsItemHasId, NewsItemModel>(
+  'NewsItem',
+  NewsItemSchema,
+);

+ 29 - 0
apps/app/src/features/news/server/models/news-read-status.spec.ts

@@ -0,0 +1,29 @@
+import { NewsReadStatus } from './news-read-status';
+
+describe('NewsReadStatus model', () => {
+  describe('schema structure', () => {
+    test('should have userId and newsItemId fields', () => {
+      const schemaPaths = NewsReadStatus.schema.paths;
+      expect(schemaPaths.userId).toBeDefined();
+      expect(schemaPaths.newsItemId).toBeDefined();
+    });
+
+    test('should have readAt field', () => {
+      const schemaPaths = NewsReadStatus.schema.paths;
+      expect(schemaPaths.readAt).toBeDefined();
+    });
+
+    test('should have compound unique index on userId + newsItemId', () => {
+      const schema = NewsReadStatus.schema;
+      const indexes = schema.indexes() as unknown as Array<
+        [Record<string, unknown>, Record<string, unknown>]
+      >;
+      const compoundIndex = indexes.find((indexDef) => {
+        const fieldKeys = Object.keys(indexDef[0]);
+        return fieldKeys.includes('userId') && fieldKeys.includes('newsItemId');
+      });
+      expect(compoundIndex).toBeDefined();
+      expect(compoundIndex?.[1].unique).toBe(true);
+    });
+  });
+});

+ 43 - 0
apps/app/src/features/news/server/models/news-read-status.ts

@@ -0,0 +1,43 @@
+import type { Document, Model, Types } from 'mongoose';
+import { Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type {
+  INewsReadStatus,
+  INewsReadStatusHasId,
+} from '../../interfaces/news-read-status';
+
+export interface NewsReadStatusDocument extends INewsReadStatus, Document {
+  _id: Types.ObjectId;
+}
+
+export interface NewsReadStatusModel extends Model<NewsReadStatusDocument> {}
+
+const NewsReadStatusSchema = new Schema<
+  NewsReadStatusDocument,
+  NewsReadStatusModel
+>({
+  userId: {
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    required: true,
+  },
+  newsItemId: {
+    type: Schema.Types.ObjectId,
+    ref: 'NewsItem',
+    required: true,
+  },
+  readAt: {
+    type: Date,
+    required: true,
+    default: Date.now,
+  },
+});
+
+NewsReadStatusSchema.index({ userId: 1, newsItemId: 1 }, { unique: true });
+
+export const NewsReadStatus = getOrCreateModel<
+  INewsReadStatusHasId,
+  NewsReadStatusModel
+>('NewsReadStatus', NewsReadStatusSchema);

+ 286 - 0
apps/app/src/features/news/server/routes/news-integration.integ.ts

@@ -0,0 +1,286 @@
+/**
+ * Integration tests for News API
+ * Requires MongoDB connection (app-integration test environment)
+ */
+import type { IUserHasId } from '@growi/core';
+import express from 'express';
+import mongoose from 'mongoose';
+import request from 'supertest';
+
+import { NewsItem } from '../models/news-item';
+import { NewsReadStatus } from '../models/news-read-status';
+import { createNewsRouter } from './news';
+
+const buildApp = (userOverride: Partial<IUserHasId> = {}) => {
+  const userId = new mongoose.Types.ObjectId();
+  const app = express();
+  app.use(express.json());
+  app.use((req: express.Request & { user?: IUserHasId }, _res, next) => {
+    req.user = {
+      _id: userId,
+      admin: false,
+      ...userOverride,
+    } as unknown as IUserHasId;
+    next();
+  });
+  app.use('/apiv3/news', createNewsRouter());
+  return { app, userId };
+};
+
+describe('News API Integration', () => {
+  beforeEach(async () => {
+    await NewsItem.deleteMany({});
+    await NewsReadStatus.deleteMany({});
+  });
+
+  describe('GET /apiv3/news/list', () => {
+    test('should return empty list when no news', async () => {
+      const { app } = buildApp();
+      const res = await request(app).get('/apiv3/news/list');
+      expect(res.status).toBe(200);
+      expect(res.body.docs).toEqual([]);
+      expect(res.body.totalDocs).toBe(0);
+    });
+
+    test('should return news filtered by role', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'admin-only',
+          title: { ja_JP: '管理者向け' },
+          publishedAt: now,
+          fetchedAt: now,
+          conditions: { targetRoles: ['admin'] },
+        },
+        {
+          externalId: 'all-users',
+          title: { ja_JP: '全ユーザー向け' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      // General user should only see all-users item
+      const { app } = buildApp({ admin: false });
+      const res = await request(app).get('/apiv3/news/list');
+      expect(res.status).toBe(200);
+      expect(res.body.docs).toHaveLength(1);
+      expect(res.body.docs[0].externalId).toBe('all-users');
+    });
+
+    test('admin user should see admin-only items', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'admin-only',
+          title: { ja_JP: '管理者向け' },
+          publishedAt: now,
+          fetchedAt: now,
+          conditions: { targetRoles: ['admin'] },
+        },
+        {
+          externalId: 'all-users',
+          title: { ja_JP: '全ユーザー向け' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp({ admin: true });
+      const res = await request(app).get('/apiv3/news/list');
+      expect(res.status).toBe(200);
+      expect(res.body.docs).toHaveLength(2);
+    });
+  });
+
+  describe('POST /apiv3/news/mark-read', () => {
+    test('should mark an item as read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'test-001',
+        title: { ja_JP: 'テスト' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app, userId } = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ ok: true });
+
+      const status = await NewsReadStatus.findOne({
+        userId,
+        newsItemId: item._id,
+      });
+      expect(status).not.toBeNull();
+    });
+
+    test('should be idempotent (second call does not error)', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'test-002',
+        title: { ja_JP: 'テスト2' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+      const res2 = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      expect(res2.status).toBe(200);
+    });
+
+    test('should return 400 for invalid newsItemId', async () => {
+      const { app } = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: 'not-an-objectid' });
+      expect(res.status).toBe(400);
+    });
+  });
+
+  describe('GET /apiv3/news/list - sort order', () => {
+    test('should return items sorted by publishedAt descending', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'oldest',
+          title: { ja_JP: 'Oldest' },
+          publishedAt: new Date('2026-01-01'),
+          fetchedAt: now,
+        },
+        {
+          externalId: 'newest',
+          title: { ja_JP: 'Newest' },
+          publishedAt: new Date('2026-03-01'),
+          fetchedAt: now,
+        },
+        {
+          externalId: 'middle',
+          title: { ja_JP: 'Middle' },
+          publishedAt: new Date('2026-02-01'),
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp();
+      const res = await request(app).get('/apiv3/news/list');
+
+      expect(res.status).toBe(200);
+      const ids = res.body.docs.map(
+        (d: { externalId: string }) => d.externalId,
+      );
+      expect(ids).toEqual(['newest', 'middle', 'oldest']);
+    });
+  });
+
+  describe('markRead → listForUser cross-method consistency', () => {
+    test('should reflect isRead=true after mark-read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'cross-test',
+        title: { ja_JP: 'Cross test' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+
+      // Before mark-read: isRead should be false
+      const before = await request(app).get('/apiv3/news/list');
+      expect(before.body.docs[0].isRead).toBe(false);
+
+      // Mark as read
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      // After mark-read: isRead should be true
+      const after = await request(app).get('/apiv3/news/list');
+      expect(after.body.docs[0].isRead).toBe(true);
+    });
+
+    test('should decrease unread-count after mark-read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'count-test',
+        title: { ja_JP: 'Count test' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+
+      const before = await request(app).get('/apiv3/news/unread-count');
+      expect(before.body.count).toBe(1);
+
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      const after = await request(app).get('/apiv3/news/unread-count');
+      expect(after.body.count).toBe(0);
+    });
+  });
+
+  describe('GET /apiv3/news/unread-count', () => {
+    test('should return 0 after mark-all-read', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'n1',
+          title: { ja_JP: 'item 1' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+        {
+          externalId: 'n2',
+          title: { ja_JP: 'item 2' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp();
+      await request(app).post('/apiv3/news/mark-all-read');
+      const res = await request(app).get('/apiv3/news/unread-count');
+
+      expect(res.status).toBe(200);
+      expect(res.body.count).toBe(0);
+    });
+
+    test('should not count admin-only items for general user', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'admin-news',
+          title: { ja_JP: 'Admin only' },
+          publishedAt: now,
+          fetchedAt: now,
+          conditions: { targetRoles: ['admin'] },
+        },
+        {
+          externalId: 'general-news',
+          title: { ja_JP: 'General' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp({ admin: false });
+      const res = await request(app).get('/apiv3/news/unread-count');
+
+      expect(res.status).toBe(200);
+      // Contract: general user only sees 1 unread (not the admin-only item)
+      expect(res.body.count).toBe(1);
+    });
+  });
+});

+ 359 - 0
apps/app/src/features/news/server/routes/news.spec.ts

@@ -0,0 +1,359 @@
+import type { IUserHasId } from '@growi/core';
+import express from 'express';
+import mongoose from 'mongoose';
+import request from 'supertest';
+
+// Hoisted mocks
+const mocks = vi.hoisted(() => {
+  const listForUser = vi.fn();
+  const getUnreadCount = vi.fn();
+  const markRead = vi.fn();
+  const markAllRead = vi.fn();
+  const getConfig = vi.fn<(key: string) => unknown>();
+  const updateConfigs = vi.fn();
+  return {
+    NewsService: vi.fn(() => ({
+      listForUser,
+      getUnreadCount,
+      markRead,
+      markAllRead,
+    })),
+    listForUser,
+    getUnreadCount,
+    markRead,
+    markAllRead,
+    getConfig,
+    updateConfigs,
+  };
+});
+
+vi.mock('../services/news-service', () => ({
+  NewsService: mocks.NewsService,
+}));
+
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: mocks.getConfig,
+    updateConfigs: mocks.updateConfigs,
+  },
+}));
+
+// Middleware mocks - bypass auth
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser: () => (_req: unknown, _res: unknown, next: () => void) =>
+    next(),
+}));
+
+vi.mock('~/server/middlewares/login-required', () => ({
+  default:
+    () =>
+    (
+      req: express.Request & { user?: IUserHasId },
+      _res: unknown,
+      next: () => void,
+    ) => {
+      // Attach a mock user if not set
+      if (!req.user) {
+        req.user = {
+          _id: new mongoose.Types.ObjectId(),
+          admin: false,
+        } as unknown as IUserHasId;
+      }
+      next();
+    },
+}));
+
+import { createNewsRouter } from './news';
+
+const buildApp = (userOverride?: Partial<IUserHasId>) => {
+  const app = express();
+  app.use(express.json());
+  app.use((req: express.Request & { user?: IUserHasId }, _res, next) => {
+    req.user = {
+      _id: new mongoose.Types.ObjectId(),
+      admin: false,
+      ...userOverride,
+    } as unknown as IUserHasId;
+    next();
+  });
+  app.use('/apiv3/news', createNewsRouter());
+  return app;
+};
+
+describe('News API routes', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('GET /apiv3/news/list', () => {
+    test('should return news list with default params', async () => {
+      const mockResult = {
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      };
+      mocks.listForUser.mockResolvedValue(mockResult);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/list');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toMatchObject({ docs: [], totalDocs: 0 });
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        ['general'],
+        expect.objectContaining({ limit: 10, offset: 0 }),
+      );
+    });
+
+    test('should pass admin roles for admin user', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp({ admin: true });
+      await request(app).get('/apiv3/news/list');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        ['admin'],
+        expect.any(Object),
+      );
+    });
+
+    test('should silently cap limit at 100 when caller exceeds the upper bound', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 100,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?limit=99999');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ limit: 100 }),
+      );
+    });
+
+    test('should fall back to default limit when caller passes a non-numeric value', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?limit=abc');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ limit: 10 }),
+      );
+    });
+
+    test('should clamp limit up to 1 when caller passes a negative value', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 1,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?limit=-5');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ limit: 1 }),
+      );
+    });
+
+    test('should pass onlyUnread=true when query param is set', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?onlyUnread=true');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ onlyUnread: true }),
+      );
+    });
+  });
+
+  describe('GET /apiv3/news/unread-count', () => {
+    test('should return unread count', async () => {
+      mocks.getUnreadCount.mockResolvedValue(5);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/unread-count');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ count: 5 });
+    });
+  });
+
+  describe('POST /apiv3/news/mark-read', () => {
+    test('should mark a news item as read', async () => {
+      mocks.markRead.mockResolvedValue(undefined);
+
+      const newsItemId = new mongoose.Types.ObjectId().toString();
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ ok: true });
+      expect(mocks.markRead).toHaveBeenCalled();
+    });
+
+    test('should return 400 for invalid newsItemId', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: 'invalid-id' });
+
+      expect(res.status).toBe(400);
+      expect(mocks.markRead).not.toHaveBeenCalled();
+    });
+
+    test('should return 400 when newsItemId is missing', async () => {
+      const app = buildApp();
+      const res = await request(app).post('/apiv3/news/mark-read').send({});
+
+      expect(res.status).toBe(400);
+    });
+  });
+
+  describe('POST /apiv3/news/mark-all-read', () => {
+    test('should mark all news as read', async () => {
+      mocks.markAllRead.mockResolvedValue(undefined);
+
+      const app = buildApp();
+      const res = await request(app).post('/apiv3/news/mark-all-read');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ ok: true });
+      expect(mocks.markAllRead).toHaveBeenCalled();
+    });
+  });
+
+  describe('GET /apiv3/news/admin/delivery-setting', () => {
+    test('should return current value from configManager', async () => {
+      mocks.getConfig.mockReturnValue(true);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: true });
+      expect(mocks.getConfig).toHaveBeenCalledWith('news:isDeliveryEnabled');
+    });
+
+    test('should reflect false when delivery is disabled', async () => {
+      mocks.getConfig.mockReturnValue(false);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+    });
+  });
+
+  describe('POST /apiv3/news/admin/delivery-setting', () => {
+    test('should update delivery setting via configManager', async () => {
+      mocks.updateConfigs.mockResolvedValue(undefined);
+
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: false });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+      expect(mocks.updateConfigs).toHaveBeenCalledWith({
+        'news:isDeliveryEnabled': false,
+      });
+    });
+
+    test('should return 400 when flag is not boolean', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: 'true' });
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+
+    test('should return 400 when flag is missing', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({});
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+  });
+});

+ 246 - 0
apps/app/src/features/news/server/routes/news.ts

@@ -0,0 +1,246 @@
+import type { IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import express from 'express';
+import mongoose from 'mongoose';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { NewsService } from '../services/news-service';
+
+const logger = loggerFactory('growi:feature:news:routes');
+
+/**
+ * Maximum number of news items returnable per request.
+ * Caps caller-supplied `limit` so a misuse cannot make a single request
+ * pull an unbounded result set into memory.
+ */
+const MAX_LIST_LIMIT = 100;
+const DEFAULT_LIST_LIMIT = 10;
+
+type NewsRequest = CrowiRequest & { user: IUserHasId };
+
+/**
+ * Returns user roles based on admin flag
+ */
+const getUserRoles = (user: IUserHasId): string[] => {
+  return user.admin ? ['admin'] : ['general'];
+};
+
+/**
+ * Resolve the effective list limit from a query value.
+ * Falls back to `DEFAULT_LIST_LIMIT` for missing/invalid input,
+ * and silently caps the result to `[1, MAX_LIST_LIMIT]`.
+ */
+const resolveLimit = (raw: unknown): number => {
+  const requested =
+    raw != null
+      ? parseInt(String(raw), 10) || DEFAULT_LIST_LIMIT
+      : DEFAULT_LIST_LIMIT;
+  return Math.min(Math.max(requested, 1), MAX_LIST_LIMIT);
+};
+
+/**
+ * Creates and returns the news Express router.
+ * Accepts an optional Crowi instance for middleware setup.
+ */
+export const createNewsRouter = (crowi?: Crowi): express.Router => {
+  const router = express.Router();
+
+  // Use loginRequiredFactory when crowi is provided, otherwise use a pass-through middleware for testing
+  const loginRequiredStrictly =
+    crowi != null
+      ? loginRequiredFactory(crowi)
+      : (_req: unknown, _res: unknown, next: () => void) => next();
+  const adminRequired =
+    crowi != null
+      ? adminRequiredFactory(crowi)
+      : (_req: unknown, _res: unknown, next: () => void) => next();
+
+  /**
+   * GET /news/list
+   * Returns paginated news items filtered by user role
+   */
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const user = req.user;
+        const userRoles = getUserRoles(user);
+
+        const limit = resolveLimit(req.query.limit);
+        const offset =
+          req.query.offset != null
+            ? parseInt(String(req.query.offset), 10) || 0
+            : 0;
+        const onlyUnread = req.query.onlyUnread === 'true';
+
+        const service = new NewsService();
+        const result = await service.listForUser(user._id, userRoles, {
+          limit,
+          offset,
+          onlyUnread,
+        });
+
+        return res.json(result);
+      } catch (err) {
+        logger.error('GET /news/list failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * GET /news/unread-count
+   * Returns the unread news count for the current user
+   */
+  router.get(
+    '/unread-count',
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const user = req.user;
+        const userRoles = getUserRoles(user);
+
+        const service = new NewsService();
+        const count = await service.getUnreadCount(user._id, userRoles);
+
+        return res.json({ count });
+      } catch (err) {
+        logger.error('GET /news/unread-count failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/mark-read
+   * Marks a single news item as read for the current user
+   */
+  router.post(
+    '/mark-read',
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const { newsItemId } = req.body;
+
+        if (!newsItemId || !mongoose.isValidObjectId(newsItemId)) {
+          return res
+            .status(400)
+            .json({ error: 'Invalid or missing newsItemId' });
+        }
+
+        const user = req.user;
+        const service = new NewsService();
+        await service.markRead(
+          user._id,
+          new mongoose.Types.ObjectId(newsItemId),
+        );
+
+        return res.json({ ok: true });
+      } catch (err) {
+        logger.error('POST /news/mark-read failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/mark-all-read
+   * Marks all news items as read for the current user
+   */
+  router.post(
+    '/mark-all-read',
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const user = req.user;
+        const userRoles = getUserRoles(user);
+
+        const service = new NewsService();
+        await service.markAllRead(user._id, userRoles);
+
+        return res.json({ ok: true });
+      } catch (err) {
+        logger.error('POST /news/mark-all-read failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * GET /news/admin/delivery-setting
+   * Returns the current value of `news:isDeliveryEnabled` (admin only)
+   */
+  router.get(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.READ.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    (_req, res) => {
+      try {
+        const isDeliveryEnabled = configManager.getConfig(
+          'news:isDeliveryEnabled',
+        );
+        return res.json({ isDeliveryEnabled });
+      } catch (err) {
+        logger.error('GET /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/admin/delivery-setting
+   * Updates `news:isDeliveryEnabled` (admin only). Body: `{ flag: boolean }`.
+   * The new value is persisted to the `Config` collection and reflected on
+   * the next cron tick without a restart.
+   */
+  router.post(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const { flag } = req.body;
+        if (typeof flag !== 'boolean') {
+          return res.status(400).json({ error: '`flag` must be a boolean' });
+        }
+
+        await configManager.updateConfigs({ 'news:isDeliveryEnabled': flag });
+        return res.json({ isDeliveryEnabled: flag });
+      } catch (err) {
+        logger.error('POST /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  return router;
+};
+
+/**
+ * Default export for Express app registration (crowi factory pattern).
+ * Required by the apiv3 router loader which calls require(...).default(crowi).
+ */
+// biome-ignore lint/style/noDefaultExport: required by apiv3 router loader
+export default (crowi: Crowi): express.Router => createNewsRouter(crowi);

+ 65 - 0
apps/app/src/features/news/server/services/feed-parser.ts

@@ -0,0 +1,65 @@
+import { z } from 'zod';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:feature:news:feed-parser');
+
+const FeedItemSchema = z.object({
+  id: z.string().min(1),
+  type: z.string().optional(),
+  emoji: z.string().optional(),
+  title: z.record(z.string()),
+  body: z.record(z.string()).optional(),
+  url: z.string().optional(),
+  publishedAt: z.string().min(1),
+  conditions: z
+    .object({
+      targetRoles: z.array(z.string()).optional(),
+      growiVersionRegExps: z.array(z.string()).optional(),
+    })
+    .optional(),
+});
+
+const FeedJsonSchema = z.object({
+  version: z.string(),
+  // Items are parsed individually so a single bad item does not abort the batch
+  items: z.array(z.unknown()),
+});
+
+export type FeedItem = z.infer<typeof FeedItemSchema>;
+
+export interface FeedJson {
+  version: string;
+  items: FeedItem[];
+}
+
+/**
+ * Validate parsed JSON against the feed schema.
+ * Items failing per-item validation are skipped (logged), allowing the rest to be processed.
+ * Returns null when the top-level shape is invalid.
+ */
+export const parseFeedJson = (raw: unknown): FeedJson | null => {
+  const topResult = FeedJsonSchema.safeParse(raw);
+  if (!topResult.success) {
+    logger.error(
+      { issues: topResult.error.issues },
+      'News feed JSON top-level shape invalid',
+    );
+    return null;
+  }
+
+  const validItems: FeedItem[] = [];
+  for (const rawItem of topResult.data.items) {
+    const itemResult = FeedItemSchema.safeParse(rawItem);
+    if (itemResult.success) {
+      validItems.push(itemResult.data);
+    } else {
+      logger.warn(
+        { issues: itemResult.error.issues },
+        'News feed item failed validation, skipping',
+      );
+    }
+  }
+
+  return { version: topResult.data.version, items: validItems };
+};

+ 319 - 0
apps/app/src/features/news/server/services/news-cron-service.spec.ts

@@ -0,0 +1,319 @@
+// Hoisted mocks
+const mocks = vi.hoisted(() => {
+  const upsertNewsItems = vi.fn();
+  const deleteItemsNotInFeed = vi.fn();
+  const mockFetch = vi.fn();
+  const getGrowiVersion = vi.fn(() => '7.5.0');
+  // Default delivery to enabled so existing tests behave as before.
+  // Tests that need OFF state can override via mocks.getConfig.mockImplementationOnce.
+  const getConfig = vi.fn<(key: string) => unknown>((key: string) => {
+    if (key === 'news:isDeliveryEnabled') return true;
+    return undefined;
+  });
+
+  return {
+    NewsService: vi.fn(() => ({
+      upsertNewsItems,
+      deleteItemsNotInFeed,
+    })),
+    upsertNewsItems,
+    deleteItemsNotInFeed,
+    mockFetch,
+    getGrowiVersion,
+    getConfig,
+  };
+});
+
+vi.mock('../services/news-service', () => ({
+  NewsService: mocks.NewsService,
+}));
+
+vi.mock('~/utils/growi-version', () => ({
+  getGrowiVersion: mocks.getGrowiVersion,
+}));
+
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: mocks.getConfig,
+  },
+}));
+
+// Mock global fetch
+vi.stubGlobal('fetch', mocks.mockFetch);
+
+// Mock Math.random for deterministic sleep (zero sleep)
+vi.spyOn(Math, 'random').mockReturnValue(0);
+
+import { NewsCronService } from './news-cron-service';
+
+const VALID_FEED = {
+  version: '1.0',
+  items: [
+    {
+      id: 'item-001',
+      title: { ja_JP: 'テスト', en_US: 'Test' },
+      publishedAt: '2026-01-01T00:00:00Z',
+    },
+    {
+      id: 'item-002',
+      title: { ja_JP: '管理者向け' },
+      publishedAt: '2026-01-02T00:00:00Z',
+      conditions: { targetRoles: ['admin'] },
+    },
+  ],
+};
+
+/** Build a Response-like mock that exposes `text()` returning the JSON-stringified body. */
+const mockResponse = (
+  body: unknown,
+  init?: { ok?: boolean; status?: number },
+) => ({
+  ok: init?.ok ?? true,
+  status: init?.status ?? 200,
+  text: () => Promise.resolve(JSON.stringify(body)),
+});
+
+describe('NewsCronService', () => {
+  let service: NewsCronService;
+
+  beforeEach(() => {
+    service = new NewsCronService();
+    vi.clearAllMocks();
+    // Reset random mock
+    vi.spyOn(Math, 'random').mockReturnValue(0);
+  });
+
+  describe('getCronSchedule', () => {
+    test('should return daily schedule at midnight', () => {
+      expect(service.getCronSchedule()).toBe('0 0 * * *');
+    });
+  });
+
+  describe('executeJob', () => {
+    test('should skip when news:isDeliveryEnabled is false', async () => {
+      mocks.getConfig.mockImplementationOnce((key: string) =>
+        key === 'news:isDeliveryEnabled' ? false : undefined,
+      );
+
+      await service.executeJob();
+
+      // Delivery flag short-circuits before any network call or DB write
+      expect(mocks.mockFetch).not.toHaveBeenCalled();
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
+
+    test('should run when news:isDeliveryEnabled is true (default)', async () => {
+      mocks.mockFetch.mockResolvedValue(
+        mockResponse({ version: '1.0', items: [] }),
+      );
+
+      await service.executeJob();
+
+      expect(mocks.getConfig).toHaveBeenCalledWith('news:isDeliveryEnabled');
+      expect(mocks.mockFetch).toHaveBeenCalled();
+    });
+
+    test('should fetch from the hardcoded vendor URL', async () => {
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).toHaveBeenCalledWith(
+        'https://growilabs.github.io/growi-news-feed/feed.json',
+        expect.any(Object),
+      );
+    });
+
+    test('should upsert items on successful fetch', async () => {
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).toHaveBeenCalledWith(VALID_FEED.items);
+      expect(mocks.deleteItemsNotInFeed).toHaveBeenCalledWith([
+        'item-001',
+        'item-002',
+      ]);
+    });
+
+    test('should NOT update DB when fetch fails', async () => {
+      mocks.mockFetch.mockResolvedValue({ ok: false, status: 500 });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteItemsNotInFeed).not.toHaveBeenCalled();
+    });
+
+    test('should NOT update DB when fetch throws', async () => {
+      mocks.mockFetch.mockRejectedValue(new Error('Network error'));
+
+      await expect(service.executeJob()).resolves.not.toThrow();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
+
+    test('should filter items by growiVersionRegExps', async () => {
+      mocks.getGrowiVersion.mockReturnValue('7.5.0');
+      const feedWithVersionFilter = {
+        version: '1.0',
+        items: [
+          {
+            id: 'match-item',
+            title: { ja_JP: 'バージョン一致' },
+            publishedAt: '2026-01-01T00:00:00Z',
+            conditions: { growiVersionRegExps: ['^7\\.5\\..*'] },
+          },
+          {
+            id: 'no-match-item',
+            title: { ja_JP: 'バージョン不一致' },
+            publishedAt: '2026-01-01T00:00:00Z',
+            conditions: { growiVersionRegExps: ['^6\\..*'] },
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithVersionFilter));
+
+      await service.executeJob();
+
+      const upsertCall = mocks.upsertNewsItems.mock.calls[0][0];
+      expect(upsertCall).toHaveLength(1);
+      expect(upsertCall[0].id).toBe('match-item');
+    });
+
+    test('should skip items with invalid growiVersionRegExps', async () => {
+      mocks.getGrowiVersion.mockReturnValue('7.5.0');
+      const feedWithInvalidRegex = {
+        version: '1.0',
+        items: [
+          {
+            id: 'invalid-regex-item',
+            title: { ja_JP: '不正Regex' },
+            publishedAt: '2026-01-01T00:00:00Z',
+            conditions: { growiVersionRegExps: ['[invalid'] },
+          },
+          {
+            id: 'valid-item',
+            title: { ja_JP: '正常アイテム' },
+            publishedAt: '2026-01-01T00:00:00Z',
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithInvalidRegex));
+
+      await service.executeJob();
+
+      const upsertCall = mocks.upsertNewsItems.mock.calls[0][0];
+      // invalid-regex-item is skipped (treated as not matching), valid-item passes
+      expect(upsertCall.map((i: { id: string }) => i.id)).toContain(
+        'valid-item',
+      );
+      expect(upsertCall.map((i: { id: string }) => i.id)).not.toContain(
+        'invalid-regex-item',
+      );
+    });
+
+    // Regression for Requirement 1.3: items removed from the feed must be
+    // deleted from the local DB. Earlier code computed `idsToDelete` from
+    // `feedJson.items` only, so DB items absent from the feed were never
+    // cleaned up. The cron must now hand the full set of feed externalIds
+    // to `deleteItemsNotInFeed`, which uses a $nin filter to remove the rest.
+    test('should pass every feed externalId to deleteItemsNotInFeed (regression for stale-item bug)', async () => {
+      const feed = {
+        version: '1.0',
+        items: [
+          {
+            id: 'still-present-1',
+            title: { ja_JP: 'still present 1' },
+            publishedAt: '2026-01-01T00:00:00Z',
+          },
+          {
+            id: 'still-present-2',
+            title: { ja_JP: 'still present 2' },
+            publishedAt: '2026-01-02T00:00:00Z',
+          },
+          // Item present in feed but version-filtered out — must remain in
+          // the deletion safelist so it is not wiped from the DB.
+          {
+            id: 'version-filtered',
+            title: { ja_JP: 'version filtered' },
+            publishedAt: '2026-01-03T00:00:00Z',
+            conditions: { growiVersionRegExps: ['^999\\.'] },
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feed));
+
+      await service.executeJob();
+
+      // The argument is the *full* feed externalId list, not the
+      // version-matched subset. Items absent from this list (e.g. an
+      // earlier `removed-from-feed` item still in the DB) will be
+      // deleted by the service via `$nin`.
+      expect(mocks.deleteItemsNotInFeed).toHaveBeenCalledWith([
+        'still-present-1',
+        'still-present-2',
+        'version-filtered',
+      ]);
+    });
+
+    test('should skip when response body exceeds size limit (5 MiB)', async () => {
+      // Build a string that exceeds 5 MiB
+      const oversizedText = 'x'.repeat(5 * 1024 * 1024 + 1);
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        text: () => Promise.resolve(oversizedText),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteItemsNotInFeed).not.toHaveBeenCalled();
+    });
+
+    test('should abort when top-level shape is invalid', async () => {
+      // Missing `items` field — top-level schema check fails
+      mocks.mockFetch.mockResolvedValue(mockResponse({ version: '1.0' }));
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteItemsNotInFeed).not.toHaveBeenCalled();
+    });
+
+    test('should skip individual invalid items but keep valid ones', async () => {
+      const feedWithMixedItems = {
+        version: '1.0',
+        items: [
+          // Missing required fields (title, publishedAt) → skipped
+          { id: 'broken-item' },
+          // Valid item
+          {
+            id: 'good-item',
+            title: { ja_JP: '正常' },
+            publishedAt: '2026-01-01T00:00:00Z',
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithMixedItems));
+
+      await service.executeJob();
+
+      const upsertCall = mocks.upsertNewsItems.mock.calls[0][0];
+      expect(upsertCall.map((i: { id: string }) => i.id)).toEqual([
+        'good-item',
+      ]);
+    });
+
+    test('should skip when response body is not valid JSON', async () => {
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        text: () => Promise.resolve('not-a-json{'),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
+  });
+});

+ 140 - 0
apps/app/src/features/news/server/services/news-cron-service.ts

@@ -0,0 +1,140 @@
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import { getGrowiVersion } from '~/utils/growi-version';
+import loggerFactory from '~/utils/logger';
+
+import type { INewsItemInput } from '../../interfaces/news-item';
+import { type FeedItem, parseFeedJson } from './feed-parser';
+import { NewsService } from './news-service';
+
+const logger = loggerFactory('growi:feature:news:cron');
+
+/** Maximum random sleep in ms (5 hours) */
+const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;
+
+/** HTTP fetch timeout in ms */
+const FETCH_TIMEOUT_MS = 10_000;
+
+/**
+ * Maximum response body size (5 MiB).
+ * Sanity limit for the trust boundary at the news feed adapter — caps how much
+ * an external endpoint (broken or compromised) can push into our process memory.
+ */
+const MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024;
+
+/**
+ * Vendor-controlled news feed URL. Hardcoded so a fresh deployment delivers
+ * news without any infrastructure-side env injection. Users (incl. admins)
+ * cannot change this; opt-out is performed via the `news:isDeliveryEnabled`
+ * config flag managed in the admin UI.
+ */
+const FEED_URL = 'https://growilabs.github.io/growi-news-feed/feed.json';
+
+/**
+ * Check if the item matches the current GROWI version
+ * Returns true if no version conditions set.
+ * If a regex is invalid, the item is skipped (returns false).
+ */
+const matchesGrowiVersion = (
+  item: FeedItem,
+  currentVersion: string,
+): boolean => {
+  const regExps = item.conditions?.growiVersionRegExps;
+  if (!regExps || regExps.length === 0) return true;
+
+  return regExps.some((pattern) => {
+    try {
+      return new RegExp(pattern).test(currentVersion);
+    } catch {
+      logger.warn(`Invalid growiVersionRegExp pattern skipped: ${pattern}`);
+      return false;
+    }
+  });
+};
+
+/**
+ * Sleep for a random duration between 0 and maxMs
+ */
+const randomSleep = (maxMs: number): Promise<void> => {
+  const ms = Math.floor(Math.random() * maxMs);
+  return new Promise((resolve) => setTimeout(resolve, ms));
+};
+
+export class NewsCronService extends CronService {
+  override getCronSchedule(): string {
+    return '0 0 * * *';
+  }
+
+  override async executeJob(): Promise<void> {
+    // Read the delivery toggle (DB > defaultValue: true) on every tick so
+    // an admin's UI change takes effect from the next scheduled run, with no
+    // pod restart required (Requirements 9.5, 9.6).
+    if (!configManager.getConfig('news:isDeliveryEnabled')) {
+      logger.debug('News delivery is disabled, skipping news feed sync');
+      return;
+    }
+
+    // Random sleep to distribute requests across multiple GROWI instances
+    await randomSleep(MAX_RANDOM_SLEEP_MS);
+
+    let rawJson: unknown;
+    try {
+      const response = await fetch(FEED_URL, {
+        signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+      });
+
+      if (!response.ok) {
+        logger.error(`Failed to fetch news feed: HTTP ${response.status}`);
+        return;
+      }
+
+      const text = await response.text();
+      if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_SIZE_BYTES) {
+        logger.error(
+          `News feed response exceeds size limit (${MAX_RESPONSE_SIZE_BYTES} bytes), skipping`,
+        );
+        return;
+      }
+
+      rawJson = JSON.parse(text);
+    } catch (err) {
+      logger.error('Error fetching news feed, keeping existing data', err);
+      return;
+    }
+
+    const feedJson = parseFeedJson(rawJson);
+    if (feedJson == null) {
+      return;
+    }
+
+    const currentVersion = getGrowiVersion();
+    const filteredItems = feedJson.items.filter((item) =>
+      matchesGrowiVersion(item, currentVersion),
+    );
+
+    // Convert FeedItem to INewsItemInput (reuse id as externalId)
+    const newsItemInputs: INewsItemInput[] = filteredItems.map((item) => ({
+      id: item.id,
+      title: item.title,
+      body: item.body,
+      emoji: item.emoji,
+      url: item.url,
+      publishedAt: item.publishedAt,
+      conditions: item.conditions
+        ? {
+            targetRoles: item.conditions.targetRoles,
+          }
+        : undefined,
+    }));
+
+    // Pass the full set of feed externalIds so the service can delete any DB
+    // item that is no longer present in the feed (Requirement 1.3). Includes
+    // items filtered out by version match — those remain "in the feed" and
+    // are allowed to age out via the NewsItem TTL.
+    const feedExternalIds = feedJson.items.map((item) => item.id);
+
+    const service = new NewsService();
+    await service.upsertNewsItems(newsItemInputs);
+    await service.deleteItemsNotInFeed(feedExternalIds);
+  }
+}

+ 445 - 0
apps/app/src/features/news/server/services/news-service.spec.ts

@@ -0,0 +1,445 @@
+import mongoose from 'mongoose';
+
+// Use vi.hoisted so these variables are accessible inside vi.mock factory
+const mocks = vi.hoisted(() => {
+  const newsItemFind = vi.fn();
+  const newsItemBulkWrite = vi.fn();
+  const newsItemDeleteMany = vi.fn();
+  const newsItemCountDocuments = vi.fn();
+
+  const newsReadStatusDistinct = vi.fn();
+  const newsReadStatusUpdateOne = vi.fn();
+  const newsReadStatusInsertMany = vi.fn();
+
+  return {
+    NewsItem: {
+      find: newsItemFind,
+      bulkWrite: newsItemBulkWrite,
+      deleteMany: newsItemDeleteMany,
+      countDocuments: newsItemCountDocuments,
+    },
+    NewsReadStatus: {
+      distinct: newsReadStatusDistinct,
+      updateOne: newsReadStatusUpdateOne,
+      insertMany: newsReadStatusInsertMany,
+    },
+    newsItemFind,
+    newsItemBulkWrite,
+    newsItemDeleteMany,
+    newsItemCountDocuments,
+    newsReadStatusDistinct,
+    newsReadStatusUpdateOne,
+    newsReadStatusInsertMany,
+  };
+});
+
+vi.mock('../models/news-item', () => ({
+  NewsItem: mocks.NewsItem,
+}));
+
+vi.mock('../models/news-read-status', () => ({
+  NewsReadStatus: mocks.NewsReadStatus,
+}));
+
+import { NewsService } from './news-service';
+
+describe('NewsService', () => {
+  let service: NewsService;
+
+  beforeEach(() => {
+    service = new NewsService();
+    vi.clearAllMocks();
+  });
+
+  describe('listForUser', () => {
+    test('should return empty result when no news items', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0 },
+      );
+
+      expect(result.docs).toEqual([]);
+      expect(result.totalDocs).toBe(0);
+    });
+
+    test('should attach isRead=true for read items', async () => {
+      const newsId = new mongoose.Types.ObjectId();
+      const readNewsId = new mongoose.Types.ObjectId();
+
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: newsId,
+            externalId: 'n1',
+            title: { ja_JP: 'Test' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: readNewsId,
+            externalId: 'n2',
+            title: { ja_JP: 'Read' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(2);
+      mocks.newsReadStatusDistinct.mockResolvedValue([readNewsId]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0 },
+      );
+
+      expect(result.docs).toHaveLength(2);
+      const unread = result.docs.find((d) => d._id.equals(newsId));
+      const read = result.docs.find((d) => d._id.equals(readNewsId));
+      expect(unread?.isRead).toBe(false);
+      expect(read?.isRead).toBe(true);
+    });
+
+    test('should not include items with non-matching targetRoles in docs', async () => {
+      const generalItemId = new mongoose.Types.ObjectId();
+
+      // Mock returns both items (simulating DB returning role-filtered results)
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: generalItemId,
+            externalId: 'general-news',
+            title: { ja_JP: 'General' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(1);
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0 },
+      );
+
+      // Contract: only items matching user's role appear in docs
+      expect(result.docs).toHaveLength(1);
+      expect(result.docs.every((d) => d._id.equals(generalItemId))).toBe(true);
+    });
+
+    test('should exclude read items from docs when onlyUnread is true', async () => {
+      const unreadId = new mongoose.Types.ObjectId();
+      const readId = new mongoose.Types.ObjectId();
+
+      mocks.newsReadStatusDistinct.mockResolvedValue([readId]);
+      // When onlyUnread=true, DB query already excludes read items
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: unreadId,
+            externalId: 'unread-news',
+            title: { ja_JP: 'Unread' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(1);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0, onlyUnread: true },
+      );
+
+      // Contract: no read item appears in output
+      expect(result.docs).toHaveLength(1);
+      expect(result.docs[0].isRead).toBe(false);
+      expect(result.docs.some((d) => d._id.equals(readId))).toBe(false);
+    });
+
+    test('should return correct pagination metadata', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p1',
+            title: { ja_JP: 'P1' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p2',
+            title: { ja_JP: 'P2' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p3',
+            title: { ja_JP: 'P3' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p4',
+            title: { ja_JP: 'P4' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p5',
+            title: { ja_JP: 'P5' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(23);
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 5, offset: 10 },
+      );
+
+      // Contract: pagination fields are correct for offset=10, limit=5, total=23
+      expect(result.totalDocs).toBe(23);
+      expect(result.limit).toBe(5);
+      expect(result.page).toBe(3);
+      expect(result.totalPages).toBe(5);
+      expect(result.hasNextPage).toBe(true);
+      expect(result.hasPrevPage).toBe(true);
+    });
+  });
+
+  describe('markRead', () => {
+    test('should upsert a NewsReadStatus record', async () => {
+      mocks.newsReadStatusUpdateOne.mockResolvedValue({ upsertedCount: 1 });
+
+      const userId = new mongoose.Types.ObjectId();
+      const newsItemId = new mongoose.Types.ObjectId();
+      await service.markRead(userId, newsItemId);
+
+      expect(mocks.newsReadStatusUpdateOne).toHaveBeenCalledWith(
+        { userId, newsItemId },
+        expect.objectContaining({ $setOnInsert: expect.any(Object) }),
+        { upsert: true },
+      );
+    });
+
+    test('should be idempotent (no error on duplicate)', async () => {
+      mocks.newsReadStatusUpdateOne.mockResolvedValue({ upsertedCount: 0 });
+
+      const userId = new mongoose.Types.ObjectId();
+      const newsItemId = new mongoose.Types.ObjectId();
+      await expect(service.markRead(userId, newsItemId)).resolves.not.toThrow();
+      await expect(service.markRead(userId, newsItemId)).resolves.not.toThrow();
+    });
+  });
+
+  describe('markAllRead', () => {
+    test('should complete without error when news items exist', async () => {
+      const itemId = new mongoose.Types.ObjectId();
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi.fn().mockResolvedValue([{ _id: itemId }]),
+      });
+      mocks.newsReadStatusInsertMany.mockResolvedValue([]);
+
+      const userId = new mongoose.Types.ObjectId();
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+    });
+
+    test('should complete without error when no news items exist', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi.fn().mockResolvedValue([]),
+      });
+
+      const userId = new mongoose.Types.ObjectId();
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+
+      // Contract: no write operation when nothing to mark
+      expect(mocks.newsReadStatusInsertMany).not.toHaveBeenCalled();
+    });
+
+    test('should silently ignore duplicate key errors (already-read items)', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi
+          .fn()
+          .mockResolvedValue([{ _id: new mongoose.Types.ObjectId() }]),
+      });
+      const duplicateError = Object.assign(new Error('duplicate key'), {
+        code: 11000,
+      });
+      mocks.newsReadStatusInsertMany.mockRejectedValue(duplicateError);
+
+      const userId = new mongoose.Types.ObjectId();
+      // Contract: idempotent — calling twice doesn't throw
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+    });
+
+    test('should throw non-duplicate errors', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi
+          .fn()
+          .mockResolvedValue([{ _id: new mongoose.Types.ObjectId() }]),
+      });
+      const otherError = Object.assign(new Error('connection lost'), {
+        code: 12345,
+      });
+      mocks.newsReadStatusInsertMany.mockRejectedValue(otherError);
+
+      const userId = new mongoose.Types.ObjectId();
+      // Contract: real errors propagate to caller
+      await expect(service.markAllRead(userId, ['general'])).rejects.toThrow(
+        'connection lost',
+      );
+    });
+  });
+
+  describe('getUnreadCount', () => {
+    test('should return the number of unread items', async () => {
+      mocks.newsReadStatusDistinct.mockResolvedValue([
+        new mongoose.Types.ObjectId(),
+      ]);
+      mocks.newsItemCountDocuments.mockResolvedValue(2);
+
+      const userId = new mongoose.Types.ObjectId();
+      const count = await service.getUnreadCount(userId, ['general']);
+
+      // Contract: returns the unread count as a number
+      expect(count).toBe(2);
+    });
+
+    test('should return 0 when all items are read', async () => {
+      mocks.newsReadStatusDistinct.mockResolvedValue([
+        new mongoose.Types.ObjectId(),
+        new mongoose.Types.ObjectId(),
+      ]);
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+
+      const count = await service.getUnreadCount(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+      );
+      expect(count).toBe(0);
+    });
+
+    test('should return 0 when no news items exist', async () => {
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+
+      const count = await service.getUnreadCount(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+      );
+      expect(count).toBe(0);
+    });
+  });
+
+  describe('upsertNewsItems', () => {
+    test('should call bulkWrite with upsert for each item', async () => {
+      mocks.newsItemBulkWrite.mockResolvedValue({ upsertedCount: 1 });
+
+      await service.upsertNewsItems([
+        {
+          id: 'ext-001',
+          title: { ja_JP: 'Test' },
+          publishedAt: '2026-01-01T00:00:00Z',
+        },
+      ]);
+
+      expect(mocks.newsItemBulkWrite).toHaveBeenCalledTimes(1);
+      const [ops, opts] = mocks.newsItemBulkWrite.mock.calls[0];
+      expect(ops).toHaveLength(1);
+      expect(ops[0].updateOne.filter).toEqual({ externalId: 'ext-001' });
+      expect(ops[0].updateOne.update.$set.externalId).toBe('ext-001');
+      expect(ops[0].updateOne.upsert).toBe(true);
+      expect(opts).toEqual({ ordered: false });
+    });
+
+    test('should batch multiple items into a single bulkWrite call', async () => {
+      mocks.newsItemBulkWrite.mockResolvedValue({ upsertedCount: 2 });
+
+      await service.upsertNewsItems([
+        {
+          id: 'ext-001',
+          title: { ja_JP: 'Item 1' },
+          publishedAt: '2026-01-01T00:00:00Z',
+        },
+        {
+          id: 'ext-002',
+          title: { ja_JP: 'Item 2' },
+          publishedAt: '2026-01-02T00:00:00Z',
+        },
+      ]);
+
+      expect(mocks.newsItemBulkWrite).toHaveBeenCalledTimes(1);
+      const [ops] = mocks.newsItemBulkWrite.mock.calls[0];
+      expect(ops).toHaveLength(2);
+      expect(ops[0].updateOne.filter).toEqual({ externalId: 'ext-001' });
+      expect(ops[1].updateOne.filter).toEqual({ externalId: 'ext-002' });
+    });
+
+    test('should do nothing when items is empty', async () => {
+      await service.upsertNewsItems([]);
+      expect(mocks.newsItemBulkWrite).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('deleteItemsNotInFeed', () => {
+    test('should call deleteMany with $nin filter for items not in feed', async () => {
+      mocks.newsItemDeleteMany.mockResolvedValue({ deletedCount: 1 });
+
+      await service.deleteItemsNotInFeed(['ext-001', 'ext-002']);
+
+      expect(mocks.newsItemDeleteMany).toHaveBeenCalledWith({
+        externalId: { $nin: ['ext-001', 'ext-002'] },
+      });
+    });
+
+    test('should call deleteMany with $nin: [] when feed is empty (deletes all cached items)', async () => {
+      mocks.newsItemDeleteMany.mockResolvedValue({ deletedCount: 5 });
+
+      await service.deleteItemsNotInFeed([]);
+
+      expect(mocks.newsItemDeleteMany).toHaveBeenCalledWith({
+        externalId: { $nin: [] },
+      });
+    });
+  });
+});

+ 184 - 0
apps/app/src/features/news/server/services/news-service.ts

@@ -0,0 +1,184 @@
+import type { Types } from 'mongoose';
+
+import type { PaginateResult } from '~/interfaces/in-app-notification';
+import loggerFactory from '~/utils/logger';
+
+import type {
+  INewsItemInput,
+  INewsItemWithReadStatus,
+} from '../../interfaces/news-item';
+import { NewsItem } from '../models/news-item';
+import { NewsReadStatus } from '../models/news-read-status';
+
+const logger = loggerFactory('growi:feature:news:service');
+
+/**
+ * Build role filter query for NewsItem
+ */
+const buildRoleFilter = (userRoles: string[]) => ({
+  $or: [
+    { 'conditions.targetRoles': { $exists: false } },
+    { 'conditions.targetRoles': { $size: 0 } },
+    { 'conditions.targetRoles': { $in: userRoles } },
+  ],
+});
+
+export class NewsService {
+  /**
+   * List news items for a user with role filter and read status
+   */
+  async listForUser(
+    userId: Types.ObjectId,
+    userRoles: string[],
+    options: { limit: number; offset: number; onlyUnread?: boolean },
+  ): Promise<PaginateResult<INewsItemWithReadStatus>> {
+    const { limit, offset, onlyUnread = false } = options;
+
+    const roleFilter = buildRoleFilter(userRoles);
+
+    // Get read item IDs for this user
+    const readItemIds = await NewsReadStatus.distinct('newsItemId', { userId });
+
+    const query: Record<string, unknown> = { ...roleFilter };
+    if (onlyUnread) {
+      query._id = { $nin: readItemIds };
+    }
+
+    const [items, totalDocs] = await Promise.all([
+      NewsItem.find(query)
+        .sort({ publishedAt: -1 })
+        .skip(offset)
+        .limit(limit)
+        .lean(),
+      NewsItem.countDocuments(query),
+    ]);
+
+    const readIdSet = new Set(readItemIds.map((id) => id.toString()));
+
+    const docs: INewsItemWithReadStatus[] = items.map((item) => ({
+      ...item,
+      isRead: readIdSet.has(item._id.toString()),
+    }));
+
+    const totalPages = Math.ceil(totalDocs / limit) || 1;
+    const page = Math.floor(offset / limit) + 1;
+
+    return {
+      docs,
+      totalDocs,
+      limit,
+      offset,
+      page,
+      pagingCounter: offset + 1,
+      hasPrevPage: page > 1,
+      hasNextPage: page < totalPages,
+      prevPage: page > 1 ? page - 1 : null,
+      nextPage: page < totalPages ? page + 1 : null,
+      totalPages,
+    };
+  }
+
+  /**
+   * Get unread count for a user
+   */
+  async getUnreadCount(
+    userId: Types.ObjectId,
+    userRoles: string[],
+  ): Promise<number> {
+    const roleFilter = buildRoleFilter(userRoles);
+
+    const readItemIds = await NewsReadStatus.distinct('newsItemId', { userId });
+
+    return NewsItem.countDocuments({
+      ...roleFilter,
+      _id: { $nin: readItemIds },
+    });
+  }
+
+  /**
+   * Mark a single news item as read (idempotent)
+   */
+  async markRead(
+    userId: Types.ObjectId,
+    newsItemId: Types.ObjectId,
+  ): Promise<void> {
+    await NewsReadStatus.updateOne(
+      { userId, newsItemId },
+      { $setOnInsert: { userId, newsItemId, readAt: new Date() } },
+      { upsert: true },
+    );
+  }
+
+  /**
+   * Mark all news items as read for the user (filtered by role)
+   */
+  async markAllRead(
+    userId: Types.ObjectId,
+    userRoles: string[],
+  ): Promise<void> {
+    const roleFilter = buildRoleFilter(userRoles);
+    const items = await NewsItem.find(roleFilter).lean();
+
+    if (items.length === 0) return;
+
+    const now = new Date();
+    const statusDocs = items.map((item) => ({
+      userId,
+      newsItemId: item._id,
+      readAt: now,
+    }));
+
+    try {
+      await NewsReadStatus.insertMany(statusDocs, { ordered: false });
+    } catch (err: unknown) {
+      // Ignore duplicate key errors (already read items) — ordered: false continues on duplicates
+      if ((err as { code?: number }).code !== 11000) {
+        logger.error({ err }, 'markAllRead failed');
+        throw err;
+      }
+    }
+  }
+
+  /**
+   * Upsert news items from feed (keyed by externalId)
+   */
+  async upsertNewsItems(items: INewsItemInput[]): Promise<void> {
+    if (items.length === 0) return;
+
+    const now = new Date();
+
+    await NewsItem.bulkWrite(
+      items.map((item) => ({
+        updateOne: {
+          filter: { externalId: item.id },
+          update: {
+            $set: {
+              externalId: item.id,
+              title: item.title,
+              body: item.body,
+              emoji: item.emoji,
+              url: item.url,
+              publishedAt: new Date(item.publishedAt),
+              fetchedAt: now,
+              conditions: item.conditions,
+            },
+          },
+          upsert: true,
+        },
+      })),
+      { ordered: false },
+    );
+  }
+
+  /**
+   * Delete every cached news item whose externalId is NOT in the supplied set.
+   * Caller passes the full list of externalIds present in the latest feed; any DB
+   * item missing from that list is considered stale and removed (Requirement 1.3).
+   *
+   * Note: passing an empty array means "feed has no items" and will delete every
+   * cached news item. Callers must only invoke this after a successful feed fetch.
+   */
+  async deleteItemsNotInFeed(feedExternalIds: string[]): Promise<void> {
+    await NewsItem.deleteMany({ externalId: { $nin: feedExternalIds } });
+  }
+}

+ 0 - 123
apps/app/src/features/opentelemetry/docs/custom-metrics/architecture.md

@@ -1,123 +0,0 @@
-# OpenTelemetry Custom Metrics Architecture
-
-## 概要
-
-GROWIのOpenTelemetryカスタムメトリクスは、以下の3つのカテゴリに分類して実装されています:
-
-1. **Resource Attributes** - システム起動時に設定される静的情報
-2. **Config Metrics** - 設定変更により動的に変わる可能性があるメタデータ
-3. **Custom Metrics** - 時間と共に変化する業務メトリクス
-
-## アーキテクチャ
-
-### Resource Attributes
-
-静的なシステム情報をOpenTelemetryのResource Attributesとして設定します。Resource Attributesは2段階で設定されます:
-
-1. **起動時設定**: OS情報など、データベースアクセスが不要な静的情報
-2. **データベース初期化後設定**: アプリケーション情報など、データベースアクセスが必要な情報
-
-#### 実装場所
-```
-src/features/opentelemetry/server/custom-resource-attributes/
-├── os-resource-attributes.ts        # OS情報 (起動時設定)
-└── application-resource-attributes.ts  # アプリケーション固定情報 (DB初期化後設定)
-```
-
-#### OS情報 (`os-resource-attributes.ts`) - 起動時設定
-- `os.type` - OS種別 (Linux, Windows等)
-- `os.platform` - プラットフォーム (linux, darwin等)
-- `os.arch` - アーキテクチャ (x64, arm64等)
-- `os.totalmem` - 総メモリ量
-
-#### アプリケーション固定情報 (`application-resource-attributes.ts`) - DB初期化後設定
-- `growi.service.type` - サービスタイプ
-- `growi.deployment.type` - デプロイメントタイプ
-- `growi.attachment.type` - ファイルアップロードタイプ
-- `growi.installedAt` - インストール日時
-- `growi.installedAt.by_oldest_user` - 最古ユーザー作成日時
-
-### Config Metrics
-
-設定変更により動的に変わる可能性があるメタデータ実装します。値は常に1で、情報はラベルに格納されます。
-
-#### 実装場所
-```
-src/features/opentelemetry/server/custom-metrics/application-metrics.ts
-```
-
-#### 収集される情報
-- `service_instance_id` - サービスインスタンス識別子
-- `site_url` - サイトURL
-- `wiki_type` - Wiki種別 (open/closed)
-- `external_auth_types` - 有効な外部認証プロバイダー
-
-#### メトリクス例
-```
-growi_info{service_instance_id="abc123",site_url="https://wiki.example.com",wiki_type="open",external_auth_types="github,google"} 1
-```
-
-### Custom Metrics
-
-時間と共に変化する業務メトリクスを実装します。数値として監視・アラートの対象となるメトリクスです。
-
-#### 実装場所
-```
-src/features/opentelemetry/server/custom-metrics/
-├── application-metrics.ts  # Config Metrics (既存)
-└── user-counts-metrics.ts  # ユーザー数メトリクス (新規作成)
-```
-
-#### ユーザー数メトリクス (`user-counts-metrics.ts`)
-- `growi.users.total` - 総ユーザー数
-- `growi.users.active` - アクティブユーザー数
-
-## 収集間隔・設定タイミング
-
-### Resource Attributes
-- **OS情報**: アプリケーション起動時に1回のみ設定
-- **アプリケーション情報**: データベース初期化後に1回のみ設定
-
-### Metrics
-- **Config Metrics**: 60秒間隔で収集 (デフォルト)
-- **Custom Metrics**: 60秒間隔で収集 (デフォルト)
-
-### 2段階設定の理由
-
-Resource Attributesが2段階で設定される理由:
-
-1. **循環依存の回避**: アプリケーション情報の取得にはgrowiInfoServiceが必要だが、OpenTelemetry初期化時点では利用できない
-2. **データベース依存**: インストール日時やサービス設定などはデータベースから取得する必要がある
-3. **起動時間の最適化**: データベース接続を待たずにOpenTelemetryの基本機能を開始できる
-
-## 設定の変更
-
-メトリクス収集間隔は `PeriodicExportingMetricReader` の `exportIntervalMillis` で変更可能です:
-
-```typescript
-metricReader: new PeriodicExportingMetricReader({
-  exporter: new OTLPMetricExporter(),
-  exportIntervalMillis: 30000, // 30秒間隔
-}),
-```
-
-## 使用例
-
-### Prometheusでのクエリ例
-
-```promql
-# 総ユーザー数の推移
-growi_users_total
-
-# Wiki種別でグループ化した情報
-growi_info{wiki_type="open"}
-
-# 外部認証を使用しているインスタンス
-growi_info{external_auth_types!=""}
-```
-
-### Grafanaでの可視化例
-
-- ユーザー数の時系列グラフ
-- Wiki種別の分布円グラフ
-- 外部認証プロバイダーの利用状況

+ 0 - 87
apps/app/src/features/opentelemetry/docs/custom-metrics/implementation-guide.md

@@ -1,87 +0,0 @@
-# OpenTelemetry Custom Metrics Implementation Guide
-
-## 改修実装状況
-
-### ✅ 完了した実装
-
-#### 1. Resource Attributes
-- **OS情報**: `src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts`
-  - OS種別、プラットフォーム、アーキテクチャ、総メモリ量
-  - 起動時に設定
-- **アプリケーション固定情報**: `src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts`
-  - サービス・デプロイメントタイプ、添付ファイルタイプ、インストール情報
-  - データベース初期化後に設定
-
-#### 2. Config Metrics
-- **実装場所**: `src/features/opentelemetry/server/custom-metrics/application-metrics.ts`
-- **メトリクス**: `growi.configs` (値は常に1、情報はラベルに格納)
-- **収集情報**: サービスインスタンスID、サイトURL、Wiki種別、外部認証タイプ
-
-#### 3. Custom Metrics
-- **実装場所**: `src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts`
-- **メトリクス**: 
-  - `growi.users.total` - 総ユーザー数
-  - `growi.users.active` - アクティブユーザー数
-
-#### 4. 統合作業
-- **node-sdk-configuration.ts**: OS情報のResource Attributes統合済み
-- **node-sdk.ts**: データベース初期化後のアプリケーション情報設定統合済み
-- **メトリクス初期化**: Config MetricsとCustom Metricsの初期化統合済み
-
-### 📋 実装済みの統合
-
-#### Resource Attributesの2段階設定
-
-**1段階目 (起動時)**: `generateNodeSDKConfiguration`
-```typescript
-// OS情報のみでResourceを作成
-const osAttributes = getOsResourceAttributes();
-resource = resourceFromAttributes({
-  [ATTR_SERVICE_NAME]: 'growi',
-  [ATTR_SERVICE_VERSION]: version,
-  ...osAttributes,
-});
-```
-
-**2段階目 (DB初期化後)**: `setupAdditionalResourceAttributes`
-```typescript
-// アプリケーション情報とサービスインスタンスIDを追加
-const appAttributes = await getApplicationResourceAttributes();
-if (serviceInstanceId != null) {
-  appAttributes[ATTR_SERVICE_INSTANCE_ID] = serviceInstanceId;
-}
-const updatedResource = await generateAdditionalResourceAttributes(appAttributes);
-setResource(sdkInstance, updatedResource);
-```
-
-#### メトリクス収集の統合
-```typescript
-// generateNodeSDKConfiguration内で初期化
-addApplicationMetrics();
-addUserCountsMetrics();
-```
-
-## ファイル構成
-
-```
-src/features/opentelemetry/server/
-├── custom-resource-attributes/
-│   ├── index.ts                           # エクスポート用インデックス
-│   ├── os-resource-attributes.ts          # OS情報
-│   └── application-resource-attributes.ts # アプリケーション情報
-├── custom-metrics/
-│   ├── application-metrics.ts             # Config Metrics (更新済み)
-│   └── user-counts-metrics.ts             # ユーザー数メトリクス (新規)
-└── docs/
-    ├── custom-metrics-architecture.md     # アーキテクチャ文書
-    └── implementation-guide.md            # このファイル
-```
-
-## 設計のポイント
-
-1. **2段階Resource設定**: データベース依存の情報は初期化後に設定して循環依存を回避
-2. **循環依存の回避**: 動的importを使用してgrowiInfoServiceを読み込み
-3. **エラーハンドリング**: 各メトリクス収集でtry-catchを実装
-4. **型安全性**: Optional chainingを使用してundefinedを適切に処理
-5. **ログ出力**: デバッグ用のログを各段階で出力
-6. **起動時間の最適化**: データベース接続を待たずにOpenTelemetryの基本機能を開始

+ 0 - 49
apps/app/src/features/opentelemetry/docs/overview.md

@@ -1,49 +0,0 @@
-# OpenTelemetry Overview
-
-## 現在の実装状況
-
-### 基本機能
-- ✅ **Trace収集**: HTTP、Database等の自動インストルメンテーション
-- ✅ **Metrics収集**: 基本的なアプリケーションメトリクス
-- ✅ **OTLP Export**: gRPCでのデータ送信
-- ✅ **設定管理**: 環境変数による有効/無効制御
-
-### アーキテクチャ
-```
-[GROWI App] → [NodeSDK] → [Auto Instrumentations] → [OTLP Exporter] → [Collector]
-```
-
-### 実装ファイル
-| ファイル | 責務 |
-|---------|------|
-| `node-sdk.ts` | SDK初期化・管理 |
-| `node-sdk-configuration.ts` | 設定生成 |
-| `node-sdk-resource.ts` | リソース属性管理 |
-| `logger.ts` | 診断ログ |
-
-### 設定項目
-| 環境変数 | デフォルト | 説明 |
-|---------|-----------|------|
-| `OTEL_ENABLED` | `false` | 有効/無効 |
-| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | エクスポート先 |
-| `OTEL_SERVICE_NAME` | `growi` | サービス名 |
-| `OTEL_SERVICE_VERSION` | 自動 | バージョン |
-
-### データフロー
-1. **Auto Instrumentation** でHTTP/DB操作を自動計測
-2. **NodeSDK** がスパン・メトリクスを収集
-3. **OTLP Exporter** が外部Collectorに送信
-
-## 制限事項
-- 機密データの匿名化未実装
-- GROWIアプリ固有の情報未送信
-
-## 参考情報
-- [OpenTelemetry Node.js SDK](https://open-telemetry.github.io/opentelemetry-js/)
-- [Custom Metrics Documentation](https://opentelemetry.io/docs/instrumentation/js/manual/#creating-metrics)
-- [HTTP Instrumentation Configuration](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http#configuration)
-- [Semantic Conventions for System Metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/system/system-metrics.md)
-- [Resource Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/README.md)
-
----
-*更新日: 2025-06-19*

+ 6 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts

@@ -74,6 +74,7 @@ describe('addApplicationMetrics', () => {
       wikiType: 'open',
       additionalInfo: {
         activeExternalAccountTypes: ['google', 'github'],
+        attachmentType: 'aws',
       },
     };
 
@@ -102,6 +103,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: undefined,
         wiki_type: 'open',
         external_auth_types: 'google,github',
+        attachment_type: 'aws',
       });
     });
 
@@ -131,6 +133,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: expectedHash,
         wiki_type: 'open',
         external_auth_types: 'google,github',
+        attachment_type: 'aws',
       });
     });
 
@@ -145,6 +148,7 @@ describe('addApplicationMetrics', () => {
         ...mockGrowiInfo,
         additionalInfo: {
           activeExternalAccountTypes: [],
+          attachmentType: 'aws',
         },
       };
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAuth);
@@ -159,6 +163,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: undefined,
         wiki_type: 'open',
         external_auth_types: '',
+        attachment_type: 'aws',
       });
     });
 
@@ -212,6 +217,7 @@ describe('addApplicationMetrics', () => {
         site_url_hashed: undefined,
         wiki_type: 'open',
         external_auth_types: '',
+        attachment_type: '',
       });
     });
   });

+ 1 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts

@@ -55,6 +55,7 @@ export function addApplicationMetrics(): void {
           external_auth_types:
             growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') ||
             '',
+          attachment_type: growiInfo.additionalInfo?.attachmentType ?? '',
         });
       } catch (error) {
         loggerDiag.error('Failed to collect application config metrics', {

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

@@ -1,14 +1,28 @@
 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';
 export { addUserCountsMetrics } from './user-counts-metrics';
+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');
+  const { addYjsMetrics } = await import('./yjs-metrics');
+  const { addMongooseConnectionPoolMetrics } = await import(
+    './mongoose-connection-pool-metrics'
+  );
 
   // Add custom metrics
   addApplicationMetrics();
+  addInstalledAtMetrics();
   addUserCountsMetrics();
   addPageCountsMetrics();
+  addSystemMetrics();
+  addYjsMetrics();
+  addMongooseConnectionPoolMetrics();
 };

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

+ 277 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/mongoose-connection-pool-metrics.spec.ts

@@ -0,0 +1,277 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import {
+  addMongooseConnectionPoolMetrics,
+  getPoolStats,
+} from './mongoose-connection-pool-metrics';
+
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+    warn: vi.fn(),
+  }),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: vi.fn(() => ({ error: vi.fn() })),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+const { mockGetClient } = vi.hoisted(() => ({
+  mockGetClient: vi.fn(),
+}));
+vi.mock('mongoose', () => ({
+  default: {
+    connection: { getClient: mockGetClient },
+  },
+}));
+
+// ---- helpers ----
+
+function makePool(
+  total: number,
+  checkedOut: number,
+  available: number,
+): {
+  totalConnectionCount: number;
+  currentCheckedOutCount: number;
+  availableConnectionCount: number;
+} {
+  return {
+    totalConnectionCount: total,
+    currentCheckedOutCount: checkedOut,
+    availableConnectionCount: available,
+  };
+}
+
+function makeClient(
+  servers: Map<string, { s?: { pool?: ReturnType<typeof makePool> } }>,
+) {
+  return {
+    topology: {
+      s: { servers },
+    },
+  };
+}
+
+// ---- getPoolStats unit tests ----
+
+describe('getPoolStats', () => {
+  it('returns zeros when client has no topology', () => {
+    expect(getPoolStats({})).toEqual({ total: 0, checkedOut: 0, available: 0 });
+  });
+
+  it('returns zeros when topology.s is missing', () => {
+    expect(getPoolStats({ topology: {} })).toEqual({
+      total: 0,
+      checkedOut: 0,
+      available: 0,
+    });
+  });
+
+  it('returns zeros when servers map is empty', () => {
+    const client = makeClient(new Map());
+    expect(getPoolStats(client)).toEqual({
+      total: 0,
+      checkedOut: 0,
+      available: 0,
+    });
+  });
+
+  it('returns pool stats for a single server', () => {
+    const pool = makePool(5, 2, 3);
+    const client = makeClient(new Map([['localhost:27017', { s: { pool } }]]));
+    expect(getPoolStats(client)).toEqual({
+      total: 5,
+      checkedOut: 2,
+      available: 3,
+    });
+  });
+
+  it('sums stats across multiple servers', () => {
+    const pool1 = makePool(3, 1, 2);
+    const pool2 = makePool(4, 2, 2);
+    const client = makeClient(
+      new Map([
+        ['host1:27017', { s: { pool: pool1 } }],
+        ['host2:27017', { s: { pool: pool2 } }],
+      ]),
+    );
+    expect(getPoolStats(client)).toEqual({
+      total: 7,
+      checkedOut: 3,
+      available: 4,
+    });
+  });
+
+  it('skips servers with no pool', () => {
+    const pool = makePool(2, 1, 1);
+    const client = makeClient(
+      new Map([
+        ['host1:27017', { s: { pool } }],
+        ['host2:27017', {}],
+      ]),
+    );
+    expect(getPoolStats(client)).toEqual({
+      total: 2,
+      checkedOut: 1,
+      available: 1,
+    });
+  });
+
+  it('treats undefined pool fields as 0', () => {
+    const client = makeClient(
+      new Map([
+        ['localhost:27017', { s: { pool: {} as ReturnType<typeof makePool> } }],
+      ]),
+    );
+    expect(getPoolStats(client)).toEqual({
+      total: 0,
+      checkedOut: 0,
+      available: 0,
+    });
+  });
+
+  it('returns zeros and does not throw when an error is thrown internally', () => {
+    const badClient = {
+      get topology(): never {
+        throw new Error('unexpected');
+      },
+    };
+    expect(() => getPoolStats(badClient)).not.toThrow();
+    expect(getPoolStats(badClient)).toEqual({
+      total: 0,
+      checkedOut: 0,
+      available: 0,
+    });
+  });
+});
+
+// ---- addMongooseConnectionPoolMetrics unit tests ----
+
+describe('addMongooseConnectionPoolMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockPoolSizeGauge = mock<ObservableGauge>();
+  const mockCheckedOutGauge = mock<ObservableGauge>();
+  const mockAvailableGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge
+      .mockReturnValueOnce(mockPoolSizeGauge)
+      .mockReturnValueOnce(mockCheckedOutGauge)
+      .mockReturnValueOnce(mockAvailableGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('returns early without registering meters when getClient() returns null', () => {
+    mockGetClient.mockReturnValue(null);
+    addMongooseConnectionPoolMetrics();
+    expect(metrics.getMeter).not.toHaveBeenCalled();
+  });
+
+  it('creates meter with correct name and version', () => {
+    mockGetClient.mockReturnValue({ topology: { s: { servers: new Map() } } });
+    addMongooseConnectionPoolMetrics();
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-mongoose-metrics',
+      '1.0.0',
+    );
+  });
+
+  it('creates three ObservableGauges with the correct names', () => {
+    mockGetClient.mockReturnValue({ topology: { s: { servers: new Map() } } });
+    addMongooseConnectionPoolMetrics();
+
+    const names = mockMeter.createObservableGauge.mock.calls.map(
+      ([name]) => name,
+    );
+    expect(names).toEqual([
+      'growi.mongoose.pool.size',
+      'growi.mongoose.pool.checked_out',
+      'growi.mongoose.pool.available',
+    ]);
+  });
+
+  it('creates all gauges with unit {connection}', () => {
+    mockGetClient.mockReturnValue({ topology: { s: { servers: new Map() } } });
+    addMongooseConnectionPoolMetrics();
+
+    for (const [, options] of mockMeter.createObservableGauge.mock.calls) {
+      expect(options).toMatchObject({ unit: '{connection}' });
+    }
+  });
+
+  it('registers a batch callback covering all three gauges', () => {
+    mockGetClient.mockReturnValue({ topology: { s: { servers: new Map() } } });
+    addMongooseConnectionPoolMetrics();
+
+    expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledTimes(1);
+    const [, gauges] = mockMeter.addBatchObservableCallback.mock.calls[0];
+    expect(gauges).toContain(mockPoolSizeGauge);
+    expect(gauges).toContain(mockCheckedOutGauge);
+    expect(gauges).toContain(mockAvailableGauge);
+  });
+
+  it('observes correct pool stats in the callback', async () => {
+    const pool = makePool(10, 3, 7);
+    const client = makeClient(new Map([['localhost:27017', { s: { pool } }]]));
+    mockGetClient.mockReturnValue(client);
+
+    addMongooseConnectionPoolMetrics();
+
+    const mockResult = { observe: vi.fn() };
+    const [callback] = mockMeter.addBatchObservableCallback.mock.calls[0];
+    await callback(mockResult);
+
+    expect(mockResult.observe).toHaveBeenCalledWith(mockPoolSizeGauge, 10);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockCheckedOutGauge, 3);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockAvailableGauge, 7);
+  });
+
+  it('observes zeros when the topology has no servers', async () => {
+    mockGetClient.mockReturnValue(makeClient(new Map()));
+    addMongooseConnectionPoolMetrics();
+
+    const mockResult = { observe: vi.fn() };
+    const [callback] = mockMeter.addBatchObservableCallback.mock.calls[0];
+    await callback(mockResult);
+
+    expect(mockResult.observe).toHaveBeenCalledWith(mockPoolSizeGauge, 0);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockCheckedOutGauge, 0);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockAvailableGauge, 0);
+  });
+
+  it('reflects updated pool stats across multiple callback invocations', async () => {
+    const pool = makePool(2, 1, 1);
+    const servers = new Map([['localhost:27017', { s: { pool } }]]);
+    mockGetClient.mockReturnValue(makeClient(servers));
+
+    addMongooseConnectionPoolMetrics();
+
+    const mockResult = { observe: vi.fn() };
+    const [callback] = mockMeter.addBatchObservableCallback.mock.calls[0];
+
+    await callback(mockResult);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockPoolSizeGauge, 2);
+
+    // Simulate pool growth
+    pool.totalConnectionCount = 8;
+    pool.currentCheckedOutCount = 5;
+    pool.availableConnectionCount = 3;
+
+    await callback(mockResult);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockPoolSizeGauge, 8);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockCheckedOutGauge, 5);
+    expect(mockResult.observe).toHaveBeenCalledWith(mockAvailableGauge, 3);
+  });
+});

+ 119 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/mongoose-connection-pool-metrics.ts

@@ -0,0 +1,119 @@
+import { diag, metrics } from '@opentelemetry/api';
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-metrics:mongoose-connection-pool',
+);
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:mongoose-connection-pool',
+});
+
+// Internal pool shape accessed via topology internals (mongodb driver 4.x).
+// Wrapped in try/catch so metrics degrade gracefully if the driver changes.
+type ServerPool = {
+  totalConnectionCount?: number;
+  currentCheckedOutCount?: number;
+  availableConnectionCount?: number;
+};
+
+type ServerInternal = { s?: { pool?: ServerPool } };
+
+type TopologyInternal = { s?: { servers?: Map<string, ServerInternal> } };
+
+export type PoolStats = {
+  total: number;
+  checkedOut: number;
+  available: number;
+};
+
+/**
+ * Reads current connection pool stats from the mongodb driver topology.
+ * Sums across all servers (typically one in a standalone/replica-set primary scenario).
+ * Returns zeros if the topology internals are not accessible.
+ */
+export function getPoolStats(client: {
+  topology?: TopologyInternal;
+}): PoolStats {
+  try {
+    const servers = client.topology?.s?.servers;
+    if (!servers) return { total: 0, checkedOut: 0, available: 0 };
+
+    let total = 0;
+    let checkedOut = 0;
+    let available = 0;
+
+    for (const server of servers.values()) {
+      const pool = server?.s?.pool;
+      if (pool) {
+        total += pool.totalConnectionCount ?? 0;
+        checkedOut += pool.currentCheckedOutCount ?? 0;
+        available += pool.availableConnectionCount ?? 0;
+      }
+    }
+
+    return { total, checkedOut, available };
+  } catch {
+    return { total: 0, checkedOut: 0, available: 0 };
+  }
+}
+
+export function addMongooseConnectionPoolMetrics(): void {
+  logger.info('Starting mongoose connection pool metrics collection');
+
+  const client = mongoose.connection.getClient();
+  if (client == null) {
+    logger.warn(
+      'Mongoose client not available; skipping connection pool metrics',
+    );
+    return;
+  }
+
+  const meter = metrics.getMeter('growi-mongoose-metrics', '1.0.0');
+
+  const poolSizeGauge = meter.createObservableGauge(
+    'growi.mongoose.pool.size',
+    {
+      description:
+        'Total number of connections in the MongoDB connection pool (available + pending + checked out)',
+      unit: '{connection}',
+    },
+  );
+  const checkedOutGauge = meter.createObservableGauge(
+    'growi.mongoose.pool.checked_out',
+    {
+      description:
+        'Number of MongoDB connections currently checked out (in use)',
+      unit: '{connection}',
+    },
+  );
+  const availableGauge = meter.createObservableGauge(
+    'growi.mongoose.pool.available',
+    {
+      description:
+        'Number of MongoDB connections currently available in the pool',
+      unit: '{connection}',
+    },
+  );
+
+  meter.addBatchObservableCallback(
+    (result) => {
+      try {
+        const stats = getPoolStats(client as { topology?: TopologyInternal });
+        result.observe(poolSizeGauge, stats.total);
+        result.observe(checkedOutGauge, stats.checkedOut);
+        result.observe(availableGauge, stats.available);
+      } catch (error) {
+        loggerDiag.error('Failed to collect mongoose connection pool metrics', {
+          error,
+        });
+      }
+    },
+    [poolSizeGauge, checkedOutGauge, availableGauge],
+  );
+
+  logger.info(
+    'Mongoose connection pool metrics collection started successfully',
+  );
+}

+ 373 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/system-metrics.spec.ts

@@ -0,0 +1,373 @@
+import * as os from 'node:os';
+import * as v8 from 'node:v8';
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addSystemMetrics } from './system-metrics';
+
+// vi.hoisted ensures the factory runs before vi.mock factories (which are also hoisted).
+// This is needed because diag.createComponentLogger() is called at module-load time in
+// system-metrics.ts, so the mock must already hold the reference when the module is imported.
+const { diagErrorMock } = vi.hoisted(() => ({
+  diagErrorMock: { error: vi.fn() },
+}));
+
+// Mock external dependencies
+vi.mock('node:os', () => ({
+  totalmem: vi.fn(),
+}));
+vi.mock('node:v8', () => ({
+  getHeapStatistics: vi.fn(),
+}));
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => diagErrorMock,
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+describe('addSystemMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockGauges: ObservableGauge[] = Array.from({ length: 6 }, () =>
+    mock<ObservableGauge>(),
+  );
+
+  // Assign individual gauges for assertion clarity
+  let mockMemoryLimitGauge: ObservableGauge;
+  let mockHostMemoryTotalGauge: ObservableGauge;
+  let mockProcessMemoryUsageGauge: ObservableGauge;
+  let mockV8HeapUsedGauge: ObservableGauge;
+  let mockV8HeapTotalGauge: ObservableGauge;
+  let mockV8HeapExternalGauge: ObservableGauge;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    diagErrorMock.error.mockReset();
+
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+
+    // Return different gauge mocks for each createObservableGauge call
+    let callCount = 0;
+    mockMeter.createObservableGauge.mockImplementation(
+      () => mockGauges[callCount++],
+    );
+
+    [
+      mockMemoryLimitGauge,
+      mockHostMemoryTotalGauge,
+      mockProcessMemoryUsageGauge,
+      mockV8HeapUsedGauge,
+      mockV8HeapTotalGauge,
+      mockV8HeapExternalGauge,
+    ] = mockGauges;
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  describe('meter and gauge setup', () => {
+    it('should create meter with correct name and version', () => {
+      addSystemMetrics();
+
+      expect(metrics.getMeter).toHaveBeenCalledWith(
+        'growi-system-metrics',
+        '1.0.0',
+      );
+      expect(metrics.getMeter).toHaveBeenCalledTimes(1);
+    });
+
+    it('should create 6 ObservableGauges all with unit By', () => {
+      addSystemMetrics();
+
+      expect(mockMeter.createObservableGauge).toHaveBeenCalledTimes(6);
+
+      const calls = mockMeter.createObservableGauge.mock.calls;
+      const names = calls.map(([name]) => name);
+      expect(names).toContain('system.memory.limit');
+      expect(names).toContain('system.host.memory.total');
+      expect(names).toContain('process.memory.usage');
+      expect(names).toContain('process.runtime.v8.heap.used');
+      expect(names).toContain('process.runtime.v8.heap.total');
+      expect(names).toContain('process.runtime.v8.heap.external');
+
+      // All gauges must use unit 'By'
+      for (const [, options] of calls) {
+        expect(options).toMatchObject({ unit: 'By' });
+      }
+    });
+
+    it('should register a single addBatchObservableCallback with all 6 gauges', () => {
+      addSystemMetrics();
+
+      expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledTimes(1);
+
+      const [, gaugeArray] = mockMeter.addBatchObservableCallback.mock.calls[0];
+      expect(gaugeArray).toHaveLength(6);
+      expect(gaugeArray).toContain(mockMemoryLimitGauge);
+      expect(gaugeArray).toContain(mockHostMemoryTotalGauge);
+      expect(gaugeArray).toContain(mockProcessMemoryUsageGauge);
+      expect(gaugeArray).toContain(mockV8HeapUsedGauge);
+      expect(gaugeArray).toContain(mockV8HeapTotalGauge);
+      expect(gaugeArray).toContain(mockV8HeapExternalGauge);
+    });
+  });
+
+  describe('callback behavior — constrainedMemory > 0', () => {
+    it('should observe system.memory.limit when constrainedMemory returns a positive value (Req 3.1)', async () => {
+      const constrainedMemoryValue = 4_294_967_296; // 4 GiB
+      vi.spyOn(process, 'constrainedMemory').mockReturnValue(
+        constrainedMemoryValue,
+      );
+      vi.spyOn(process, 'memoryUsage').mockReturnValue({
+        rss: 100_000_000,
+        heapUsed: 50_000_000,
+        heapTotal: 80_000_000,
+        external: 5_000_000,
+        arrayBuffers: 1_000_000,
+      });
+      vi.mocked(os.totalmem).mockReturnValue(8_589_934_592);
+      vi.mocked(v8.getHeapStatistics).mockReturnValue({
+        total_heap_size: 80_000_000,
+        total_heap_size_executable: 0,
+        total_physical_size: 0,
+        total_available_size: 0,
+        used_heap_size: 50_000_000,
+        heap_size_limit: 0,
+        malloced_memory: 0,
+        peak_malloced_memory: 0,
+        does_zap_garbage: 0,
+        number_of_native_contexts: 0,
+        number_of_detached_contexts: 0,
+        total_global_handles_size: 0,
+        used_global_handles_size: 0,
+        external_memory: 0,
+      });
+
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockMemoryLimitGauge,
+        constrainedMemoryValue,
+      );
+    });
+  });
+
+  describe('callback behavior — constrainedMemory === 0', () => {
+    it('should NOT observe system.memory.limit when constrainedMemory returns 0 (Req 3.2)', async () => {
+      vi.spyOn(process, 'constrainedMemory').mockReturnValue(0);
+      vi.spyOn(process, 'memoryUsage').mockReturnValue({
+        rss: 100_000_000,
+        heapUsed: 50_000_000,
+        heapTotal: 80_000_000,
+        external: 5_000_000,
+        arrayBuffers: 1_000_000,
+      });
+      vi.mocked(os.totalmem).mockReturnValue(8_589_934_592);
+      vi.mocked(v8.getHeapStatistics).mockReturnValue({
+        total_heap_size: 80_000_000,
+        total_heap_size_executable: 0,
+        total_physical_size: 0,
+        total_available_size: 0,
+        used_heap_size: 50_000_000,
+        heap_size_limit: 0,
+        malloced_memory: 0,
+        peak_malloced_memory: 0,
+        does_zap_garbage: 0,
+        number_of_native_contexts: 0,
+        number_of_detached_contexts: 0,
+        total_global_handles_size: 0,
+        used_global_handles_size: 0,
+        external_memory: 0,
+      });
+
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      // system.memory.limit must NOT be observed
+      expect(mockResult.observe).not.toHaveBeenCalledWith(
+        mockMemoryLimitGauge,
+        expect.anything(),
+      );
+
+      // All other 5 gauges must still be observed
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockHostMemoryTotalGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockProcessMemoryUsageGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapUsedGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapTotalGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapExternalGauge,
+        expect.any(Number),
+      );
+      expect(mockResult.observe).toHaveBeenCalledTimes(5);
+    });
+  });
+
+  describe('callback behavior — metric values', () => {
+    beforeEach(() => {
+      vi.spyOn(process, 'constrainedMemory').mockReturnValue(0);
+      vi.spyOn(process, 'memoryUsage').mockReturnValue({
+        rss: 111_222_333,
+        heapUsed: 44_455_566,
+        heapTotal: 77_888_999,
+        external: 12_345_678,
+        arrayBuffers: 1_000_000,
+      });
+      vi.mocked(os.totalmem).mockReturnValue(16_000_000_000);
+      vi.mocked(v8.getHeapStatistics).mockReturnValue({
+        total_heap_size: 77_888_999,
+        total_heap_size_executable: 0,
+        total_physical_size: 0,
+        total_available_size: 0,
+        used_heap_size: 44_455_566,
+        heap_size_limit: 0,
+        malloced_memory: 0,
+        peak_malloced_memory: 0,
+        does_zap_garbage: 0,
+        number_of_native_contexts: 0,
+        number_of_detached_contexts: 0,
+        total_global_handles_size: 0,
+        used_global_handles_size: 0,
+        external_memory: 0,
+      });
+    });
+
+    it('should observe system.host.memory.total from os.totalmem() (Req 3.3)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockHostMemoryTotalGauge,
+        16_000_000_000,
+      );
+    });
+
+    it('should observe process.memory.usage from process.memoryUsage().rss (Req 4.1)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockProcessMemoryUsageGauge,
+        111_222_333,
+      );
+    });
+
+    it('should observe v8.heap.used from v8.getHeapStatistics().used_heap_size (Req 4.2)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapUsedGauge,
+        44_455_566,
+      );
+    });
+
+    it('should observe v8.heap.total from v8.getHeapStatistics().total_heap_size (Req 4.3)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapTotalGauge,
+        77_888_999,
+      );
+    });
+
+    it('should observe v8.heap.external from process.memoryUsage().external (Req 4.4)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockV8HeapExternalGauge,
+        12_345_678,
+      );
+    });
+
+    it('should call process.memoryUsage() exactly once per callback invocation (efficiency)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(process.memoryUsage).toHaveBeenCalledTimes(1);
+    });
+
+    it('should call v8.getHeapStatistics() exactly once per callback invocation (efficiency)', async () => {
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(v8.getHeapStatistics).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('error handling', () => {
+    it('should call loggerDiag.error and not call observe when an error occurs in callback (Req 5.2)', async () => {
+      const testError = new Error('Simulated metric collection failure');
+      vi.spyOn(process, 'constrainedMemory').mockImplementation(() => {
+        throw testError;
+      });
+
+      addSystemMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // Should not throw
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+
+      // loggerDiag.error must be called with the error
+      expect(diagErrorMock.error).toHaveBeenCalledWith(
+        'Failed to collect system metrics',
+        { error: testError },
+      );
+
+      // observe must never be called
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 93 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/system-metrics.ts

@@ -0,0 +1,93 @@
+import * as os from 'node:os';
+import * as v8 from 'node:v8';
+import { diag, metrics } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-metrics:system');
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:system',
+});
+
+export function addSystemMetrics(): void {
+  logger.info('Starting system metrics collection');
+
+  const meter = metrics.getMeter('growi-system-metrics', '1.0.0');
+
+  const memoryLimitGauge = meter.createObservableGauge('system.memory.limit', {
+    description: 'Container or OS-imposed memory limit for this process',
+    unit: 'By',
+  });
+  const hostMemoryTotalGauge = meter.createObservableGauge(
+    'system.host.memory.total',
+    {
+      description: 'Total physical memory available on the host',
+      unit: 'By',
+    },
+  );
+  const processMemoryUsageGauge = meter.createObservableGauge(
+    'process.memory.usage',
+    {
+      description: 'Resident Set Size — physical memory in use by this process',
+      unit: 'By',
+    },
+  );
+  const v8HeapUsedGauge = meter.createObservableGauge(
+    'process.runtime.v8.heap.used',
+    {
+      description: 'V8 heap memory currently in use',
+      unit: 'By',
+    },
+  );
+  const v8HeapTotalGauge = meter.createObservableGauge(
+    'process.runtime.v8.heap.total',
+    {
+      description: 'Total V8 heap memory allocated',
+      unit: 'By',
+    },
+  );
+  const v8HeapExternalGauge = meter.createObservableGauge(
+    'process.runtime.v8.heap.external',
+    {
+      description: 'External memory referenced by V8 objects (e.g. Buffers)',
+      unit: 'By',
+    },
+  );
+
+  meter.addBatchObservableCallback(
+    async (result) => {
+      try {
+        // process.constrainedMemory() is available in Node.js >=19.6.0.
+        // On older versions it may not exist; guard with a falsy check.
+        const constrainedMemory =
+          (
+            process as NodeJS.Process & { constrainedMemory?(): number }
+          ).constrainedMemory?.() ?? 0;
+        // Call each system API exactly once per collection cycle.
+        const memUsage = process.memoryUsage();
+        const heapStats = v8.getHeapStatistics();
+
+        if (constrainedMemory) {
+          result.observe(memoryLimitGauge, constrainedMemory);
+        }
+        result.observe(hostMemoryTotalGauge, os.totalmem());
+        result.observe(processMemoryUsageGauge, memUsage.rss);
+        result.observe(v8HeapUsedGauge, heapStats.used_heap_size);
+        result.observe(v8HeapTotalGauge, heapStats.total_heap_size);
+        result.observe(v8HeapExternalGauge, memUsage.external);
+      } catch (error) {
+        loggerDiag.error('Failed to collect system metrics', { error });
+      }
+    },
+    [
+      memoryLimitGauge,
+      hostMemoryTotalGauge,
+      processMemoryUsageGauge,
+      v8HeapUsedGauge,
+      v8HeapTotalGauge,
+      v8HeapExternalGauge,
+    ],
+  );
+
+  logger.info('System metrics collection started successfully');
+}

+ 179 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/yjs-metrics.spec.ts

@@ -0,0 +1,179 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addYjsMetrics, getDocsCount } from './yjs-metrics';
+
+// Mock external dependencies
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+
+const { mockDiagError } = vi.hoisted(() => ({
+  mockDiagError: vi.fn(),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: vi.fn(() => ({ error: mockDiagError })),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+// Controlled docs Map mock
+const mockDocs = new Map<string, unknown>();
+vi.mock('y-websocket/bin/utils', () => ({
+  get docs() {
+    return mockDocs;
+  },
+}));
+
+describe('addYjsMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockDocs.clear();
+    mockDiagError.mockReset();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge.mockReturnValue(mockGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  describe('meter and gauge setup', () => {
+    it('should create meter with correct name and version', () => {
+      addYjsMetrics();
+
+      expect(metrics.getMeter).toHaveBeenCalledWith(
+        'growi-yjs-metrics',
+        '1.0.0',
+      );
+      expect(metrics.getMeter).toHaveBeenCalledTimes(1);
+    });
+
+    it('should create ObservableGauge with name growi.yjs.docs.count (Req 4.1)', () => {
+      addYjsMetrics();
+
+      expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+        'growi.yjs.docs.count',
+        expect.objectContaining({ unit: '{document}' }),
+      );
+    });
+
+    it('should create ObservableGauge with unit {document} (Req 4.2)', () => {
+      addYjsMetrics();
+
+      const [, options] = mockMeter.createObservableGauge.mock.calls[0];
+      expect(options).toMatchObject({ unit: '{document}' });
+    });
+
+    it('should create ObservableGauge with an appropriate description', () => {
+      addYjsMetrics();
+
+      const [name, options] = mockMeter.createObservableGauge.mock.calls[0];
+      expect(name).toBe('growi.yjs.docs.count');
+      expect(options?.description).toBeTruthy();
+      expect(typeof options?.description).toBe('string');
+    });
+
+    it('should register a callback via addBatchObservableCallback (Req 4.2)', () => {
+      addYjsMetrics();
+
+      expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledTimes(1);
+      const [, gaugeArray] = mockMeter.addBatchObservableCallback.mock.calls[0];
+      expect(gaugeArray).toContain(mockGauge);
+    });
+  });
+
+  describe('callback behavior — docs.size reflects current count', () => {
+    it('should observe 0 when docs is empty (Req 4.1)', async () => {
+      mockDocs.clear(); // size === 0
+
+      addYjsMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 0);
+    });
+
+    it('should observe docs.size when docs has N entries (Req 4.1)', async () => {
+      mockDocs.set('doc-1', {});
+      mockDocs.set('doc-2', {});
+      mockDocs.set('doc-3', {});
+
+      addYjsMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 3);
+    });
+
+    it('should reflect updated docs.size across multiple callback invocations', async () => {
+      addYjsMetrics();
+
+      const mockResult = { observe: vi.fn() };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // First invocation — no docs
+      await callback(mockResult);
+      expect(mockResult.observe).toHaveBeenLastCalledWith(mockGauge, 0);
+
+      // Add docs and invoke again
+      mockDocs.set('doc-a', {});
+      mockDocs.set('doc-b', {});
+      await callback(mockResult);
+      expect(mockResult.observe).toHaveBeenLastCalledWith(mockGauge, 2);
+    });
+
+    it('does not propagate errors and logs via diag when observation throws', async () => {
+      addYjsMetrics();
+
+      const throwingResult = {
+        observe: vi.fn().mockImplementation(() => {
+          throw new Error('otel observe failed');
+        }),
+      };
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      await expect(async () => callback(throwingResult)).not.toThrow();
+      expect(mockDiagError).toHaveBeenCalledWith(
+        expect.stringContaining('yjs'),
+        expect.objectContaining({ error: expect.any(Error) }),
+      );
+    });
+  });
+
+  describe('getDocsCount — defensive helper', () => {
+    it('should return 0 when docs is undefined (Req design: defensive check)', () => {
+      expect(getDocsCount(undefined)).toBe(0);
+    });
+
+    it('should return 0 when docs is null (Req design: defensive check)', () => {
+      expect(getDocsCount(null)).toBe(0);
+    });
+
+    it('should return 0 when docs is an empty map', () => {
+      expect(getDocsCount(new Map())).toBe(0);
+    });
+
+    it('should return the map size when docs has entries', () => {
+      const m = new Map<string, unknown>([
+        ['a', {}],
+        ['b', {}],
+        ['c', {}],
+      ]);
+      expect(getDocsCount(m)).toBe(3);
+    });
+  });
+});

+ 47 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/yjs-metrics.ts

@@ -0,0 +1,47 @@
+import { diag, metrics } from '@opentelemetry/api';
+import { docs } from 'y-websocket/bin/utils';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-metrics:yjs');
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:yjs',
+});
+
+/**
+ * Returns the number of documents in the given map.
+ * Returns 0 when the map is undefined or null (y-websocket not yet initialised).
+ */
+export function getDocsCount(
+  d: ReadonlyMap<string, unknown> | undefined | null,
+): number {
+  return d?.size ?? 0;
+}
+
+export function addYjsMetrics(): void {
+  logger.info('Starting yjs metrics collection');
+
+  const meter = metrics.getMeter('growi-yjs-metrics', '1.0.0');
+
+  const yjsDocsCountGauge = meter.createObservableGauge(
+    'growi.yjs.docs.count',
+    {
+      description:
+        'Current number of collaborative documents held by y-websocket',
+      unit: '{document}',
+    },
+  );
+
+  meter.addBatchObservableCallback(
+    (result) => {
+      try {
+        result.observe(yjsDocsCountGauge, getDocsCount(docs));
+      } catch (error) {
+        loggerDiag.error('Failed to collect yjs metrics', { error });
+      }
+    },
+    [yjsDocsCountGauge],
+  );
+
+  logger.info('Yjs metrics collection started successfully');
+}

+ 8 - 40
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts

@@ -21,15 +21,10 @@ describe('getApplicationResourceAttributes', () => {
     vi.clearAllMocks();
   });
 
-  it('should return complete application resource attributes when growi info is available', async () => {
+  it('should return only service and deployment type attributes when growi info is available', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
-      additionalInfo: {
-        attachmentType: 'local',
-        installedAt: new Date('2023-01-01T00:00:00.000Z'),
-        installedAtByOldestUser: new Date('2023-01-01T00:00:00.000Z'),
-      },
     };
 
     mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
@@ -39,32 +34,28 @@ describe('getApplicationResourceAttributes', () => {
     expect(result).toEqual({
       'growi.service.type': 'app',
       'growi.deployment.type': 'standalone',
-      'growi.attachment.type': 'local',
-      'growi.installedAt': '2023-01-01T00:00:00.000Z',
-      'growi.installedAt.by_oldest_user': '2023-01-01T00:00:00.000Z',
-    });
-    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
-      includeInstalledInfo: true,
     });
+    expect(result).not.toHaveProperty('growi.attachment.type');
+    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({});
   });
 
-  it('should handle missing additionalInfo gracefully', async () => {
+  it('should not include growi.attachment.type even when additionalInfo is present', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
-      additionalInfo: undefined,
+      additionalInfo: {
+        attachmentType: 'local',
+      },
     };
 
     mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
 
     const result = await getApplicationResourceAttributes();
 
+    expect(result).not.toHaveProperty('growi.attachment.type');
     expect(result).toEqual({
       'growi.service.type': 'app',
       'growi.deployment.type': 'standalone',
-      'growi.attachment.type': undefined,
-      'growi.installedAt': undefined,
-      'growi.installedAt.by_oldest_user': undefined,
     });
   });
 
@@ -77,27 +68,4 @@ describe('getApplicationResourceAttributes', () => {
 
     expect(result).toEqual({});
   });
-
-  it('should handle partial additionalInfo data', async () => {
-    const mockGrowiInfo = {
-      type: 'app',
-      deploymentType: 'docker',
-      additionalInfo: {
-        attachmentType: 'gridfs',
-        // Missing installedAt and installedAtByOldestUser
-      },
-    };
-
-    mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
-
-    const result = await getApplicationResourceAttributes();
-
-    expect(result).toEqual({
-      'growi.service.type': 'app',
-      'growi.deployment.type': 'docker',
-      'growi.attachment.type': 'gridfs',
-      'growi.installedAt': undefined,
-      'growi.installedAt.by_oldest_user': undefined,
-    });
-  });
 });

+ 1 - 9
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts

@@ -17,20 +17,12 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
     // Dynamic import to avoid circular dependencies
     const { growiInfoService } = await import('~/server/service/growi-info');
 
-    const growiInfo = await growiInfoService.getGrowiInfo({
-      includeInstalledInfo: true,
-    });
+    const growiInfo = await growiInfoService.getGrowiInfo({});
 
     const attributes: Attributes = {
       // Service configuration (rarely changes after system setup)
       'growi.service.type': growiInfo.type,
       'growi.deployment.type': growiInfo.deploymentType,
-      'growi.attachment.type': growiInfo.additionalInfo?.attachmentType,
-
-      // Installation information (fixed values)
-      'growi.installedAt': growiInfo.additionalInfo?.installedAt?.toISOString(),
-      'growi.installedAt.by_oldest_user':
-        growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
     };
 
     logger.info({ attributes }, 'Application resource attributes collected');

+ 2 - 12
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts

@@ -5,7 +5,6 @@ vi.mock('node:os', () => ({
   type: vi.fn(),
   platform: vi.fn(),
   arch: vi.fn(),
-  totalmem: vi.fn(),
 }));
 
 describe('getOsResourceAttributes', () => {
@@ -13,7 +12,6 @@ describe('getOsResourceAttributes', () => {
     type: ReturnType<typeof vi.fn>;
     platform: ReturnType<typeof vi.fn>;
     arch: ReturnType<typeof vi.fn>;
-    totalmem: ReturnType<typeof vi.fn>;
   };
 
   beforeEach(async () => {
@@ -28,13 +26,11 @@ describe('getOsResourceAttributes', () => {
       type: 'Linux',
       platform: 'linux' as const,
       arch: 'x64',
-      totalmem: 16777216000,
     };
 
     mockOs.type.mockReturnValue(mockOsData.type);
     mockOs.platform.mockReturnValue(mockOsData.platform);
     mockOs.arch.mockReturnValue(mockOsData.arch);
-    mockOs.totalmem.mockReturnValue(mockOsData.totalmem);
 
     const result = getOsResourceAttributes();
 
@@ -42,8 +38,8 @@ describe('getOsResourceAttributes', () => {
       'os.type': 'Linux',
       'os.platform': 'linux',
       'os.arch': 'x64',
-      'os.totalmem': 16777216000,
     });
+    expect(result).not.toHaveProperty('os.totalmem');
   });
 
   it('should call all required os module functions', () => {
@@ -51,14 +47,12 @@ describe('getOsResourceAttributes', () => {
     mockOs.type.mockReturnValue('Linux');
     mockOs.platform.mockReturnValue('linux');
     mockOs.arch.mockReturnValue('x64');
-    mockOs.totalmem.mockReturnValue(16777216000);
 
     getOsResourceAttributes();
 
     expect(mockOs.type).toHaveBeenCalledOnce();
     expect(mockOs.platform).toHaveBeenCalledOnce();
     expect(mockOs.arch).toHaveBeenCalledOnce();
-    expect(mockOs.totalmem).toHaveBeenCalledOnce();
   });
 
   it('should handle different OS types correctly', () => {
@@ -68,13 +62,11 @@ describe('getOsResourceAttributes', () => {
           type: 'Windows_NT',
           platform: 'win32',
           arch: 'x64',
-          totalmem: 8589934592,
         },
         expected: {
           'os.type': 'Windows_NT',
           'os.platform': 'win32',
           'os.arch': 'x64',
-          'os.totalmem': 8589934592,
         },
       },
       {
@@ -82,13 +74,11 @@ describe('getOsResourceAttributes', () => {
           type: 'Darwin',
           platform: 'darwin',
           arch: 'arm64',
-          totalmem: 17179869184,
         },
         expected: {
           'os.type': 'Darwin',
           'os.platform': 'darwin',
           'os.arch': 'arm64',
-          'os.totalmem': 17179869184,
         },
       },
     ];
@@ -97,10 +87,10 @@ describe('getOsResourceAttributes', () => {
       mockOs.type.mockReturnValue(input.type);
       mockOs.platform.mockReturnValue(input.platform as NodeJS.Platform);
       mockOs.arch.mockReturnValue(input.arch);
-      mockOs.totalmem.mockReturnValue(input.totalmem);
 
       const result = getOsResourceAttributes();
       expect(result).toEqual(expected);
+      expect(result).not.toHaveProperty('os.totalmem');
     });
   });
 });

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