Shun Miyazawa 4 дней назад
Родитель
Сommit
29cd657ace
2 измененных файлов с 86 добавлено и 265 удалено
  1. 78 255
      .kiro/specs/sharelink-page-api/design.md
  2. 8 10
      .kiro/specs/sharelink-page-api/requirements.md

+ 78 - 255
.kiro/specs/sharelink-page-api/design.md

@@ -4,20 +4,20 @@
 
 本機能は、share link 経由のページ取得を専用の API エンドポイント `GET /_api/v3/page/shared` として分離する。現行の `GET /_api/v3/page` ルートは、通常認証アクセスと share link アクセスの両方をミドルウェアフラグ(`req.isSharedPage`)と条件分岐で処理しており、責務が混在している。専用エンドポイントを設けることで、各ルートの責務を明確化し、コードの可読性と保守性を向上させる。
 
-既存のサービス関数(`findPageAndMetaDataByViewer`、`respondWithSinglePage`)を共有ユーティリティとして抽出・再利用することで、ロジックの重複を排除する。さらに、現行実装で未対応だった `security:disableLinkSharing` 設定のチェックをページ取得 API レイヤーに追加し、セキュリティギャップを解消する。
+既存のミドルウェア(`certifySharedPage`)とサービス関数(`findPageAndMetaDataByViewer`)を再利用し、`respondWithSinglePage` を共有ユーティリティとして抽出することで、ロジックの重複を排除する。さらに、現行実装で未対応だった `security:disableLinkSharing` 設定のチェックを `rejectLinkSharingDisabled` ミドルウェアとして新設し、セキュリティギャップを解消する。
 
 ### Goals
 
 - share link アクセス専用の `GET /_api/v3/page/shared` エンドポイントを実装する
-- 認証ミドルウェア(`accessTokenParser`、`loginRequired`、`certifySharedPage`)を使用せず、公開エンドポイントとして設計する
-- `findPageAndMetaDataByViewer` および `respondWithSinglePage` を共有ユーティリティとして再利用し、コード重複を排除する
-- `security:disableLinkSharing` 設定を新エンドポイントで正しく適用する
+- 既存の `certifySharedPage` ミドルウェアを再利用して ShareLink バリデーションを行う
+- `rejectLinkSharingDisabled` ミドルウェアを新設し、`security:disableLinkSharing` 設定を正しく適用する
+- `respondWithSinglePage` を共有ユーティリティとして抽出し、`GET /page` と `GET /page/shared` で共用する
 - 既存の `GET /page` ルートから share link 関連のコードを除去してシンプル化する
 
 ### Non-Goals
 
 - `GET /_api/v3/page/info`(`get-page-info.ts`)の share link 対応リファクタリング(別スコープ)
-- `certifySharedPage` ミドルウェアの削除(`get-page-info.ts` が引き続き使用するため)
+- `certifySharedPage` ミドルウェアの削除(`get-page-info.ts`・`revisions.js` が引き続き使用するため)
 - share link の特定リビジョン指定(`revisionId`)対応(初期スコープ外)
 - SSR share ページ(`/share/[[...path]]/page-data-props.ts`)の変更
 
@@ -25,24 +25,6 @@
 
 ## 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
@@ -57,8 +39,12 @@ graph TB
         RespondUtil[respondWithSinglePage - extracted]
     end
 
+    subgraph MiddlewareLayer[Middleware Layer]
+        RejectLSD[rejectLinkSharingDisabled - NEW]
+        CertifySP[certifySharedPage - existing]
+    end
+
     subgraph ServiceLayer[Service Layer]
-        ValidateSL[validateShareLink - NEW]
         FPAMDBV[findPageAndMetaDataByViewer - existing]
     end
 
@@ -70,31 +56,22 @@ graph TB
 
     FetchHook -->|shareLinkId present| NewRoute
     FetchHook -->|shareLinkId absent| OldRoute
