From 4b528f91825cd0f5383e9bea7da1eddee1847d6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 17:05:46 +0000 Subject: [PATCH 1/2] feat(api): add metadata-update and public-content routes for pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Y.js `local` モード廃止の Phase 1 として、以下を新設する。 - `PUT /api/pages/:id`: タイトル等のメタデータだけを更新する純粋な REST 経路。Y.Doc は Hocuspocus が一手に担うので、本ルートでバイト列は受けない。 既存の `applyPagesMetadataUpdate` / `tryPropagateTitleRename` / `emitPageUpdatedIfChanged` を再利用するため、PUT /:id/content と同じ ゲーティング(SSE emit、タイトル伝播)が維持される。 - `GET /api/pages/:id/public-content`: ゲスト / viewer 向けの読み取り専用 本文 API。`page_contents.content_text` だけを返し、Y.Doc バイト列は 含めない。`getNoteRole` で role を解決して private / restricted を 弾く。未ログインの guest は短期 `public, max-age=60` でエッジキャッシュ 可能、ログイン済みは `private, must-revalidate`。 これらは local モードを最終的に削除する後続 Phase の前提となる代替経路。 本 Phase では既存 `GET/PUT /content` には触れず、後続 PR で削除する。 Phase 1 — adds metadata + read-only content routes ahead of retiring the `local` collaboration mode. Editors will continue through Hocuspocus while read-only consumers (guests on public/unlisted notes, viewer-role members, MCP) move onto `public-content`. Existing `GET/PUT /:id/content` is intentionally untouched here; removal happens in a later PR. Test plan: - `cd server/api && bun run test` — 1278 tests pass - `bun run test:run` — full multi-package suite passes - `bun run lint` — 0 errors (warnings unchanged) - `bun run format:check` — clean - `cd server/api && bun run typecheck` — clean --- server/api/src/__tests__/routes/pages.test.ts | 276 ++++++++++++++++++ server/api/src/routes/pages.ts | 158 +++++++++- src/lib/api/apiClient.ts | 38 +++ src/lib/api/types.ts | 41 +++ 4 files changed, 508 insertions(+), 5 deletions(-) diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index cd7f47c1..9958f487 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -712,3 +712,279 @@ describe("PUT /api/pages/:id/content", () => { expect(hasWikiLink).toBe(true); }); }); + +// ── PUT /api/pages/:id (metadata only, post-`local` cleanup) ──────────────── +// +// `local` モード廃止後、タイトル等のメタデータ更新は Y.Doc 経路 (`PUT /content`) +// を介さず、この新エンドポイントで行う。`applyPagesMetadataUpdate` を再利用する +// ため SSE 通知・タイトル伝播のゲーティングは PUT /content と同等に動作する。 +// +// After retiring the `local` collaboration mode, page metadata updates flow +// through this new endpoint instead of riding on the Y.Doc payload. The +// helpers (`applyPagesMetadataUpdate`, `tryPropagateTitleRename`, +// `emitPageUpdatedIfChanged`) are reused so the SSE-emit and title-propagation +// invariants match the legacy `PUT /:id/content` path. +describe("PUT /api/pages/:id (metadata only)", () => { + it("returns 200 and updates the title when body.title differs from current", async () => { + const { app, chains } = createPagesAppWithChains([ + ...pageAccessPrefix(), + // applyPagesMetadataUpdate: SELECT current title + preview + [{ title: "Old Title", contentPreview: null }], + // applyPagesMetadataUpdate: UPDATE pages.returning() + [ + { + id: PAGE_ID, + ownerId: TEST_USER_ID, + noteId: NOTE_ID, + title: "New Title", + contentPreview: null, + updatedAt: new Date("2026-05-16T10:00:00Z"), + isDeleted: false, + }, + ], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ title: "New Title" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + id: PAGE_ID, + title: "New Title", + content_preview: null, + updated_at: "2026-05-16T10:00:00.000Z", + }); + const updateChains = chains.filter((c) => c.startMethod === "update"); + expect(updateChains.length).toBe(1); + }); + + // 同じ値を round-trip した場合は `applyPagesMetadataUpdate` が UPDATE を skip し、 + // `metadataChanged: false` で返す。SSE 通知も走らない。 + // + // Round-tripping the current values must be a no-op: the helper skips the + // UPDATE and returns `metadataChanged: false`, so no SSE broadcast fires. + it("skips the UPDATE when title matches current value (PR #867 invariant)", async () => { + const { app, chains } = createPagesAppWithChains([ + ...pageAccessPrefix(), + [{ title: "Same Title", contentPreview: "Same Preview" }], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ title: "Same Title", content_preview: "Same Preview" }), + }); + + expect(res.status).toBe(200); + const updateChains = chains.filter((c) => c.startMethod === "update"); + expect(updateChains.length).toBe(0); + }); + + it("returns 400 when body has neither title nor content_preview", async () => { + const app = createPagesApp([]); + + const res = await app.request(`/api/pages/${PAGE_ID}`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + }); + + it("returns 401 without auth header", async () => { + const app = createPagesApp([]); + + const res = await app.request(`/api/pages/${PAGE_ID}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "x" }), + }); + + expect(res.status).toBe(401); + }); + + it("returns 404 when the page does not exist (via assertPageEditAccess)", async () => { + // assertPageEditAccess → getPageOwnership → SELECT pages: empty + const app = createPagesApp([[]]); + + const res = await app.request(`/api/pages/${PAGE_ID}`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ title: "x" }), + }); + + expect(res.status).toBe(404); + }); +}); + +// ── GET /api/pages/:id/public-content (read-only for guests / viewers) ───── +// +// `local` モード廃止後、編集者は Hocuspocus 経由で Y.Doc を扱うが、未ログインの +// ゲスト(public / unlisted ノートの読者)や viewer ロールの閲覧者は WebSocket を +// 張らずに `content_text` だけを REST で取得する。本ルートは Y.Doc バイト列を +// 返さないことで、誤って編集セッションを開始できないようにする。 +// +// After retiring the `local` mode, editors flow through Hocuspocus while +// guests (public/unlisted readers) and viewer-role members fetch +// `content_text` via REST without spinning up a WebSocket. The endpoint +// deliberately omits Y.Doc bytes so a read-only consumer cannot start an +// editing session by accident. +describe("GET /api/pages/:id/public-content", () => { + it("returns the rendered text and version for the owner", async () => { + const app = createPagesApp([ + // page row (SELECT pages) + [ + { + id: PAGE_ID, + noteId: NOTE_ID, + title: "Hello", + contentPreview: "preview body", + updatedAt: new Date("2026-05-16T09:00:00Z"), + }, + ], + // getNoteRole → findActiveNoteById (owner short-circuit) + [mockNoteRow()], + // page_contents SELECT + [ + { + contentText: "Hello world", + version: 7, + updatedAt: new Date("2026-05-16T10:00:00Z"), + }, + ], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/public-content`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + id: PAGE_ID, + title: "Hello", + content_text: "Hello world", + content_preview: "preview body", + version: 7, + updated_at: "2026-05-16T10:00:00.000Z", + }); + expect(res.headers.get("cache-control")).toBe("private, must-revalidate"); + }); + + it("returns content_text=null and version=0 when page_contents row is missing", async () => { + const app = createPagesApp([ + [ + { + id: PAGE_ID, + noteId: NOTE_ID, + title: "Blank", + contentPreview: null, + updatedAt: new Date("2026-05-16T11:00:00Z"), + }, + ], + [mockNoteRow()], + [], // page_contents not yet inserted + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/public-content`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + id: PAGE_ID, + content_text: null, + version: 0, + // falls back to pages.updated_at when page_contents is missing + updated_at: "2026-05-16T11:00:00.000Z", + }); + }); + + it("returns 404 when the page row is missing or already soft-deleted", async () => { + const app = createPagesApp([[]]); + + const res = await app.request(`/api/pages/${PAGE_ID}/public-content`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(404); + }); + + it("returns 403 when caller has no role on the owning private note", async () => { + const privateNote = { ...mockNoteRow(), ownerId: "other-user", visibility: "private" as const }; + const app = createPagesApp([ + [ + { + id: PAGE_ID, + noteId: NOTE_ID, + title: "Secret", + contentPreview: null, + updatedAt: new Date(), + }, + ], + [privateNote], // note row + [], // member SELECT empty + [], // domain SELECT empty + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/public-content`, { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(403); + }); + + // 未ログインゲストでも public ノート配下のページは閲覧できる。 + // `getNoteRole` は visibility=public に対して role=guest を返す。 + // エッジでの短期キャッシュを許す Cache-Control を確認する。 + // + // Unauthenticated guests can read pages on public notes. `getNoteRole` + // resolves them as `guest` via the visibility branch. The route returns a + // short edge-cacheable `Cache-Control` for that case. + it("allows guest access to a public-note page with edge-cacheable Cache-Control", async () => { + const publicNote = { ...mockNoteRow(), ownerId: "other-user", visibility: "public" as const }; + const app = createPagesApp([ + [ + { + id: PAGE_ID, + noteId: NOTE_ID, + title: "Public", + contentPreview: "edge ok", + updatedAt: new Date("2026-05-16T08:00:00Z"), + }, + ], + [publicNote], + [ + { + contentText: "Public body", + version: 2, + updatedAt: new Date("2026-05-16T09:00:00Z"), + }, + ], + ]); + + const res = await app.request(`/api/pages/${PAGE_ID}/public-content`, { + method: "GET", + // no auth header → guest path + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + id: PAGE_ID, + content_text: "Public body", + version: 2, + }); + expect(res.headers.get("cache-control")).toBe("public, max-age=60, must-revalidate"); + }); +}); diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index 29248cd1..3773232c 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -1,15 +1,18 @@ /** * /api/pages — ページ CRUD + コンテンツ管理 * - * GET /api/pages — 後方互換のページ一覧(Issue #823 以降は `Deprecation: true`)。 + * GET /api/pages — 後方互換のページ一覧(Issue #823 以降は `Deprecation: true`)。 * 新規実装は `GET /api/notes/me` → `GET /api/notes/:noteId/pages` を推奨。 * — Legacy page listing (sends `Deprecation: true` after issue #823). * Prefer `GET /api/notes/me` then `GET /api/notes/:noteId/pages` for new clients. - * GET /api/pages/:id/content — Y.Doc コンテンツ取得(`page_contents` 行が未作成の空ページは 200 + 空 ydoc) + * GET /api/pages/:id/content — Y.Doc コンテンツ取得(`page_contents` 行が未作成の空ページは 200 + 空 ydoc) * — Retrieve Y.Doc content (200 + empty `ydoc_state` when no `page_contents` row). - * PUT /api/pages/:id/content — Y.Doc コンテンツ更新 (楽観的ロック) / Update with optimistic locking - * POST /api/pages — 新規ページ作成 / Create page - * DELETE /api/pages/:id — ページ論理削除 / Soft-delete page + * GET /api/pages/:id/public-content — 読み取り専用のページ本文(ゲスト・viewer 用 / Y.Doc を返さない) + * — Read-only page text for guests / viewer-only callers (no Y.Doc bytes). + * PUT /api/pages/:id/content — Y.Doc コンテンツ更新 (楽観的ロック) / Update with optimistic locking + * PUT /api/pages/:id — ページメタデータ(タイトル等)更新 / Update page metadata (title, preview) + * POST /api/pages — 新規ページ作成 / Create page + * DELETE /api/pages/:id — ページ論理削除 / Soft-delete page */ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; @@ -874,4 +877,149 @@ app.delete("/:id", authRequired, async (c) => { return c.json({ id: pageId, deleted: true }); }); +// ── PUT /pages/:id (metadata only) ────────────────────────────────────────── +/** + * `PUT /api/pages/:pageId` — ページのメタデータ(タイトル / `content_preview`)だけを + * 更新する。Y.Doc 本体は Hocuspocus WebSocket 経由で更新されるため、本ルートで + * バイト列を受け付けることはない。 + * + * `local` コラボレーションモード廃止(REST 経由で Y.Doc を同期する経路の削除)に + * 伴い、タイトル変更を REST から行いたいクライアント向けに独立した経路として + * 用意した。既存の `applyPagesMetadataUpdate` / `tryPropagateTitleRename` / + * `emitPageUpdatedIfChanged` を再利用するため、`PUT /:id/content` 経路と同じ整合性 + * (タイトル伝播・SSE 通知のゲーティング)が保たれる。 + * + * `PUT /api/pages/:pageId` updates page metadata only (title and + * content_preview). The Y.Doc payload is owned by Hocuspocus and is never + * accepted here. + * + * Introduced when the `local` collaboration mode was retired so callers that + * need to rename a page via REST have a stable endpoint. Reuses + * `applyPagesMetadataUpdate`, `tryPropagateTitleRename`, and + * `emitPageUpdatedIfChanged` so the title-propagation and SSE-emit invariants + * stay identical to the legacy `PUT /:id/content` path. + * + * @returns 200 + `{ id, title, content_preview, updated_at }` on success. + * 400 when the body is empty (neither `title` nor `content_preview`). + * 403 / 404 from `assertPageEditAccess` when the caller cannot edit. + */ +app.put("/:id", authRequired, async (c) => { + const pageId = c.req.param("id"); + const userId = c.get("userId"); + const db = c.get("db"); + + const body = await c.req.json<{ + title?: string; + content_preview?: string; + }>(); + + if (body.title === undefined && body.content_preview === undefined) { + throw new HTTPException(400, { + message: "At least one of `title` or `content_preview` is required", + }); + } + + await assertPageEditAccess(db, pageId, userId); + + const { renamed, metadataChanged, updatedRow } = await applyPagesMetadataUpdate(db, pageId, body); + + if (renamed) { + tryPropagateTitleRename(db, pageId, renamed.oldTitle, renamed.newTitle); + } + emitPageUpdatedIfChanged(metadataChanged, updatedRow); + + return c.json({ + id: pageId, + title: updatedRow?.title ?? null, + content_preview: updatedRow?.contentPreview ?? null, + updated_at: updatedRow?.updatedAt?.toISOString() ?? null, + }); +}); + +// ── GET /pages/:id/public-content (read-only for guests / viewers) ────────── +/** + * `GET /api/pages/:pageId/public-content` — 読み取り専用のページ本文 API。 + * `page_contents.content_text` と派生情報のみ返し、Y.Doc バイト列は返さない。 + * + * `local` モード廃止後、編集者は Hocuspocus WebSocket 経由でページを開くが、 + * 未ログインの guest(public / unlisted ノートを覗いている読者)や viewer ロールの + * メンバーは WebSocket 接続を張らずに REST で本文だけ読みたい。本ルートはその + * 経路を提供する。Y.Doc バイト列を返さないため、編集セッションは絶対に始まらない。 + * + * 認証は `authOptional`。所属ノートに対する `getNoteRole` で role を解決し、 + * `null`(private / restricted ノートを未認可で要求した等)の場合は 403 を返す。 + * + * `GET /api/pages/:pageId/public-content` returns the rendered plain text of + * a page without exposing the underlying Y.Doc bytes. Used by read-only + * viewers — anonymous guests on public/unlisted notes and signed-in viewer + * members — who do not need to participate in the realtime editing session. + * + * `authOptional` so guests on public/unlisted notes can hit it. `getNoteRole` + * gates private/restricted notes by returning 403 when no role is resolved. + * + * @returns 200 + `{ id, title, content_text, content_preview, version, updated_at }`. + * 404 when the page row is missing or already soft-deleted. + * 403 when no role is resolved on the owning note. + */ +app.get("/:id/public-content", authOptional, async (c) => { + const pageId = c.req.param("id"); + const userId = c.get("userId"); + const userEmail = c.get("userEmail"); + const db = c.get("db"); + + const rows = await db + .select({ + id: pages.id, + noteId: pages.noteId, + title: pages.title, + contentPreview: pages.contentPreview, + updatedAt: pages.updatedAt, + }) + .from(pages) + .where(and(eq(pages.id, pageId), eq(pages.isDeleted, false))) + .limit(1); + + const row = rows[0]; + if (!row) throw new HTTPException(404, { message: "Page not found" }); + + // 所属ノートに対する role が解決しない呼び出し元はアクセス不可。owner / + // editor / viewer / guest のいずれかが付けば 200 で返す(`GET /api/pages/:id` + // と同じ判定)。 + // The caller must hold some role on the owning note. The visibility check + // inside `getNoteRole` already gates anonymous access to private notes + // correctly (mirrors `GET /api/pages/:id`). + const { role } = await getNoteRole(row.noteId, userId, userEmail, db); + if (!role) throw new HTTPException(403, { message: "Forbidden" }); + + const contentRows = await db + .select({ + contentText: pageContents.contentText, + version: pageContents.version, + updatedAt: pageContents.updatedAt, + }) + .from(pageContents) + .where(eq(pageContents.pageId, pageId)) + .limit(1); + + const content = contentRows[0]; + + // 未ログインゲストはエッジで短期キャッシュ可能(同じ public ノートを多数が + // 開く想定)、ログイン済みは個人スコープに留める。 + // Guests can be cached briefly at the edge (many readers on the same public + // note); logged-in viewers stay private. + c.header( + "Cache-Control", + userId ? "private, must-revalidate" : "public, max-age=60, must-revalidate", + ); + + return c.json({ + id: pageId, + title: row.title ?? null, + content_text: content?.contentText ?? null, + content_preview: row.contentPreview ?? null, + version: content?.version ?? 0, + updated_at: (content?.updatedAt ?? row.updatedAt).toISOString(), + }); +}); + export default app; diff --git a/src/lib/api/apiClient.ts b/src/lib/api/apiClient.ts index 3c5d9a10..415ea89f 100644 --- a/src/lib/api/apiClient.ts +++ b/src/lib/api/apiClient.ts @@ -9,6 +9,9 @@ import type { PostSyncPagesResponse, PageContentResponse, PutPageContentBody, + PagePublicContentResponse, + UpdatePageMetadataBody, + UpdatePageMetadataResponse, CreatePageBody, CreatePageResponse, CopyPersonalPageToNoteResponse, @@ -326,6 +329,41 @@ export function createApiClient(options?: Partial) { }); }, + /** + * `PUT /api/pages/:id` — タイトル等のメタデータだけを更新する。 + * `local` モード廃止に伴い、Y.Doc を扱わない純粋な REST メタデータ更新経路として + * 追加された。サインインユーザーの編集権限が前提。 + * + * `PUT /api/pages/:id` updates page metadata only (title, + * content_preview). Introduced when the `local` collaboration mode was + * retired so callers that rename via REST have a stable endpoint. + */ + async updatePageMetadata( + pageId: string, + body: UpdatePageMetadataBody, + ): Promise { + return req("PUT", `/api/pages/${encodeURIComponent(pageId)}`, { + body, + }); + }, + + /** + * `GET /api/pages/:id/public-content` — 読み取り専用のページ本文を取得する。 + * `Y.Doc` バイト列は返さず、`content_text` と派生情報だけを返す。ゲスト + * (public / unlisted ノートの未認証読者)や viewer ロールの読み取り + * 専用 UI から使う。 + * + * `GET /api/pages/:id/public-content` returns the rendered plain text of a + * page. Use this from read-only views (guests on public/unlisted notes or + * signed-in viewer-role members) instead of opening a Hocuspocus session. + */ + async getPagePublicContent(pageId: string): Promise { + return reqOptionalAuth( + "GET", + `/api/pages/${encodeURIComponent(pageId)}/public-content`, + ); + }, + /** POST /api/pages — create page. Returns created page (snake_case). */ async createPage(body: CreatePageBody = {}): Promise { return req("POST", "/api/pages", { body }); diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index fc270caf..2e783724 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -121,6 +121,47 @@ export interface PutPageContentBody { expected_version?: number; } +/** + * `PUT /api/pages/:id` ボディ。タイトル等のメタデータだけを更新する。 + * Y.Doc バイト列は Hocuspocus 経由で扱うため含めない。 + * + * `PUT /api/pages/:id` request body. Updates page metadata only (title and + * content_preview). Y.Doc payloads flow through Hocuspocus, not this route. + */ +export interface UpdatePageMetadataBody { + title?: string; + content_preview?: string; +} + +/** + * `PUT /api/pages/:id` レスポンス。 + * + * `PUT /api/pages/:id` response. + */ +export interface UpdatePageMetadataResponse { + id: string; + title: string | null; + content_preview: string | null; + updated_at: string | null; +} + +/** + * `GET /api/pages/:id/public-content` レスポンス。 + * 読み取り専用ビュー(ゲスト / viewer)が `content_text` だけを取得するために使う。 + * + * `GET /api/pages/:id/public-content` response. Used by read-only viewers + * (guests on public / unlisted notes, viewer-role members) to fetch the + * rendered plain text without participating in the Hocuspocus session. + */ +export interface PagePublicContentResponse { + id: string; + title: string | null; + content_text: string | null; + content_preview: string | null; + version: number; + updated_at: string; +} + /** POST /api/pages body. */ export interface CreatePageBody { id?: string; From c4f4c16801de1e5014db590f9ec2241998660194 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 17:37:10 +0000 Subject: [PATCH 2/2] fix(api): preserve current metadata in no-op PUT /pages/:id responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #888 のレビューフィードバックに対応。 - gemini-code-assist / chatgpt-codex / coderabbitai 全員が指摘した 「no-op PUT で title/content_preview/updated_at が null で返り、 クライアントの正規キャッシュを破壊しうる」問題を修正。 `applyPagesMetadataUpdate` が UPDATE を skip して `updatedRow=null` を 返す場合に限り、現在の行を追加 SELECT してその値を返す。 - coderabbitai 指摘の型バリデーション欠落を修正。`body.title` / `body.content_preview` が非文字列の場合に、ヘルパー内の `.trim()` で 500 になる前にルート境界で 400 を返す。 - gemini-code-assist 指摘に従い `UpdatePageMetadataResponse.updated_at` を non-nullable に修正 (`pages.updated_at` はスキーマ上 notNull)。 - テストを拡張: no-op 経路がレスポンスボディに現在値を含めて返すこと、 および title/content_preview の非文字列入力で 400 を返すことを検証。 Test plan: - `cd server/api && bun run test` — 1280 tests pass (+2 new) - `bun run lint` / `bun run format:check` — clean - `cd server/api && bun run typecheck` — clean - root frontend typecheck — clean Refs: PR #888 review threads (HIGH/Major priority) --- server/api/src/__tests__/routes/pages.test.ts | 56 ++++++++++++++++++- server/api/src/routes/pages.ts | 56 ++++++++++++++++++- src/lib/api/types.ts | 10 +++- 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index 9958f487..e5335a16 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -763,14 +763,29 @@ describe("PUT /api/pages/:id (metadata only)", () => { }); // 同じ値を round-trip した場合は `applyPagesMetadataUpdate` が UPDATE を skip し、 - // `metadataChanged: false` で返す。SSE 通知も走らない。 + // `metadataChanged: false` で返す。SSE 通知も走らない。さらに、レスポンスは + // 現在値 (DB に保存された値) を必ず含み、`null` で返してクライアントの + // キャッシュを壊さないこと (PR #888 レビューフィードバック)。 // // Round-tripping the current values must be a no-op: the helper skips the // UPDATE and returns `metadataChanged: false`, so no SSE broadcast fires. - it("skips the UPDATE when title matches current value (PR #867 invariant)", async () => { + // The response must echo the current persisted values rather than nulls so + // clients trusting the response do not clobber valid cache entries + // (PR #888 review feedback from gemini-code-assist, codex, coderabbitai). + it("skips the UPDATE but echoes current metadata when title matches (PR #888 review)", async () => { + const sameUpdatedAt = new Date("2026-05-16T11:00:00Z"); const { app, chains } = createPagesAppWithChains([ ...pageAccessPrefix(), + // applyPagesMetadataUpdate の現在値取得 SELECT [{ title: "Same Title", contentPreview: "Same Preview" }], + // no-op 経路の現在値再取得 SELECT (`updated_at` を含む) + [ + { + title: "Same Title", + contentPreview: "Same Preview", + updatedAt: sameUpdatedAt, + }, + ], ]); const res = await app.request(`/api/pages/${PAGE_ID}`, { @@ -780,6 +795,13 @@ describe("PUT /api/pages/:id (metadata only)", () => { }); expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + id: PAGE_ID, + title: "Same Title", + content_preview: "Same Preview", + updated_at: sameUpdatedAt.toISOString(), + }); const updateChains = chains.filter((c) => c.startMethod === "update"); expect(updateChains.length).toBe(0); }); @@ -796,6 +818,36 @@ describe("PUT /api/pages/:id (metadata only)", () => { expect(res.status).toBe(400); }); + // 非文字列が混ざった場合に `applyPagesMetadataUpdate` の `.trim()` で 500 に + // 落ちないこと (PR #888 review by coderabbitai)。境界で 400 に倒す。 + // + // Malformed payloads with non-string fields must be rejected at the route + // boundary (400) instead of crashing inside the metadata helper's + // `.trim()` call (PR #888 review by coderabbitai). + it("returns 400 when title is not a string", async () => { + const app = createPagesApp([]); + + const res = await app.request(`/api/pages/${PAGE_ID}`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ title: 123 }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 when content_preview is not a string", async () => { + const app = createPagesApp([]); + + const res = await app.request(`/api/pages/${PAGE_ID}`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ content_preview: 42 }), + }); + + expect(res.status).toBe(400); + }); + it("returns 401 without auth header", async () => { const app = createPagesApp([]); diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index 3773232c..7d5d1063 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -913,6 +913,18 @@ app.put("/:id", authRequired, async (c) => { content_preview?: string; }>(); + // 型バリデーション: ヘルパー (`applyPagesMetadataUpdate`) は `body.title.trim()` + // を呼ぶので、文字列以外が混ざると runtime 500 になる。境界で 400 に倒す。 + // Validate field types so malformed payloads (e.g. `{ "title": 123 }`) + // are surfaced as 400 instead of crashing inside `applyPagesMetadataUpdate`'s + // `.trim()` call. + if (body.title !== undefined && typeof body.title !== "string") { + throw new HTTPException(400, { message: "`title` must be a string" }); + } + if (body.content_preview !== undefined && typeof body.content_preview !== "string") { + throw new HTTPException(400, { message: "`content_preview` must be a string" }); + } + if (body.title === undefined && body.content_preview === undefined) { throw new HTTPException(400, { message: "At least one of `title` or `content_preview` is required", @@ -928,11 +940,49 @@ app.put("/:id", authRequired, async (c) => { } emitPageUpdatedIfChanged(metadataChanged, updatedRow); + // no-op (現在値と同値を送信) のときは UPDATE がスキップされて `updatedRow` が + // null になる。クライアントのキャッシュを null で上書きしないよう、現在値を + // SELECT して返す。追加 SELECT は no-op パスのみ発生する。 + // No-op saves (request body echoes the current values) skip the UPDATE and + // leave `updatedRow` null. To avoid responding with null fields that would + // clobber a client cache, fall back to fetching the current row. The extra + // SELECT only fires on the no-op path. + let title: string | null; + let contentPreview: string | null; + let updatedAt: Date; + if (updatedRow) { + title = updatedRow.title; + contentPreview = updatedRow.contentPreview; + updatedAt = updatedRow.updatedAt; + } else { + const current = await db + .select({ + title: pages.title, + contentPreview: pages.contentPreview, + updatedAt: pages.updatedAt, + }) + .from(pages) + .where(and(eq(pages.id, pageId), eq(pages.isDeleted, false))) + .limit(1); + const row = current[0]; + if (!row) { + // `assertPageEditAccess` の直後に同じ id が論理削除される競合は通常 + // 起きないが、起きた場合はクライアントが再同期できるよう 404 を返す。 + // `assertPageEditAccess` already proved the row exists; reaching here + // means a concurrent delete raced us. Surface 404 so the client + // resyncs cleanly instead of seeing a half-formed response. + throw new HTTPException(404, { message: "Page not found" }); + } + title = row.title; + contentPreview = row.contentPreview; + updatedAt = row.updatedAt; + } + return c.json({ id: pageId, - title: updatedRow?.title ?? null, - content_preview: updatedRow?.contentPreview ?? null, - updated_at: updatedRow?.updatedAt?.toISOString() ?? null, + title, + content_preview: contentPreview, + updated_at: updatedAt.toISOString(), }); }); diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 2e783724..de5684c5 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -134,15 +134,19 @@ export interface UpdatePageMetadataBody { } /** - * `PUT /api/pages/:id` レスポンス。 + * `PUT /api/pages/:id` レスポンス。`title` / `content_preview` はスキーマ上 nullable + * のままだが、`updated_at` は `pages.updated_at` (`notNull`) を反映するため必ず + * 文字列。 * - * `PUT /api/pages/:id` response. + * `PUT /api/pages/:id` response. `title` / `content_preview` stay nullable to + * mirror the schema, while `updated_at` is always present because + * `pages.updated_at` is `notNull` in the DB. */ export interface UpdatePageMetadataResponse { id: string; title: string | null; content_preview: string | null; - updated_at: string | null; + updated_at: string; } /**