diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index cd7f47c1..e5335a16 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -712,3 +712,331 @@ 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 通知も走らない。さらに、レスポンスは + // 現在値 (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. + // 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}`, { + method: "PUT", + headers: authHeaders(), + body: JSON.stringify({ title: "Same Title", content_preview: "Same Preview" }), + }); + + 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); + }); + + 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); + }); + + // 非文字列が混ざった場合に `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([]); + + 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..7d5d1063 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,199 @@ 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; + }>(); + + // 型バリデーション: ヘルパー (`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", + }); + } + + 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); + + // 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, + content_preview: contentPreview, + updated_at: updatedAt.toISOString(), + }); +}); + +// ── 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..de5684c5 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -121,6 +121,51 @@ 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` レスポンス。`title` / `content_preview` はスキーマ上 nullable + * のままだが、`updated_at` は `pages.updated_at` (`notNull`) を反映するため必ず + * 文字列。 + * + * `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; +} + +/** + * `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;