-    NewRoute --> ConfigMgr
-    NewRoute --> ValidateSL
+    NewRoute --> RejectLSD
+    RejectLSD --> ConfigMgr
+    NewRoute --> CertifySP
+    CertifySP --> ShareLinkModel
     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` 入力バリデーション | 既存バリデーター再利用 |
+- New components: `rejectLinkSharingDisabled` ミドルウェア、`respondWithSinglePage` ユーティリティ(抽出)、`getPageByShareLinkHandlerFactory` ハンドラー
+- Reused components: `certifySharedPage` ミドルウェア、`findPageAndMetaDataByViewer` サービス
 
 ---
 
@@ -105,28 +82,25 @@ graph TB
 ```mermaid
 sequenceDiagram
     participant Client
-    participant Handler as GET slash page slash shared
-    participant ValidateSL as validateShareLink
-    participant ShareLink as ShareLink Model
+    participant RejectLSD as rejectLinkSharingDisabled
+    participant CertifySP as certifySharedPage
+    participant Handler as handler
     participant FPAMDBV as findPageAndMetaDataByViewer
     participant Page as Page Model
 
-    Client->>Handler: shareLinkId pageId
-    Handler->>Handler: validate params MongoId
-    Handler->>Handler: check disableLinkSharing config
+    Client->>RejectLSD: shareLinkId pageId
     alt disableLinkSharing = true
-        Handler-->>Client: 403 link-sharing-disabled
+        RejectLSD-->>Client: 403 link-sharing-disabled
+    end
+    RejectLSD->>CertifySP: next
+    CertifySP->>CertifySP: ShareLink.findOne id relatedPage
+    alt valid and not expired
+        CertifySP->>CertifySP: req.isSharedPage = true
     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
+    CertifySP->>Handler: next
+    alt req.isSharedPage is falsy
+        Handler-->>Client: 404 share-link-invalid
     end
-    ValidateSL-->>Handler: ShareLinkDocument
     Handler->>FPAMDBV: pageId isSharedPage=true
     FPAMDBV->>Page: findOne _id
     FPAMDBV-->>Handler: pageWithMeta
