Shun Miyazawa 6 дней назад
Родитель
Сommit
b5f362c444

+ 5 - 1
.claude/settings.json

@@ -27,7 +27,11 @@
       "mcp__plugin_context7_*",
       "mcp__github__*",
       "WebSearch",
-      "WebFetch"
+      "WebFetch",
+      "Skill(kiro:spec-impl)",
+      "Skill(kiro:spec-impl:*)",
+      "Skill(kiro:spec-init)",
+      "Skill(kiro:spec-init:*)"
     ]
   },
   "enableAllProjectMcpServers": true,

+ 469 - 0
.kiro/specs/sharelink-page-api/design.md

@@ -0,0 +1,469 @@
+# Technical Design: sharelink-page-api
+
+## Overview
+
+本機能は、share link 経由のページ取得を専用の API エンドポイント `GET /_api/v3/page/shared` として分離する。現行の `GET /_api/v3/page` ルートは、通常認証アクセスと share link アクセスの両方をミドルウェアフラグ(`req.isSharedPage`)と条件分岐で処理しており、責務が混在している。専用エンドポイントを設けることで、各ルートの責務を明確化し、コードの可読性と保守性を向上させる。
+
+既存のサービス関数(`findPageAndMetaDataByViewer`、`respondWithSinglePage`)を共有ユーティリティとして抽出・再利用することで、ロジックの重複を排除する。さらに、現行実装で未対応だった `security:disableLinkSharing` 設定のチェックをページ取得 API レイヤーに追加し、セキュリティギャップを解消する。
+
+### Goals
+
+- share link アクセス専用の `GET /_api/v3/page/shared` エンドポイントを実装する
+- 認証ミドルウェア(`accessTokenParser`、`loginRequired`、`certifySharedPage`)を使用せず、公開エンドポイントとして設計する
+- `findPageAndMetaDataByViewer` および `respondWithSinglePage` を共有ユーティリティとして再利用し、コード重複を排除する
+- `security:disableLinkSharing` 設定を新エンドポイントで正しく適用する
+- 既存の `GET /page` ルートから share link 関連のコードを除去してシンプル化する
+
+### Non-Goals
+
+- `GET /_api/v3/page/info`(`get-page-info.ts`)の share link 対応リファクタリング(別スコープ)
+- `certifySharedPage` ミドルウェアの削除(`get-page-info.ts` が引き続き使用するため)
+- share link の特定リビジョン指定(`revisionId`)対応(初期スコープ外)
+- SSR share ページ(`/share/[[...path]]/page-data-props.ts`)の変更
+
+---
+
+## Architecture
+
+### Existing Architecture Analysis
+
+現行の `GET /page` ルートのミドルウェアチェーン:
+
+```
+accessTokenParser → certifySharedPage → loginRequired → validator.getPage → handler
+```
+
+ハンドラー内の条件分岐:
+1. `req.isSharedPage === true` → ShareLink 二次検索 + `findPageAndMetaDataByViewer({ isSharedPage: true })`
+2. `findAll` → `Page.findByPathAndViewer`
+3. デフォルト → `findPageAndMetaDataByViewer`
+
+**既存の設計課題**:
+- `certifySharedPage` は `disableLinkSharing` を未チェック(既存バグ)
+- ShareLink に対して 2 回の DB クエリを実行(ミドルウェアとハンドラーで各 1 回)
+- `certifySharedPage` は期限切れ・無効リンクでもエラーを返さずサイレントにパス
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph ClientLayer[Client Layer]
+        FetchHook[useFetchCurrentPage]
+    end
+
+    subgraph PageRouter[Page Router - page slash]
+        NewRoute[GET slash shared - NEW]
+        OldRoute[GET slash - existing cleanup]
+        RespondUtil[respondWithSinglePage - extracted]
+    end
+
+    subgraph ServiceLayer[Service Layer]
+        ValidateSL[validateShareLink - NEW]
+        FPAMDBV[findPageAndMetaDataByViewer - existing]
+    end
+
+    subgraph DataLayer[Data Layer]
+        ShareLinkModel[ShareLink Model]
+        PageModel[Page Model]
+        ConfigMgr[configManager]
+    end
+
+    FetchHook -->|shareLinkId present| NewRoute
+    FetchHook -->|shareLinkId absent| OldRoute
+    NewRoute --> ConfigMgr
+    NewRoute --> ValidateSL
+    NewRoute --> FPAMDBV
+    NewRoute --> RespondUtil
+    OldRoute --> RespondUtil
+    OldRoute --> FPAMDBV
+    ValidateSL --> ShareLinkModel
+    FPAMDBV --> PageModel
+```
+
+**Architecture Integration**:
+- Selected pattern: Handler Factory(`getPageInfoHandlerFactory` と同一パターン)
+- Domain boundary: share link バリデーションは `server/service/share-link/` に分離
+- Existing patterns preserved: `RequestHandler[]` 返却型、`apiV3FormValidator`、`res.apiv3()`
+- New components: `validateShareLink` サービス、`respondWithSinglePage` ユーティリティ(抽出)、`getPageByShareLinkHandlerFactory` ハンドラー
+- Steering compliance: サーバー・クライアント境界の維持、純粋関数の抽出、命名規約(camelCase)
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Backend | Express.js (既存) | ルート登録・ミドルウェアチェーン | 新規依存なし |
+| Data | MongoDB / Mongoose ^6.13.6 (既存) | ShareLink・Page ドキュメント取得 | ShareLink.findOne 一本化 |
+| Type Safety | TypeScript (既存) | 全インターフェース定義 | `any` 使用禁止 |
+| Validation | express-validator (既存) | `shareLinkId`・`pageId` 入力バリデーション | 既存バリデーター再利用 |
+
+---
+
+## System Flows
+
+### 新エンドポイント リクエストフロー
+
+```mermaid
+sequenceDiagram
+    participant Client
+    participant Handler as GET slash page slash shared
+    participant ValidateSL as validateShareLink
+    participant ShareLink as ShareLink Model
+    participant FPAMDBV as findPageAndMetaDataByViewer
+    participant Page as Page Model
+
+    Client->>Handler: shareLinkId pageId
+    Handler->>Handler: validate params MongoId
+    Handler->>Handler: check disableLinkSharing config
+    alt disableLinkSharing = true
+        Handler-->>Client: 403 link-sharing-disabled
+    end
+    Handler->>ValidateSL: shareLinkId pageId
+    ValidateSL->>ShareLink: findOne id relatedPage
+    alt not found or relatedPage mismatch
+        ValidateSL-->>Handler: not-found error
+        Handler-->>Client: 404 share-link-not-found
+    else isExpired = true
+        ValidateSL-->>Handler: expired error
+        Handler-->>Client: 403 share-link-expired
+    end
+    ValidateSL-->>Handler: ShareLinkDocument
+    Handler->>FPAMDBV: pageId isSharedPage=true
+    FPAMDBV->>Page: findOne _id
+    FPAMDBV-->>Handler: pageWithMeta
+    Handler->>Handler: respondWithSinglePage
+    Handler-->>Client: 200 page meta
+```
+
+フロー上の重要決定事項:
+- `disableLinkSharing` チェックはバリデーション前のファーストゲートとして配置し、DB アクセスを不要にする
+- `validateShareLink` は `findOne({ _id, relatedPage })` の単一クエリで存在確認と page 照合を同時実施(旧実装の二重クエリを解消)
+- share link が有効である場合のみ `findPageAndMetaDataByViewer` を呼び出し、`isSharedPage: true` で page grant チェックをスキップする
+
+---
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1 | 専用エンドポイントの提供 | `getPageByShareLinkHandlerFactory` | `GET /page/shared` API Contract | リクエストフロー全体 |
+| 1.2 | 認証ミドルウェアからの独立 | `getPageByShareLinkHandlerFactory` | ミドルウェアなし | ハンドラー直接実行 |
+| 1.3 | `shareLinkId` と `pageId` 必須パラメータ | `getPageByShareLinkHandlerFactory` | validator 定義 | params バリデーション |
+| 1.4 | 既存と同一レスポンス構造 | `respondWithSinglePage` | `{ page, meta }` | respondWithSinglePage |
+| 2.1–2.2 | ShareLink 存在確認・relatedPage 照合 | `validateShareLink` | Service Interface | validateShareLink フロー |
+| 2.3 | 不一致時 404 | `validateShareLink`, Handler | エラーレスポンス | not-found ブランチ |
+| 2.4 | 期限切れ時 403 | `validateShareLink`, Handler | エラーレスポンス | expired ブランチ |
+| 2.5 | `disableLinkSharing` 時 403 | Handler | エラーレスポンス | config チェック |
+| 3.1 | 最新リビジョン付きページ返却 | `findPageAndMetaDataByViewer`, `respondWithSinglePage` | `IDataWithMeta` | FPAMDBV + respond |
+| 3.2–3.3 | isMovable 等 false・bookmarkCount 0 | `findPageAndMetaDataByViewer` | `IPageInfoExt.isSharedPage` | FPAMDBV 内の分岐 |
+| 3.4 | ページ未存在時 404 | `respondWithSinglePage` | `IPageNotFoundInfo` | not-found メタ |
+| 4.1–4.3 | 認証不要 | `getPageByShareLinkHandlerFactory` | ミドルウェアチェーン | 認証ミドルウェアなし |
+| 5.1 | `findPageAndMetaDataByViewer` 再利用 | `getPageByShareLinkHandlerFactory` | 既存関数呼び出し | FPAMDBV 呼び出し |
+| 5.2 | バリデーション共通化 | `validateShareLink` | 新サービス関数 | validateShareLink |
+| 5.3 | シリアライズロジック非複製 | `respondWithSinglePage` | 抽出ユーティリティ | respondWithSinglePage |
+| 5.4 | `pageId` バリデーター共有 | `getPageByShareLinkHandlerFactory` | express-validator | params バリデーション |
+
+---
+
+## Components and Interfaces
+
+### コンポーネント一覧
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
+|-----------|--------------|--------|--------------|------------------|-----------|
+| `validateShareLink` | Service | ShareLink の DB バリデーション | 2.1, 2.2, 2.3, 2.4 | ShareLink Model (P0) | Service |
+| `respondWithSinglePage` | Route Utility | ページデータ → API レスポンス変換 | 1.4, 3.1, 3.2, 3.3, 3.4 | ApiV3Response (P0) | Service |
+| `getPageByShareLinkHandlerFactory` | Route Handler | `GET /page/shared` エンドポイント実装 | 全要件 | validateShareLink (P0), findPageAndMetaDataByViewer (P0), respondWithSinglePage (P0) | API |
+| `useFetchCurrentPage` update | Client State | 新エンドポイントへのクライアント移行 | 1.1, 1.3 | apiv3Get (P0) | State |
+
+---
+
+### Server Layer
+
+#### validateShareLink
+
+| Field | Detail |
+|-------|--------|
+| Intent | ShareLink の存在・pageId 一致・有効期限を DB で検証し、有効な ShareLinkDocument を返す |
+| Requirements | 2.1, 2.2, 2.3, 2.4 |
+
+**Responsibilities & Constraints**
+- `shareLinkId` と `pageId` の両方を条件とした単一の `findOne` クエリで照合(二重クエリ排除)
+- 失敗時は呼び出し元がマッピング可能な型付きエラーオブジェクトを返す
+- `disableLinkSharing` の確認は呼び出し元(ハンドラー)に委ねる
+
+**Dependencies**
+- Outbound: `ShareLink` Model — `findOne` によるドキュメント取得 (P0)
+
+**Contracts**: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
+
+##### Service Interface
+
+```typescript
+// apps/app/src/server/service/share-link/validate-share-link.ts
+
+type ValidateShareLinkSuccess = { readonly shareLink: ShareLinkDocument };
+type ValidateShareLinkFailure =
+  | { readonly type: 'not-found' }
+  | { readonly type: 'expired' };
+export type ValidateShareLinkResult =
+  | ValidateShareLinkSuccess
+  | ValidateShareLinkFailure;
+
+export async function validateShareLink(
+  shareLinkId: string,
+  pageId: string,
+): Promise<ValidateShareLinkResult>;
+```
+
+- Preconditions: `shareLinkId` と `pageId` は有効な MongoDB ObjectId 文字列
+- Postconditions: 成功時は `{ shareLink }` を返す。`relatedPage` 不一致・未存在時は `{ type: 'not-found' }`、期限切れ時は `{ type: 'expired' }` を返す
+- Invariants: `shareLink.relatedPage` が `pageId` と一致する場合のみ `shareLink` を返す
+
+**Implementation Notes**
+- Integration: `ShareLink.findOne({ _id: { $eq: shareLinkId }, relatedPage: { $eq: pageId } })` の単一クエリで存在確認と relatedPage 照合を同時実施。`isExpired()` メソッドを呼び出して期限確認
+- Validation: ObjectId 形式の検証はハンドラー側の `express-validator` が担当
+- Risks: 型付き結果を使用することで呼び出し元でのステータスコードマッピングが明示的になる
+
+---
+
+#### respondWithSinglePage
+
+| Field | Detail |
+|-------|--------|
+| Intent | ページデータと meta 情報を `ApiV3Response` 形式に変換して返す共有ユーティリティ |
+| Requirements | 1.4, 3.1, 3.2, 3.3, 3.4 |
+
+**Responsibilities & Constraints**
+- `IPageNotFoundInfo` メタの場合に 403/404 を返す
+- `security:disableUserPages` 設定に基づくユーザーページの制限
+- ページが存在する場合の `populateDataToShowRevision` 呼び出し
+- 既存の `page/index.ts` インライン実装を抽出・置き換え。**新ロジックを追加しない**
+
+**Dependencies**
+- Inbound: `GET /page` handler — 既存ハンドラーからの呼び出し (P0)
+- Inbound: `getPageByShareLinkHandlerFactory` — 新ハンドラーからの呼び出し (P0)
+- External: `@growi/core` `isIPageNotFoundInfo`, `isUserPage`, `isUsersTopPage` (P1)
+
+**Contracts**: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
+
+##### Service Interface
+
+```typescript
+// apps/app/src/server/routes/apiv3/page/respond-with-single-page.ts
+
+import type { HydratedDocument } from 'mongoose';
+import type { IDataWithMeta, IPageInfoExt, IPageNotFoundInfo } from '@growi/core';
+import type { PageDocument } from '~/server/models/page';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+type RespondWithSinglePageOpts = {
+  readonly revisionId?: string;
+  readonly disableUserPages: boolean;
+};
+
+export const respondWithSinglePage = async (
+  res: ApiV3Response,
+  pageWithMeta:
+    | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+    | IDataWithMeta<null, IPageNotFoundInfo>,
+  opts: RespondWithSinglePageOpts,
+): Promise<void>;
+```
+
+- Preconditions: `res` は有効な Express レスポンスオブジェクト
+- Postconditions: `res.apiv3()` または `res.apiv3Err()` のいずれか一方を必ず 1 回呼び出す
+- Invariants: share link ページの meta(`isMovable: false` 等)は `findPageAndMetaDataByViewer` が設定済みであり、このユーティリティは meta を変更しない
+
+**Implementation Notes**
+- Integration: `page/index.ts` の既存クロージャを `opts` を受け取るモジュール関数に変換。`page/index.ts` は抽出後にこの関数を import して使用する
+- Validation: `disableUserPages` はハンドラーが `configManager` から取得して渡す
+- Risks: 抽出時に `revisionId` の型(`string | undefined`)と `page.initLatestRevisionField()` の挙動を確認する
+
+---
+
+#### getPageByShareLinkHandlerFactory
+
+| Field | Detail |
+|-------|--------|
+| Intent | `GET /page/shared` エンドポイントのミドルウェア配列を生成するファクトリー |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1–2.5, 3.1–3.4, 4.1–4.3, 5.1–5.4 |
+
+**Responsibilities & Constraints**
+- 認証ミドルウェア(`accessTokenParser`、`loginRequired`、`certifySharedPage`)を使用しない
+- `security:disableLinkSharing` チェックを最初のゲートとして実施
+- `validateShareLink` を呼び出して ShareLink バリデーション結果をハンドリング
+- `findPageAndMetaDataByViewer({ isSharedPage: true })` でページデータ取得
+- `respondWithSinglePage` で統一レスポンスを返す
+
+**Dependencies**
+- Inbound: `page/index.ts` — ルーター登録 (P0)
+- Outbound: `validateShareLink` — share link バリデーション (P0)
+- Outbound: `findPageAndMetaDataByViewer` — ページデータ取得 (P0)
+- Outbound: `respondWithSinglePage` — レスポンス生成 (P0)
+- External: `configManager` — `disableLinkSharing`・`disableUserPages` 設定取得 (P0)
+- External: `express-validator` — `shareLinkId`・`pageId` 入力バリデーション (P1)
+
+**Contracts**: Service [ ] / API [x] / Event [ ] / Batch [ ] / State [ ]
+
+##### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|--------|----------|---------|----------|--------|
+| GET | `/_api/v3/page/shared` | `{ shareLinkId: MongoId, pageId: MongoId }` (query) | `{ page: IPagePopulatedToShowRevision, meta: IPageInfo }` | 400, 403, 404, 500 |
+
+**Request Parameters**:
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `shareLinkId` | MongoId (string) | ✅ | ShareLink の ObjectId |
+| `pageId` | MongoId (string) | ✅ | 参照先ページの ObjectId |
+
+**Handler TypeScript Interface**:
+
+```typescript
+// apps/app/src/server/routes/apiv3/page/get-page-by-share-link.ts
+
+import type { RequestHandler } from 'express';
+import type Crowi from '~/server/crowi';
+
+export const getPageByShareLinkHandlerFactory = (
+  crowi: Crowi,
+): RequestHandler[];
+```
+
+**Handler Execution Order**:
+1. `query('shareLinkId').isMongoId()` バリデーター(必須)
+2. `query('pageId').isMongoId()` バリデーター(必須)
+3. `apiV3FormValidator`
+4. async handler:
+   - `disableLinkSharing` チェック → 403
+   - `validateShareLink(shareLinkId, pageId)` → 結果に応じて 403/404
+   - `findPageAndMetaDataByViewer(pageService, pageGrantService, { pageId, path: null, isSharedPage: true })`
+   - `respondWithSinglePage(res, pageWithMeta, { disableUserPages })`
+
+**Validator sharing**: `pageId` のフォーマットルール(`isMongoId()`)は `validator.getPage` と共通だが、**新エンドポイントでは `optional()` を付けず必須扱いとする**。`validator.getPage` の `query('pageId').isMongoId().optional()` をそのまま流用してはならない。
+
+**Implementation Notes**:
+- Integration: `page/index.ts` で `router.get('/shared', getPageByShareLinkHandlerFactory(crowi))` として登録
+- Validation: handler 内で `configManager.getConfig('security:disableLinkSharing')` および `configManager.getConfig('security:disableUserPages')` を取得
+- Risks: `validateShareLink` 結果の型ガード(`'shareLink' in result`)で成功/失敗を分岐。`shareLink` は取得後に未使用だが、将来のレスポンス拡張(ShareLink メタ情報返却)に備えて保持可能
+
+---
+
+#### page/index.ts クリーンアップ
+
+| Field | Detail |
+|-------|--------|
+| Intent | `GET /page` ルートから share link 関連コードを除去し、`respondWithSinglePage` を抽出ユーティリティに置き換え |
+| Requirements | 5.2, 5.3 |
+
+**変更内容**:
+1. `respondWithSinglePage` クロージャを削除 → `respond-with-single-page.ts` を import
+2. `certifySharedPage` をミドルウェアチェーンから除去
+3. `isSharedPage` 条件分岐をハンドラーから除去
+4. `validator.getPage` から `shareLinkId` バリデーターを除去
+5. `getPageByShareLinkHandlerFactory` を import し `router.get('/shared', ...)` を追加
+
+**後方互換性**: `GET /page?shareLinkId=xxx&pageId=yyy` への既存リクエストは、クライアント移行後に `shareLinkId` パラメータを無視するのみ(400 は返さない)。
+
+**デプロイ順序(必須)**: `certifySharedPage` の除去とクライアントの `/page/shared` 切り替えは**同一デプロイ**で実施すること。分割デプロイが必要な場合は、クライアント移行を先にデプロイし、`certifySharedPage` 除去を後にする。逆順にすると、クライアントがまだ `/page?shareLinkId=xxx` を呼んでいる状態で `certifySharedPage` がなくなり、`loginRequired` が未認証の share link ユーザーをブロックして share ページが閲覧不能になる。
+
+---
+
+### Client Layer
+
+#### useFetchCurrentPage update
+
+| Field | Detail |
+|-------|--------|
+| Intent | share link アクセス時の API エンドポイントを `/page` から `/page/shared` に変更 |
+| Requirements | 1.1, 1.3 |
+
+**Responsibilities & Constraints**
+- `shareLinkId` が存在する場合に `apiv3Get('/page/shared', ...)` を呼び出す
+- `shareLinkId` が存在しない場合は `apiv3Get('/page', ...)` を引き続き使用
+- レスポンス処理ロジックは変更なし
+
+**Contracts**: Service [ ] / API [ ] / Event [ ] / Batch [ ] / State [x]
+
+##### State Management
+
+**変更箇所**: `apps/app/src/states/page/use-fetch-current-page.ts`
+
+```typescript
+// Before
+const { data } = await apiv3Get<FetchedPageResult>('/page', params);
+
+// After
+const endpoint = shareLinkId != null && shareLinkId.length > 0
+  ? '/page/shared'
+  : '/page';
+const { data } = await apiv3Get<FetchedPageResult>(endpoint, params);
+```
+
+- Persistence: Jotai atoms(`currentPageDataAtom` 等)への書き込みは変更なし
+- Concurrency: 変更なし
+
+**Implementation Notes**:
+- Integration: `buildApiParams` の戻り値(`params`)は `/page/shared` で必要な `shareLinkId` と `pageId` を既に含んでいるため、パラメータ構造の変更は不要
+- Validation: `/page/shared` では `shareLinkId` と `pageId` の両方が必須。`buildApiParams` の priority B 分岐(`shareLinkId != null && currentPageId != null` の場合に `params.pageId = currentPageId`)が既にこれを保証している
+- Risks: `currentPageId` が未解決の状態(undefined)で share link ページにアクセスした場合、`buildApiParams` の priority B が機能しないケースを確認する(`shouldSkip` フラグで対処済みの可能性が高い)
+
+---
+
+## Error Handling
+
+### Error Strategy
+
+全バリデーションエラーはリクエストの早い段階で検出し、DB アクセスを最小化する。エラーレスポンスは既存の `ErrorV3` / `res.apiv3Err()` パターンに準拠する。
+
+### Error Categories and Responses
+
+| Category | Scenario | Error Code | HTTP Status |
+|----------|----------|------------|-------------|
+| User Errors (4xx) | `shareLinkId` / `pageId` 未指定・不正形式 | `validation-failed` | 400 |
+| Business Logic (4xx) | `disableLinkSharing=true` | `link-sharing-disabled` | 403 |
+| Business Logic (4xx) | ShareLink 未存在または `relatedPage` 不一致 | `share-link-not-found` | 404 |
+| Business Logic (4xx) | ShareLink 期限切れ | `share-link-expired` | 403 |
+| Business Logic (4xx) | ページ未存在 | `page-not-found` | 404 |
+| Business Logic (4xx) | `disableUserPages` でユーザーページへのアクセス | `page-is-forbidden` | 403 |
+| System Errors (5xx) | DB エラー・populate 失敗 | `get-page-failed` | 500 |
+
+### Monitoring
+
+- logger: `loggerFactory('growi:routes:apiv3:page:get-page-by-share-link')` を使用
+- 403/404 は業務上正常な範囲として `logger.debug` レベル、5xx は `logger.error` レベルでログ出力
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+
+`validateShareLink` のユニットテスト(`validate-share-link.spec.ts`):
+- 有効な ShareLink(存在・relatedPage 一致・未期限)→ `{ shareLink }` を返す
+- ShareLink が存在しない → `{ type: 'not-found' }` を返す
+- `relatedPage` が `pageId` と不一致 → `{ type: 'not-found' }` を返す
+- `isExpired() === true` → `{ type: 'expired' }` を返す
+
+`respondWithSinglePage` のユニットテスト(`respond-with-single-page.spec.ts`):
+- `IPageNotFoundInfo` メタかつ `isForbidden=true` → 403
+- `IPageNotFoundInfo` メタかつ `isForbidden=false` → 404
+- `disableUserPages=true` でユーザーページ → 403
+- 正常ページ → `res.apiv3({ page, meta })` が呼ばれる
+
+### Integration Tests
+
+`getPageByShareLinkHandlerFactory` の統合テスト(`get-page-by-share-link.integ.ts`):
+- 有効な `shareLinkId` + `pageId` → 200 と `{ page, meta }` を返す(`isMovable: false` を確認)
+- 期限切れリンク → 403 `share-link-expired`
+- 存在しない `shareLinkId` → 404 `share-link-not-found`
+- `disableLinkSharing=true` → 403 `link-sharing-disabled`
+- `pageId` / `shareLinkId` 未指定 → 400
+
+### Security Considerations
+
+- **認証不要エンドポイントのデータ保護**: `validateShareLink` が全ての DB チェックを完了した後にのみ `findPageAndMetaDataByViewer` を呼び出す。バリデーション失敗時はページデータに一切アクセスしない。
+- **`disableLinkSharing` の適用**: 新エンドポイントは既存の未適用バグを修正し、設定が `true` の場合に確実に 403 を返す。
+- **ページ権限のスキップ**: `isSharedPage: true` による `findPageAndMetaDataByViewer` の権限チェックスキップは、ShareLink バリデーション完了後のみ実行されるため、不正アクセスのリスクはない。
+- **入力サニタイズ**: `shareLinkId` と `pageId` は `express-validator` で MongoId 形式を強制し、NoSQL インジェクションを防止する。

+ 188 - 0
.kiro/specs/sharelink-page-api/gap-analysis.md

@@ -0,0 +1,188 @@
+# Gap Analysis: sharelink-page-api
+
+## 1. 現状調査 (Current State Investigation)
+
+### 調査対象ファイル
+
+| ファイル | 役割 |
+|---|---|
+| `apps/app/src/server/routes/apiv3/page/index.ts` | 既存の `GET /page` ルートハンドラ |
+| `apps/app/src/server/middlewares/certify-shared-page.js` | share link 検証ミドルウェア |
+| `apps/app/src/server/service/page/find-page-and-meta-data-by-viewer.ts` | ページデータ取得サービス関数 |
+| `apps/app/src/server/models/share-link.ts` | ShareLink Mongoose モデル |
+| `apps/app/src/server/routes/apiv3/share-links.js` | ShareLink CRUD ルート |
+| `apps/app/src/pages/share/[[...path]]/page-data-props.ts` | SSR 用 share link ページ取得 |
+| `apps/app/src/states/page/use-fetch-current-page.ts` | クライアント側 page API 呼び出しフック |
+
+### 既存ミドルウェアチェーン(`GET /page` ルート)
+
+```
+accessTokenParser → certifySharedPage → loginRequired → validator.getPage → handler
+```
+
+handler 内の分岐:
+1. `isSharedPage=true` の場合 → `ShareLink.findOne` (2回目) + `findPageAndMetaDataByViewer({ isSharedPage: true })`
+2. `findAll` の場合 → `Page.findByPathAndViewer`
+3. デフォルト → `findPageAndMetaDataByViewer`
+
+### クライアント側の呼び出しパターン(`use-fetch-current-page.ts`)
+
+```typescript
+// share link 表示時に生成されるパラメータ
+{ pageId: currentPageId, shareLinkId: shareLinkId }
+// → apiv3Get('/page', { pageId, shareLinkId }) で呼び出し
+```
+
+---
+
+## 2. 要件フィージビリティ分析 (Requirements Feasibility Analysis)
+
+### 要件 → 資産マップ
+
+| 要件 | 既存資産 | ギャップ種別 | 詳細 |
+|---|---|---|---|
+| Req 1: 専用エンドポイント | なし | **Missing** | 新規ルートハンドラが必要 |
+| Req 2: シェアリンク検証 | `certifySharedPage.js`(部分的) | **Gap** | `disableLinkSharing` 未チェック・DB 二重クエリ問題 |
+| Req 3: ページデータ返却 | `findPageAndMetaDataByViewer` | **Gap** | `respondWithSinglePage` ヘルパーがインライン定義のため未抽出 |
+| Req 4: 認証不要 | `loginRequired(isGuestAllowed: true)` で代替中 | **Constraint** | 既存ルートの設計では `certifySharedPage` + `loginRequired` に依存 |
+| Req 5: コード重複回避 | `findPageAndMetaDataByViewer` は再利用可能 | **Gap** | バリデーション・レスポンスシリアライズが抽出されていない |
+
+### 発見された既知の問題(既存実装のバグ/設計課題)
+
+#### 問題 1: DB 二重クエリ
+
+`certifySharedPage` ミドルウェアで 1 回 `ShareLink.findOne` → ハンドラでさらに `ShareLink.findOne` を実行。専用エンドポイントでは 1 回に集約できる。
+
+```javascript
+// certifySharedPage.js: 1回目
+ShareLink.findOne({ _id: shareLinkId, relatedPage: pageId })
+
+// page/index.ts handler: 2回目
+ShareLink.findOne({ _id: shareLinkId })
+```
+
+#### 問題 2: `disableLinkSharing` がページ取得 API で未チェック
+
+- `share-links.js` の CRUD ルート(作成・一覧)では `linkSharingRequired` ミドルウェアで `disableLinkSharing` をチェックしている
+- `certifySharedPage` ミドルウェアは `disableLinkSharing` を**チェックしない**
+- `GET /page` ハンドラも `disableLinkSharing` を**チェックしない**
+- → `disableLinkSharing=true` 設定でも、既存の share link 経由でページデータを取得可能な状態
+
+専用エンドポイントでは、この設定を適切にチェックすることで要件 2-5 を満たす。
+
+#### 問題 3: `certifySharedPage` のサイレント・パス
+
+期限切れ / 無効なリンクの場合、`certifySharedPage` はエラーを返さず `next()` を呼ぶ。その後 `loginRequired` が未認証リクエストをブロックする設計。専用エンドポイントでは、無効リンクを明示的なエラーレスポンス(403/404)で返すべき。
+
+### 再利用可能な既存資産
+
+**完全に再利用可能(変更不要):**
+- `findPageAndMetaDataByViewer` — `isSharedPage: true` オプション対応済み
+- `ShareLink` モデル + `isExpired()` メソッド
+- `page.populateDataToShowRevision()` メソッド
+- `configManager.getConfig('security:disableLinkSharing')`
+- `configManager.getConfig('security:disableUserPages')`
+- `apiV3FormValidator` ミドルウェア
+- `ErrorV3` エラークラス + `res.apiv3Err()` / `res.apiv3()` レスポンスヘルパー
+
+**抽出・リファクタリングが必要:**
+- `respondWithSinglePage` — `page/index.ts` ハンドラ内インライン定義 → 共有ユーティリティに抽出
+- share link 検証ロジック — `certifySharedPage.js` の DB クエリ部分 → 純粋関数として抽出
+
+**不要(専用エンドポイントには使用しない):**
+- `accessTokenParser` — 認証不要のため
+- `loginRequired` — 認証不要のため
+- `certifySharedPage` ミドルウェア本体 — 専用エンドポイントでは使用しない(ただし既存ルートの互換性維持のため削除はしない)
+
+---
+
+## 3. 実装アプローチ選択肢 (Implementation Approach Options)
+
+### Option A: `GET /page/shared` として page ルートに追加
+
+**新規ファイル**: `apps/app/src/server/routes/apiv3/page/get-page-by-share-link.ts`
+
+ハンドラーファクトリーパターン(`get-page-info.ts` と同様):
+
+```
+GET /_api/v3/page/shared?shareLinkId=xxx&pageId=yyy
+```
+
+ミドルウェアチェーン(シンプル化):
+```
+validateShareLinkParams → handler
+```
+
+- `page/index.ts` に `router.get('/shared', getPageByShareLinkHandlerFactory(crowi))` を追加
+- `respondWithSinglePage` を `page/respond-with-single-page.ts` として抽出
+- share link バリデーション純粋関数を抽出
+
+**Trade-offs:**
+- ✅ `get-page-info.ts` と同一パターン(コードレビューが容易)
+- ✅ TypeScript ネイティブ
+- ✅ `page/` ディレクトリ内で完結
+- ✅ 既存ルートへの影響最小
+- ❌ クライアント側 URL 変更が必要(`/page` → `/page/shared`)
+
+### Option B: `GET /share-links/:shareLinkId/page` として share-links ルートに追加
+
+```
+GET /_api/v3/share-links/:shareLinkId/page?pageId=yyy
+```
+
+- `share-links.js` に新ルートを追加
+- RESTful 設計として最もセマンティック
+
+**Trade-offs:**
+- ✅ RESTful な URL 構造(share link がリソースの起点)
+- ✅ share link 関連ロジックが share-links ルートに集約される
+- ❌ `share-links.js` は CommonJS/JavaScript → TypeScript 化またはJS で書く必要
+- ❌ クライアント側 URL 変更が必要かつ変更量が大きい
+
+### Option C: Hybrid(推奨)
+
+**Phase 1**: Option A と同様に `GET /page/shared` を新規作成(TypeScript)
+- 新ファイル: `page/get-page-by-share-link.ts`
+- `respondWithSinglePage` ユーティリティ抽出: `page/respond-with-single-page.ts`
+- share link バリデーション純粋関数抽出(`certifySharedPage` から)
+
+**Phase 2**: クライアント側を新エンドポイントに移行
+- `use-fetch-current-page.ts` の API 呼び出しを `/page/shared` に変更
+
+**Phase 3**: 既存ルートの share link ブランチ除去(オプション)
+- `page/index.ts` から `isSharedPage` 分岐を削除
+- `certifySharedPage` ミドルウェアの使用箇所を精査して不要になれば削除
+
+**Trade-offs:**
+- ✅ 段階的に安全に移行できる
+- ✅ TypeScript ネイティブ
+- ✅ 確立されたパターンに従う
+- ✅ 既存ルートの動作を壊さない
+- ❌ Phase 1 完了後に古い実装が一時的に並存する
+
+---
+
+## 4. 実装複雑度・リスク評価
+
+| 項目 | 評価 | 根拠 |
+|---|---|---|
+| **努力量** | **S(1〜3 日)** | 全サービス関数は既存。パターン確立済み(`get-page-info.ts` と同様)。主な作業は新ハンドラファイル + 2 つのユーティリティ抽出 + クライアント側変更 |
+| **リスク** | **Low** | 新しいアーキテクチャパターンなし・新規依存なし・対象スコープが明確 |
+
+---
+
+## 5. 設計フェーズへの推薦事項
+
+### 推奨アプローチ
+**Option C(Hybrid)** を推薦。`get-page-info.ts` と同一のハンドラファクトリーパターンで `get-page-by-share-link.ts` を実装する。
+
+### 設計フェーズで確定すべき事項
+
+1. **エンドポイント URL の確定**: `GET /page/shared` か `GET /share-links/:id/page` か
+2. **クライアント側変更のスコープ**: `use-fetch-current-page.ts` の変更内容と後方互換性方針
+3. **`respondWithSinglePage` 抽出の設計**: 型シグネチャと依存関係
+4. **`disableLinkSharing` チェックの位置**: ミドルウェアとして抽出するか、ハンドラ内に配置するか
+5. **既存 `GET /page` ルートの share link ブランチの扱い**: Phase 3 で削除するか並存させるか
+
+### Research Needed
+- `certifySharedPage` が他のルート(`get-page-info.ts` など)でも使われているため、削除・変更の影響範囲を確認(→ 設計フェーズで調査)

+ 76 - 0
.kiro/specs/sharelink-page-api/requirements.md

@@ -0,0 +1,76 @@
+# Requirements Document
+
+## Introduction
+
+本仕様は、GROWI の share link 専用ページ取得 API エンドポイントの要件を定義する。
+
+現行の `/_api/v3/page` ルートは、通常の認証アクセスと share link アクセスの両方を単一ルートで処理している。ミドルウェアチェーン(`accessTokenParser → certifySharedPage → loginRequired`)と handler 内の条件分岐によって混在しており、コードの可読性と保守性に課題がある。
+
+本機能では share link アクセス専用のエンドポイントを分離することで、責務を明確化する。同時に、既存のサービス関数(`findPageAndMetaDataByViewer` 等)を最大限に再利用し、ロジックの重複を最小化することを設計原則とする。
+
+---
+
+## Requirements
+
+### Requirement 1: 専用エンドポイントの提供
+
+**Objective:** As a クライアントアプリケーション, I want share link 専用の独立した API エンドポイント, so that share link 経由のページアクセスが通常の認証付きアクセスと明確に分離される。
+
+#### Acceptance Criteria
+
+1. The Share Link Page API shall provide a dedicated endpoint distinct from the general `/_api/v3/page` endpoint for share link-based page access.
+2. When a request is sent to the dedicated share link endpoint, the Share Link Page API shall process it independently of the authenticated page access middleware chain (`accessTokenParser`, `loginRequired`).
+3. The Share Link Page API shall accept `shareLinkId` and `pageId` as required request parameters.
+4. The Share Link Page API shall return a response in the same JSON structure as the existing page API (`{ page, meta }`).
+
+---
+
+### Requirement 2: シェアリンクの検証
+
+**Objective:** As a GROWI システム, I want strict share link validation before returning any page data, so that 無効・期限切れのリンクからページデータが漏洩しない。
+
+#### Acceptance Criteria
+
+1. When a request is received, the Share Link Page API shall verify that a ShareLink document with the specified `shareLinkId` exists in the database.
+2. When a request is received, the Share Link Page API shall verify that the ShareLink's `relatedPage` field matches the specified `pageId`.
+3. If the ShareLink document does not exist, or its `relatedPage` does not match `pageId`, the Share Link Page API shall return a 404 error response without exposing any page data.
+4. If the ShareLink has an `expiredAt` value earlier than the current time, the Share Link Page API shall return a 403 error response.
+5. While link sharing is disabled via the `security:disableLinkSharing` configuration, the Share Link Page API shall return a 403 error response for all requests regardless of link validity.
+
+---
+
+### Requirement 3: ページデータの返却
+
+**Objective:** As a share link 閲覧者, I want to receive page content and metadata via the API, so that ページを正常にレンダリングできる。
+
+#### Acceptance Criteria
+
+1. When a valid and non-expired share link is provided, the Share Link Page API shall return the page document including its latest revision data.
+2. When returning page data for a share link request, the Share Link Page API shall set `isMovable`, `isDeletable`, `isAbleToDeleteCompletely`, and `isRevertible` to `false` in the meta field.
+3. When returning page data for a share link request, the Share Link Page API shall return `bookmarkCount` as `0`.
+4. If the referenced page does not exist, the Share Link Page API shall return a 404 error response.
+
+---
+
+### Requirement 4: 認証不要のアクセス
+
+**Objective:** As an 未認証ユーザー, I want to access share link pages without logging in, so that share link の公開アクセス性が担保される。
+
+#### Acceptance Criteria
+
+1. The Share Link Page API shall not require user authentication (session cookie, access token) to process requests.
+2. When an unauthenticated request includes a valid and non-expired `shareLinkId`, the Share Link Page API shall return the page data.
+3. While link sharing is enabled, the Share Link Page API shall serve page data to any requester regardless of authentication state.
+
+---
+
+### Requirement 5: コード重複の最小化
+
+**Objective:** As a 開発者, I want the share link endpoint to reuse existing service layer code, so that ロジックの重複による保守コストとバグリスクを低減できる。
+
+#### Acceptance Criteria
+
+1. The Share Link Page API shall reuse the existing page data retrieval service function (e.g., `findPageAndMetaDataByViewer`) with the `isSharedPage: true` option, rather than reimplementing page fetch and metadata computation logic.
+2. Where share link validation logic currently exists in `certifySharedPage` middleware, the Share Link Page API shall extract it as a reusable function or middleware, rather than duplicating the validation inline.
+3. The Share Link Page API implementation shall not duplicate the page response serialization logic already present in the existing `/_api/v3/page` route handler.
+4. Where applicable, common request validators (e.g., `pageId` format checks) shall be shared between the dedicated endpoint and the existing route rather than redefined.

+ 135 - 0
.kiro/specs/sharelink-page-api/research.md

@@ -0,0 +1,135 @@
+# Research & Design Decisions: sharelink-page-api
+
+---
+
+## Summary
+
+- **Feature**: `sharelink-page-api`
+- **Discovery Scope**: Extension(既存 page API ルートへの拡張)
+- **Key Findings**:
+  1. `findPageAndMetaDataByViewer` は `isSharedPage: true` オプションを既にサポートしており、変更不要で再利用可能
+  2. `certifySharedPage` ミドルウェアには 2 つの既知の設計課題がある:`disableLinkSharing` 未チェック、および無効/期限切れリンクのサイレントパス(エラーを返さず次のミドルウェアへ)
+  3. 現行の `GET /page` ルートは share link アクセスで `ShareLink.findOne` を 2 回実行する(`certifySharedPage` + ハンドラ)。専用エンドポイントで 1 回に集約できる
+
+---
+
+## Research Log
+
+### `certifySharedPage` ミドルウェアの動作分析
+
+- **Context**: 専用エンドポイントで `certifySharedPage` をそのまま再利用できるか検証
+- **Sources Consulted**: `apps/app/src/server/middlewares/certify-shared-page.js`
+- **Findings**:
+  - `pageId` または `shareLinkId` が null の場合、検証をスキップして `next()` を呼ぶ
+  - ShareLink が見つからないか期限切れの場合も `next()` を呼ぶ(`req.isSharedPage` を設定しない)
+  - `security:disableLinkSharing` 設定を一切チェックしない
+  - 設計上「フラグ設定専用ミドルウェア」であり、`loginRequired` と組み合わせて初めて機能する
+- **Implications**: 専用エンドポイントでは `certifySharedPage` を使わず、明示的なバリデーション関数を新規作成する
+
+### `disableLinkSharing` 設定の適用範囲調査
+
+- **Context**: `disableLinkSharing=true` 時に既存の share link 経由でページ取得 API が保護されているか確認
+- **Sources Consulted**: `share-links.js` の `linkSharingRequired` ミドルウェア、`certify-shared-page.js`、`page/index.ts`
+- **Findings**:
+  - `disableLinkSharing` は ShareLink の**作成・一覧取得**ルートのみで確認されている(`share-links.js` の `linkSharingRequired`)
+  - `GET /page?shareLinkId=xxx` 経由でのページ**取得**は、`disableLinkSharing=true` でも保護されていない(既存バグ)
+  - SSR の share ページ (`page-data-props.ts`) もこの設定をチェックしていない
+- **Implications**: 新専用エンドポイントでこの設定チェックを追加することで、要件 2.5 を満たしつつ既存バグを修正する
+
+### クライアント側 API 呼び出しパターン調査
+
+- **Context**: クライアントが `GET /page` に `shareLinkId` を渡す仕組みを確認
+- **Sources Consulted**: `apps/app/src/states/page/use-fetch-current-page.ts`
+- **Findings**:
+  - `buildApiParams` 関数内で `shareLinkId` が存在する場合に `params.shareLinkId = shareLinkId` と `params.pageId = currentPageId` を設定
+  - 最終的に `apiv3Get('/page', params)` として呼び出し
+  - コメントに「required by certifySharedPage middleware」と明記されており、ミドルウェア依存を意識した設計
+- **Implications**: 新エンドポイントへの移行時に `apiv3Get` の第 1 引数を条件分岐させるだけでよい(パラメータ構造は変わらない)
+
+### 既存ハンドラーファクトリーパターン確認
+
+- **Context**: 新ハンドラーファイルが従うべきパターンを確認
+- **Sources Consulted**: `get-page-info.ts`、`page/index.ts`
+- **Findings**:
+  - `getPageInfoHandlerFactory(crowi): RequestHandler[]` の形式でファクトリーを export
+  - `page/index.ts` で `router.get('/info', getPageInfoHandlerFactory(crowi))` として登録(Express はミドルウェア配列を直接受け付ける)
+  - `certifySharedPage` と `loginRequired` の両方を使用している(新エンドポイントでは両方不要)
+- **Implications**: 新ファイル `get-page-by-share-link.ts` は同一のファクトリーパターンで実装し、`GET /page/shared` として登録する
+
+---
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| A: `GET /page/shared` | `page/` ディレクトリ内に新ハンドラーファイルを追加 | 既存パターンと一致・TypeScript ネイティブ・クライアント変更が最小 | クライアント URL 変更が必要 | **採用** |
+| B: `GET /share-links/:id/page` | `share-links.js` ルーターに追加 | RESTful なリソース URL 設計 | `share-links.js` が CommonJS/JS・クライアント変更が大きい | 非採用 |
+| C: `GET /share-links/page` | クエリパラメータ方式で `share-links.js` に追加 | パラメータ構造が変わらない | JS ファイルへの追加・名前空間が直感的でない | 非採用 |
+
+---
+
+## Design Decisions
+
+### Decision: エンドポイント URL の選択
+
+- **Context**: share link 専用エンドポイントの URL パスをどこに配置するか
+- **Alternatives Considered**:
+  1. `GET /page/shared` — `page/` ディレクトリ内の新ハンドラー
+  2. `GET /share-links/:id/page` — RESTful リソース設計
+  3. `GET /share-links/page` — クエリパラメータ方式
+- **Selected Approach**: `GET /page/shared` を採用
+- **Rationale**: `get-page-info.ts` と完全に同じハンドラーファクトリーパターンを踏襲でき、TypeScript ネイティブ。クライアント側の変更は API パスの条件分岐のみで最小限。`page/` ディレクトリ内でルーター登録まで完結する。
+- **Trade-offs**: URL がリソース中心でない点は妥協点だが、GROWI の既存 `/page/info` パターンと一貫性がある
+- **Follow-up**: 実装時に OpenAPI ドキュメントも更新する
+
+### Decision: ShareLink バリデーションの実装方式
+
+- **Context**: share link の DB バリデーション(存在確認・期限確認・relatedPage 照合)をどこに置くか
+- **Alternatives Considered**:
+  1. `certifySharedPage` ミドルウェアを改修して再利用
+  2. ハンドラー内にインライン実装
+  3. 新規サービス関数として抽出(`server/service/share-link/validate-share-link.ts`)
+- **Selected Approach**: 新規サービス関数 `validateShareLink` を `server/service/share-link/validate-share-link.ts` に作成
+- **Rationale**: `certifySharedPage` は「フラグ設定ミドルウェア」として設計されており、単体でエラーレスポンスを返す責務を持たない。`server/service/page/find-page-and-meta-data-by-viewer.ts` と同じ service 層パターンに従い、純粋な非同期バリデーション関数として実装することで、他ルートからも再利用可能になる(例: `get-page-info.ts` の将来的なリファクタリング)
+- **Trade-offs**: 新規ディレクトリ `server/service/share-link/` の作成が必要
+- **Follow-up**: `certifySharedPage` は `get-page-info.ts` でまだ使用されているため、このスペック内では削除せず残す
+
+### Decision: `respondWithSinglePage` の抽出
+
+- **Context**: ページデータをレスポンスに変換するロジックの重複を避ける方法
+- **Alternatives Considered**:
+  1. 新ハンドラーにインラインで複製
+  2. `page/index.ts` 内のクロージャを共有ユーティリティとして抽出
+- **Selected Approach**: `page/respond-with-single-page.ts` として抽出・export
+- **Rationale**: 要件 5.3 を直接満たす。既存の `page/index.ts` ハンドラーと新ハンドラーの両方が同じロジックを参照できる
+- **Trade-offs**: `res` オブジェクト(`ApiV3Response` 型)と設定値を引数として受け取る設計が必要
+- **Follow-up**: `initLatestRevisionField` の呼び出し引数(`revisionId`)の扱いを実装時に確認
+
+### Decision: `disableLinkSharing` チェックの位置
+
+- **Context**: グローバル設定の確認をどのレイヤーで行うか
+- **Alternatives Considered**:
+  1. `validateShareLink` サービス関数内に含める
+  2. 新ハンドラーの先頭で確認(リクエストレイヤー)
+- **Selected Approach**: ハンドラーの先頭で確認
+- **Rationale**: `validateShareLink` は DB レベルの検証(link の存在・有効性)に責務を限定する。グローバル設定の確認はリクエスト処理の最初のゲートとして、ハンドラーが明示的に担当する
+- **Trade-offs**: なし(シンプルかつ透明性が高い)
+
+### Decision: 既存 `GET /page` ハンドラーのクリーンアップ方針
+
+- **Context**: 新エンドポイント追加後、旧ルートの share link ブランチをどうするか
+- **Selected Approach**: 同一スコープ内でクリーンアップを実施
+  - `certifySharedPage` を `GET /page` ミドルウェアチェーンから除去
+  - `isSharedPage` 条件分岐をハンドラーから除去
+  - `shareLinkId` パラメータバリデーターを除去
+  - クライアントを新エンドポイントに移行してから除去
+- **Rationale**: ゾンビコードを残さない。クライアント側の移行と backend のクリーンアップを同一 PR にまとめることで不整合期間をゼロにする
+- **Trade-offs**: `get-page-info.ts` はまだ `certifySharedPage` を使用しているため、`certifySharedPage` ファイル自体は削除しない
+
+---
+
+## Risks & Mitigations
+
+- **クライアント移行の原子性**: クライアント (`use-fetch-current-page.ts`) と backend のデプロイが分離すると、移行期間中に新エンドポイントが存在しないタイミングが生じる可能性がある — 同一 PR で backend と client を同時変更することで回避
+- **`get-page-info.ts` への影響**: `certifySharedPage` は引き続き `get-page-info.ts` で使用される — このスペックのスコープ外として明示し、別タスク化を検討
+- **`disableLinkSharing` の既存動作変更**: 新エンドポイントで `disableLinkSharing` を正しくチェックすることは、現行の未チェック状態からの動作変更になる — セキュリティ改善として位置づけ、リリースノートに明記する

+ 22 - 0
.kiro/specs/sharelink-page-api/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "sharelink-page-api",
+  "created_at": "2026-04-13T00:00:00.000Z",
+  "updated_at": "2026-04-13T00:00:00.000Z",
+  "language": "ja",
+  "phase": "tasks-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": false
+    }
+  },
+  "ready_for_implementation": false
+}

+ 60 - 0
.kiro/specs/sharelink-page-api/tasks.md

@@ -0,0 +1,60 @@
+# Implementation Plan
+
+- [ ] 1. (P) ShareLink バリデーションサービスの実装とテスト
+- [ ] 1.1 ShareLink のデータベースバリデーション関数を実装する
+  - share link ID とページ ID の両方を照合条件とした単一クエリで ShareLink を取得し、ミドルウェアで行っていた二重クエリを解消する
+  - 照合成功・リンク未存在/pageId 不一致・期限切れの 3 パターンを判別可能な結果型(discriminated union)で返す
+  - `disableLinkSharing` 設定の確認はハンドラー層に委ねて関数の責務を DB バリデーションのみに限定する
+  - `server/service/share-link/` ディレクトリを新規作成してこのサービスを配置し、将来的に他ルートからも再利用できる構造にする
+  - _Requirements: 2.1, 2.2, 2.3, 2.4_
+
+- [ ] 1.2 バリデーション関数のユニットテストを実装する
+  - 有効なリンク(ShareLink が存在・relatedPage が一致・期限内)のとき成功結果を返すことを確認する
+  - ShareLink が存在しない、または relatedPage が pageId と不一致のとき "not-found" 結果を返すことを確認する
+  - `isExpired()` が true のとき "expired" 結果を返すことを確認する
+  - _Requirements: 2.1, 2.2, 2.3, 2.4_
+
+- [ ] 2. (P) ページレスポンスユーティリティの抽出とテスト
+- [ ] 2.1 既存ページ取得ハンドラー内のレスポンス生成ロジックを共有ユーティリティとして抽出する
+  - `GET /page` ハンドラー内のインライン関数(ページデータ・meta を受け取って API レスポンスを返す処理)を独立したモジュール関数に変換する
+  - レスポンスオブジェクト・ページデータ・オプション(revisionId・disableUserPages)を引数として受け取るよう設計する
+  - 既存の `GET /page` ハンドラーがこのユーティリティを import して使用するよう書き換え、動作が変わらないことを確認する
+  - _Requirements: 5.3_
+
+- [ ] 2.2 レスポンスユーティリティのユニットテストを実装する
+  - ページが forbidden(`isForbidden: true`)のとき 403 が返ることを確認する
+  - ページが見つからない(`isNotFound: true`)のとき 404 が返ることを確認する
+  - `disableUserPages` が有効なユーザーページで 403 が返ることを確認する
+  - 正常ページで `{ page, meta }` を含むレスポンスが返ることを確認する
+  - _Requirements: 1.4, 3.1, 3.2, 3.3, 3.4_
+
+- [ ] 3. share link 専用エンドポイントの実装・登録・テスト
+- [ ] 3.1 `GET /page/shared` ハンドラーを実装してルーターに登録する
+  - `shareLinkId` と `pageId` の両方を MongoId 形式の必須パラメータとして受け取る(`optional()` を使用しない)
+  - リクエスト処理の最初のゲートとして `disableLinkSharing` 設定を確認し、無効時は 403 を返す
+  - Task 1 のバリデーション関数を呼び出し、"not-found" 結果には 404、"expired" 結果には 403 を返す
+  - バリデーション成功後に `isSharedPage: true` オプションでページデータを取得し、Task 2 のレスポンスユーティリティで返す
+  - 認証ミドルウェア(`accessTokenParser`・`loginRequired`・`certifySharedPage`)を一切使用しない公開エンドポイントとする
+  - ページルーターに `GET /shared` として登録し、`getPageInfoHandlerFactory` と同じパターンで組み込む
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.5, 3.1, 4.1, 4.2, 4.3, 5.1, 5.4_
+
+- [ ] 3.2 エンドポイントの統合テストを実装する
+  - 有効な shareLinkId と pageId を指定したリクエストが 200 で `{ page, meta }` を返し、`isMovable: false` であることを確認する
+  - 期限切れリンクで 403 `share-link-expired` が返ることを確認する
+  - 存在しない shareLinkId または pageId 不一致で 404 `share-link-not-found` が返ることを確認する
+  - `disableLinkSharing=true` の状態で 403 `link-sharing-disabled` が返ることを確認する
+  - `shareLinkId` または `pageId` を省略したリクエストで 400 が返ることを確認する
+  - _Requirements: 1.1, 1.3, 2.1, 2.2, 2.3, 2.4, 2.5, 3.2, 3.3, 4.1_
+
+- [ ] 4. クライアント更新と既存ルートのクリーンアップ
+- [ ] 4.1 share link アクセス時のページ取得 API 呼び出しを新エンドポイントに切り替える
+  - `shareLinkId` が存在するかどうかで呼び出し先を `/page/shared` と `/page` に条件分岐させる
+  - パラメータ構造(`shareLinkId` + `pageId`)は既存の `buildApiParams` の出力をそのまま使用できるため変更不要であることを確認する
+  - _Requirements: 1.1, 1.3_
+
+- [ ] 4.2 `GET /page` ルートから share link 関連コードを除去してシンプル化する
+  - `certifySharedPage` をミドルウェアチェーンから除去する
+  - `isSharedPage` 条件分岐をハンドラーから除去する
+  - `shareLinkId` パラメータバリデーターをバリデーター定義から除去する
+  - **必須**: Task 4.1 のクライアント更新と同一デプロイで実施すること。先に除去すると未移行クライアントが share link アクセス時に `loginRequired` によりブロックされる
+  - _Requirements: 5.2, 5.3_