@@ -135,32 +109,31 @@ sequenceDiagram
 ```
 
 フロー上の重要決定事項:
-- `disableLinkSharing` チェックはバリデーション前のファーストゲートとして配置し、DB アクセスを不要にする
-- `validateShareLink` は `findOne({ _id, relatedPage })` の単一クエリで存在確認と page 照合を同時実施(旧実装の二重クエリを解消)
+- `rejectLinkSharingDisabled` を `certifySharedPage` の前段に配置し、設定無効時は DB アクセスをスキップする
+- `certifySharedPage` は既存ミドルウェアをそのまま再利用。`req.isSharedPage` フラグで有効性を伝達する
 - 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 バリデーション |
+| Requirement | Summary | Components | Flows |
+|-------------|---------|------------|-------|
+| 1.1 | 専用エンドポイントの提供 | `getPageByShareLinkHandlerFactory` | リクエストフロー全体 |
+| 1.2 | 認証ミドルウェアからの独立 | `getPageByShareLinkHandlerFactory` | `accessTokenParser`・`loginRequired` を使用しない |
+| 1.3 | `shareLinkId` と `pageId` 必須パラメータ | validator 定義 | params バリデーション |
+| 1.4 | 既存と同一レスポンス構造 | `respondWithSinglePage` | respondWithSinglePage |
+| 2.1–2.2 | ShareLink 存在確認・relatedPage 照合 | `certifySharedPage` | certifySharedPage 内部 |
+| 2.3–2.4 | 不一致・期限切れ時エラー | Handler(`!req.isSharedPage` チェック) | 404 share-link-invalid |
+| 2.5 | `disableLinkSharing` 時 403 | `rejectLinkSharingDisabled` | config チェック |
+| 3.1 | 最新リビジョン付きページ返却 | `findPageAndMetaDataByViewer`, `respondWithSinglePage` | FPAMDBV + respond |
+| 3.2–3.3 | isMovable 等 false・bookmarkCount 0 | `findPageAndMetaDataByViewer` | FPAMDBV 内の分岐 |
+| 3.4 | ページ未存在時 404 | `respondWithSinglePage` | not-found メタ |
+| 4.1–4.3 | 認証不要 | `getPageByShareLinkHandlerFactory` | 認証ミドルウェアなし |
+| 5.1 | `findPageAndMetaDataByViewer` 再利用 | `getPageByShareLinkHandlerFactory` | FPAMDBV 呼び出し |
+| 5.2 | バリデーション共通化 | `certifySharedPage` 再利用 | ミドルウェアチェーン |
+| 5.3 | シリアライズロジック非複製 | `respondWithSinglePage` | 抽出ユーティリティ |
+| 5.4 | `pageId` バリデーター共有 | express-validator | params バリデーション |
 
 ---
 
@@ -168,61 +141,26 @@ sequenceDiagram
 
 ### コンポーネント一覧
 
-| 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 |
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies |
+|-----------|--------------|--------|--------------|------------------|
+| `rejectLinkSharingDisabled` | Middleware | `disableLinkSharing` 設定チェック | 2.5 | configManager (P0) |
+| `respondWithSinglePage` | Route Utility | ページデータ → API レスポンス変換 | 1.4, 3.1, 3.2, 3.3, 3.4 | ApiV3Response (P0) |
+| `getPageByShareLinkHandlerFactory` | Route Handler | `GET /page/shared` エンドポイント実装 | 全要件 | certifySharedPage (P0), findPageAndMetaDataByViewer (P0), respondWithSinglePage (P0) |
+| `useFetchCurrentPage` update | Client State | 新エンドポイントへのクライアント移行 | 1.1, 1.3 | apiv3Get (P0) |
 
 ---
 
 ### Server Layer
 
-#### validateShareLink
+#### rejectLinkSharingDisabled
 
 | 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 [ ]
+| File | `apps/app/src/server/middlewares/reject-link-sharing-disabled.ts` |
+| Intent | `security:disableLinkSharing` が有効な場合にリクエストを 403 で拒否するガードミドルウェア |
+| Requirements | 2.5 |
 
-##### 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: 型付き結果を使用することで呼び出し元でのステータスコードマッピングが明示的になる
+**Implementation**: `configManager.getConfig('security:disableLinkSharing')` を確認し、`true` の場合は `res.apiv3Err(ErrorV3('Link sharing is disabled', 'link-sharing-disabled'), 403)` を返す。それ以外は `next()` を呼ぶ。
 
 ---
 
@@ -230,6 +168,7 @@ export async function validateShareLink(
 
 | Field | Detail |
 |-------|--------|
+| File | `apps/app/src/server/routes/apiv3/page/respond-with-single-page.ts` |
 | Intent | ページデータと meta 情報を `ApiV3Response` 形式に変換して返す共有ユーティリティ |
 | Requirements | 1.4, 3.1, 3.2, 3.3, 3.4 |
 
@@ -239,114 +178,42 @@ export async function validateShareLink(
 - ページが存在する場合の `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 (
+export async function respondWithSinglePage(
   res: ApiV3Response,
   pageWithMeta:
     | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
     | IDataWithMeta<null, IPageNotFoundInfo>,
-  opts: RespondWithSinglePageOpts,
+  options?: RespondWithSinglePageOptions,
 ): 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 |
 |-------|--------|
+| File | `apps/app/src/server/routes/apiv3/page/get-page-by-share-link.ts` |
 | 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**:
+**Middleware Execution Order**:
 1. `query('shareLinkId').isMongoId()` バリデーター(必須)
 2. `query('pageId').isMongoId()` バリデーター(必須)
 3. `apiV3FormValidator`
-4. async handler:
-   - `disableLinkSharing` チェック → 403
-   - `validateShareLink(shareLinkId, pageId)` → 結果に応じて 403/404
+4. `rejectLinkSharingDisabled` — `disableLinkSharing` 設定チェック → 403
+5. `certifySharedPage` — ShareLink 存在確認・期限チェック → `req.isSharedPage = true`
+6. async handler:
+   - `!req.isSharedPage` → 404 `share-link-invalid`
    - `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()` をそのまま流用してはならない。
+##### API Contract
 
-**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 メタ情報返却)に備えて保持可能
+| Method | Endpoint | Request | Response | Errors |
+|--------|----------|---------|----------|--------|
+| GET | `/_api/v3/page/shared` | `{ shareLinkId: MongoId, pageId: MongoId }` (query) | `{ page: IPagePopulatedToShowRevision, meta: IPageInfo }` | 400, 403, 404, 500 |
 
 ---
 
@@ -364,10 +231,6 @@ export const getPageByShareLinkHandlerFactory = (
 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
@@ -376,76 +239,36 @@ export const getPageByShareLinkHandlerFactory = (
 
 | Field | Detail |
 |-------|--------|
+| File | `apps/app/src/states/page/use-fetch-current-page.ts` |
 | 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 endpoint = params.shareLinkId != null ? '/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) | ShareLink 未存在・relatedPage 不一致・期限切れ | `share-link-invalid` | 404 |
 | 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
@@ -454,7 +277,7 @@ const { data } = await apiv3Get<FetchedPageResult>(endpoint, params);
 
 ### Security Considerations
 
-- **認証不要エンドポイントのデータ保護**: `validateShareLink` が全ての DB チェックを完了した後にのみ `findPageAndMetaDataByViewer` を呼び出す。バリデーション失敗時はページデータに一切アクセスしない。
-- **`disableLinkSharing` の適用**: 新エンドポイントは既存の未適用バグを修正し、設定が `true` の場合に確実に 403 を返す。
-- **ページ権限のスキップ**: `isSharedPage: true` による `findPageAndMetaDataByViewer` の権限チェックスキップは、ShareLink バリデーション完了後のみ実行されるため、不正アクセスのリスクはない。
+- **認証不要エンドポイントのデータ保護**: `certifySharedPage` が ShareLink の DB チェックを完了し `req.isSharedPage` をセットした後にのみ `findPageAndMetaDataByViewer` を呼び出す。バリデーション失敗時はページデータに一切アクセスしない。
+- **`disableLinkSharing` の適用**: `rejectLinkSharingDisabled` ミドルウェアを `certifySharedPage` の前段に配置し、設定が `true` の場合に確実に 403 を返す。DB アクセスも不要。
+- **ページ権限のスキップ**: `isSharedPage: true` による `findPageAndMetaDataByViewer` の権限チェックスキップは、`certifySharedPage` による ShareLink バリデーション完了後のみ実行されるため、不正アクセスのリスクはない。
 - **入力サニタイズ**: `shareLinkId` と `pageId` は `express-validator` で MongoId 形式を強制し、NoSQL インジェクションを防止する。

+ 8 - 10
.kiro/specs/sharelink-page-api/requirements.md

@@ -6,7 +6,7 @@
 
 現行の `/_api/v3/page` ルートは、通常の認証アクセスと share link アクセスの両方を単一ルートで処理している。ミドルウェアチェーン(`accessTokenParser → certifySharedPage → loginRequired`)と handler 内の条件分岐によって混在しており、コードの可読性と保守性に課題がある。
 
-本機能では share link アクセス専用のエンドポイントを分離することで、責務を明確化する。同時に、既存のサービス関数(`findPageAndMetaDataByViewer` 等)を最大限に再利用し、ロジックの重複を最小化することを設計原則とする。
+本機能では share link アクセス専用のエンドポイントを分離することで、責務を明確化する。同時に、既存のミドルウェア・サービス関数を最大限に再利用し、ロジックの重複を最小化することを設計原則とする。
 
 ---
 
@@ -27,15 +27,13 @@
 
 ### Requirement 2: シェアリンクの検証
 
-**Objective:** As a GROWI システム, I want strict share link validation before returning any page data, so that 無効・期限切れのリンクからページデータが漏洩しない。
+**Objective:** As a GROWI システム, I want 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.
+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 and its `relatedPage` field matches the specified `pageId`.
+2. If the ShareLink document does not exist, its `relatedPage` does not match `pageId`, or the ShareLink has expired, the Share Link Page API shall return a 404 error response without exposing any page data.
+3. 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.
 
 ---
 
@@ -66,11 +64,11 @@
 
 ### Requirement 5: コード重複の最小化
 
-**Objective:** As a 開発者, I want the share link endpoint to reuse existing service layer code, so that ロジックの重複による保守コストとバグリスクを低減できる。
+**Objective:** As a 開発者, I want the share link endpoint to reuse existing middleware and 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.
+2. The Share Link Page API shall reuse the existing `certifySharedPage` middleware for ShareLink validation, rather than duplicating the validation logic.
+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. The extracted `respondWithSinglePage` utility shall be shared between both endpoints.
 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.