From 9403454b501fa3c2466ed0a3a5d391892d804b72 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:38:14 +0000 Subject: [PATCH 1/4] Email a comment notification when someone comments on a doc Sibling of the share notification. On a new comment, best-effort email the people who should hear about it, each with their own single-use 7-day share-kind login link to /d/:slug: - top-level comment (parent_id null): the document owner. - reply: the owner plus the thread's other participants, deduped. - always exclude the comment's author. Two layouts inside the locked man-page email style: top-level shows the anchored passage (when present) above the body; replies show minimal parent context. The footer's "why am I getting this" line keys off owner vs participant. Suppression uses a dedicated cmt-notify:addr daily cap so comment volume never burns the recipient's login/share email budget. The send is wired into POST /comments after the comment commits and surfaces a notified count on the 201 response; a send failure rolls back only the just-minted token row and never fails the request. Co-Authored-By: Claude Opus 4.7 --- app/api/v1/docs/[slug]/comments/route.ts | 9 +- lib/auth/audit.ts | 1 + lib/auth/email.ts | 158 ++++++++ lib/docs/comment-notify.test.ts | 436 +++++++++++++++++++++++ lib/docs/comment-notify.ts | 214 +++++++++++ lib/docs/schemas.ts | 12 +- lib/openapi/generated-spec.ts | 2 +- lib/openapi/generated.yaml | 7 + 8 files changed, 835 insertions(+), 4 deletions(-) create mode 100644 lib/docs/comment-notify.test.ts create mode 100644 lib/docs/comment-notify.ts diff --git a/app/api/v1/docs/[slug]/comments/route.ts b/app/api/v1/docs/[slug]/comments/route.ts index 478b4a0..23c39e1 100644 --- a/app/api/v1/docs/[slug]/comments/route.ts +++ b/app/api/v1/docs/[slug]/comments/route.ts @@ -5,6 +5,7 @@ import { CreateCommentBody, commentBodyBadRequest } from "@/lib/docs/schemas"; import { findBySlug } from "@/lib/docs/store"; import { canView } from "@/lib/docs/access"; import { resolveAccess, type DocAccess } from "@/lib/docs/grants"; +import { sendCommentNotification } from "@/lib/docs/comment-notify"; import { checkLimits } from "@/lib/auth/ratelimit"; import { parseAnchor, type TextAnchor } from "@/lib/docs/anchor"; import { @@ -134,7 +135,13 @@ export async function POST(req: Request, ctx: Ctx): Promise { return apiError(422, "bad_parent", "parent_id must reference a live top-level comment on this document."); } - return json({ comment: commentView(result.comment, []) }, 201); + // Comment notification. Best-effort, like the share notification: the comment + // is already committed, so a send failure or tripped cap never fails the + // request. Notifies the owner (top-level) or the owner + thread participants + // (replies), minus the author. + const { notified } = await sendCommentNotification({ req, doc, comment: result.comment }); + + return json({ comment: commentView(result.comment, []), notified }, 201); } // GET /api/v1/docs/:slug/comments — the complete all-threads picture. diff --git a/lib/auth/audit.ts b/lib/auth/audit.ts index d5eb288..6af9e49 100644 --- a/lib/auth/audit.ts +++ b/lib/auth/audit.ts @@ -18,6 +18,7 @@ export type AuditEvent = | "login_link.requested" | "session.created" | "share_notification.sent" + | "comment_notification.sent" | "rate_limit.tripped"; export async function audit( diff --git a/lib/auth/email.ts b/lib/auth/email.ts index 5ac819d..416bff1 100644 --- a/lib/auth/email.ts +++ b/lib/auth/email.ts @@ -248,3 +248,161 @@ export async function sendShareEmail(opts: { } return data?.id ?? null; } + +// --- Comment notification (sibling of the share notification) --- +// +// Sent when someone comments on a doc. Recipients are the owner (top-level) and +// the owner + thread participants (replies), minus the comment's author. Each +// carries a single 7-day share-kind login link landing on /d/:slug (same link +// mechanics as the share email), so the recipient signs in and reads the thread. +// Two layouts, both inside the LOCKED Variant B man-page style: +// - top-level → Variant C: optional anchored-passage line, then the body quote. +// - reply → Variant D: minimal parent context, then the reply quote. +// The "why am I getting this" footer line keys off whether the recipient owns +// the doc or is a thread participant. + +const COMMENT_EXPIRY_DAYS = SHARE_EXPIRY_DAYS; + +// Indented quoted block (the comment/reply body) — the reference's grey-rule +// blockquote: a 2px left rule, 12px indent, LEAD type. +function quoteBlock(text: string): string { + return ` +
${esc(text)}
+`; +} + +/** + * Subject line, mirroring `shareSubject`'s shape: + * top-level: ` commented on "" — justhtml.sh` + * reply: `<author> replied on "<title>" — justhtml.sh` + */ +export function commentSubject(authorEmail: string, title: string, isReply: boolean): string { + const verb = isReply ? "replied on" : "commented on"; + return `${authorEmail} ${verb} "${title}" — justhtml.sh`; +} + +// The two flavors of the "why am I getting this" footer sentence. +function whyLine(isOwnerRecipient: boolean): string { + return isOwnerRecipient + ? "You're getting this because you own this document." + : "You're getting this because you're part of this thread."; +} + +type CommentEmailParts = { + authorEmail: string; + title: string; + isReply: boolean; + isOwnerRecipient: boolean; + bodySnippet: string; + anchoredQuote?: string | null; // top-level only: the document passage (anchor.exact) + parentAuthorEmail?: string | null; // reply only + parentSnippet?: string | null; // reply only + link: string; + docUrl: string; +}; + +function commentHtmlBody(opts: CommentEmailParts): string { + const verb = opts.isReply ? "replied on" : "commented on"; + const lead = `<tr><td style="${LEAD}">${esc(opts.authorEmail)} ${verb} <strong>"${esc(opts.title)}"</strong>.</td></tr>`; + + // Context row above the body quote: the anchored passage (top-level, Variant C) + // or the parent snippet (reply, Variant D). Both render as a muted caveat row + // followed by a tight 10px gap, matching the reference. + let context = ""; + if (opts.isReply) { + if (opts.parentSnippet) { + const who = opts.parentAuthorEmail ? esc(opts.parentAuthorEmail) : "an earlier comment"; + context = `${gap(16)}<tr><td style="${CAVEAT}">In reply to ${who}: “${esc(opts.parentSnippet)}”</td></tr> +${gap(10)}`; + } else { + context = gap(16); + } + } else if (opts.anchoredQuote) { + context = `${gap(16)}<tr><td style="${CAVEAT}">On: “${esc(opts.anchoredQuote)}”</td></tr> +${gap(10)}`; + } else { + context = gap(16); + } + + const rows = `${lead} +${context}${quoteBlock(opts.bodySnippet)} +${gap(16)}<tr><td style="${LEAD}"><a href="${esc(opts.link)}" style="${LINK}">Open the document →</a></td></tr> +${gap(16)}<tr><td style="${CAVEAT}">Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days. ${whyLine(opts.isOwnerRecipient)} If it expires, <a href="${esc(opts.docUrl)}" style="color:#666666;">open the document</a> and choose "was this shared with you? sign in".</td></tr>`; + return shell(opts.isReply ? "new reply on justhtml.sh" : "new comment on justhtml.sh", rows); +} + +function commentTextBody(opts: CommentEmailParts): string { + const verb = opts.isReply ? "replied on" : "commented on"; + const lines: string[] = [`${opts.authorEmail} ${verb} "${opts.title}" on justhtml.sh.`, ""]; + + if (opts.isReply) { + if (opts.parentSnippet) { + const who = opts.parentAuthorEmail || "an earlier comment"; + lines.push(`In reply to ${who}: "${opts.parentSnippet}"`, ""); + } + } else if (opts.anchoredQuote) { + lines.push(`On: "${opts.anchoredQuote}"`, ""); + } + + lines.push( + ` ${opts.bodySnippet}`, + "", + "Open the document:", + ` ${opts.link}`, + "", + `Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days.`, + whyLine(opts.isOwnerRecipient), + 'If it expires, open the document directly and choose "was this shared with you? sign in":', + ` ${opts.docUrl}` + ); + return lines.join("\n"); +} + +/** + * Send a comment-notification email. Returns the Resend message id on success, + * or throws on send failure so the caller can roll back the just-minted token + * row (the comment is already committed; a missed email is recoverable via the + * /d/:slug sign-in fallback). + */ +export async function sendCommentEmail(opts: { + to: string; + authorEmail: string; + title: string; + isReply: boolean; + isOwnerRecipient: boolean; + bodySnippet: string; + anchoredQuote?: string | null; // top-level, optional (anchor.exact) + parentAuthorEmail?: string | null; // reply + parentSnippet?: string | null; // reply + link: string; + docUrl: string; // bare https://justhtml.sh/d/:slug — the stale-link recovery target + idempotencyKey: string; +}): Promise<string | null> { + const parts: CommentEmailParts = { + authorEmail: opts.authorEmail, + title: opts.title, + isReply: opts.isReply, + isOwnerRecipient: opts.isOwnerRecipient, + bodySnippet: opts.bodySnippet, + anchoredQuote: opts.anchoredQuote, + parentAuthorEmail: opts.parentAuthorEmail, + parentSnippet: opts.parentSnippet, + link: opts.link, + docUrl: opts.docUrl, + }; + const { data, error } = await resend().emails.send( + { + from: RESEND_FROM, + to: opts.to, + subject: commentSubject(opts.authorEmail, opts.title, opts.isReply), + html: commentHtmlBody(parts), + text: commentTextBody(parts), + tags: [{ name: "flow", value: "comment_notification" }], + }, + { idempotencyKey: opts.idempotencyKey } + ); + if (error) { + throw new Error(`resend send failed: ${error.message ?? String(error)}`); + } + return data?.id ?? null; +} diff --git a/lib/docs/comment-notify.test.ts b/lib/docs/comment-notify.test.ts new file mode 100644 index 0000000..44adb2a --- /dev/null +++ b/lib/docs/comment-notify.test.ts @@ -0,0 +1,436 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { DocRow } from "@/lib/docs/store"; +import type { CommentRow } from "@/lib/docs/comments"; + +// Unit tests for the comment-notification orchestrator. The DB, the email send, +// the rate-limit check, and the audit log are all mocked, so these pin the +// recipient model, the per-recipient cap, the token/idempotency shape, the +// snippet truncation, the send-failure rollback, the footer flavor, and — most +// importantly — that this path NEVER touches EMAIL_SEND_LIMITS (comment volume +// must not burn the owner's login/share email budget). + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + sendCommentEmail: vi.fn(), + checkLimits: vi.fn(), + audit: vi.fn(), + EMAIL_SEND_LIMITS: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ query: mocks.query })); +vi.mock("@/lib/auth/email", () => ({ sendCommentEmail: mocks.sendCommentEmail })); +vi.mock("@/lib/auth/ratelimit", () => ({ + checkLimits: mocks.checkLimits, + // Spy: if the path ever imports/calls this, the assertion below catches it. + EMAIL_SEND_LIMITS: mocks.EMAIL_SEND_LIMITS, +})); +vi.mock("@/lib/auth/audit", () => ({ audit: mocks.audit })); + +import { sendCommentNotification } from "@/lib/docs/comment-notify"; +import { SHARE_TOKEN_TTL_S } from "@/lib/auth/config"; + +// --------------------------------------------------------------------------- +// Fixtures + a SQL-routing query mock. +// --------------------------------------------------------------------------- + +const OWNER_ID = 1; +const ALICE_ID = 2; // a thread participant +const BOB_ID = 3; // another participant / sometimes the author + +function makeDoc(over: Partial<DocRow> = {}): DocRow { + return { + id: 100, + slug: "fierce-tiger-12345", + owner_id: OWNER_ID, + title: "Q3 launch plan", + html: "<p>hello</p>", + version: 1, + is_public: false, + view_token: "vt", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + deleted_at: null, + ...over, + }; +} + +function makeComment(over: Partial<CommentRow> = {}): CommentRow { + return { + id: 88421, + doc_id: 100, + author_user_id: ALICE_ID, + author_email: "alice@co.com", + parent_id: null, + anchor: null, + anchored_version: null, + orphaned: false, + body: "Can we name the retention cap here?", + created_at: "2026-01-02T00:00:00Z", + edited_at: null, + resolved_at: null, + resolved_by_user_id: null, + deleted_at: null, + ...over, + }; +} + +type Rows = { rows: unknown[]; rowCount?: number }; + +/** + * Route the orchestrator's queries by SQL shape. `opts` supplies the rows each + * logical query returns; the token INSERT auto-assigns ids and records the + * params so tests can assert on them. + */ +function routeQuery(opts: { + ownerRows?: Array<{ email: string }>; + participantRows?: Array<{ id: number; email: string }>; + parentRows?: Array<{ email: string | null; body: string }>; + onTokenInsert?: (params: unknown[]) => void; + onTokenDelete?: (params: unknown[]) => void; +}): void { + let nextTokenId = 9000; + mocks.query.mockImplementation(async (sql: string, params?: unknown[]): Promise<Rows> => { + if (sql.includes("FROM users WHERE id =")) { + return { rows: opts.ownerRows ?? [{ email: "owner@co.com" }] }; + } + if (sql.includes("SELECT DISTINCT u.id, u.email")) { + return { rows: opts.participantRows ?? [] }; + } + if (sql.includes("SELECT u.email, c.body")) { + return { rows: opts.parentRows ?? [] }; + } + if (sql.includes("INSERT INTO login_tokens")) { + opts.onTokenInsert?.(params ?? []); + return { rows: [{ id: nextTokenId++ }] }; + } + if (sql.includes("DELETE FROM login_tokens")) { + opts.onTokenDelete?.(params ?? []); + return { rows: [], rowCount: 1 }; + } + throw new Error(`unexpected SQL in test: ${sql}`); + }); +} + +const req = new Request("https://justhtml.sh/api/v1/docs/fierce-tiger-12345/comments"); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.checkLimits.mockResolvedValue(null); // pass the cap by default + mocks.sendCommentEmail.mockResolvedValue("re_123"); // succeed by default +}); + +// --------------------------------------------------------------------------- +// Recipient model. +// --------------------------------------------------------------------------- + +describe("recipient model", () => { + it("self-suppression: a top-level comment by the owner notifies no one", async () => { + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: OWNER_ID, author_email: "owner@co.com" }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res).toEqual({ notified: 0, recipients: 0 }); + expect(mocks.sendCommentEmail).not.toHaveBeenCalled(); + }); + + it("top-level comment by a non-owner notifies the OWNER only", async () => { + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: ALICE_ID, author_email: "alice@co.com" }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res.notified).toBe(1); + expect(mocks.sendCommentEmail).toHaveBeenCalledTimes(1); + expect(mocks.sendCommentEmail.mock.calls[0][0]).toMatchObject({ + to: "owner@co.com", + isReply: false, + isOwnerRecipient: true, + }); + }); + + it("no-owner-email: owner row missing → owner is dropped (top-level → nobody)", async () => { + routeQuery({ ownerRows: [] }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: ALICE_ID }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res).toEqual({ notified: 0, recipients: 0 }); + expect(mocks.sendCommentEmail).not.toHaveBeenCalled(); + }); + + it("reply: notifies the owner + thread participants, excluding the author, deduped", async () => { + // Reply authored by BOB. Root authored by ALICE; owner also participated. + // The DISTINCT participant query returns owner, alice, bob — the orchestrator + // must drop bob (author) and dedupe the owner (already added as owner). + routeQuery({ + ownerRows: [{ email: "owner@co.com" }], + participantRows: [ + { id: OWNER_ID, email: "owner@co.com" }, + { id: ALICE_ID, email: "alice@co.com" }, + { id: BOB_ID, email: "bob@co.com" }, + ], + parentRows: [{ email: "alice@co.com", body: "name the retention cap here?" }], + }); + const doc = makeDoc(); + const comment = makeComment({ + id: 88422, + author_user_id: BOB_ID, + author_email: "bob@co.com", + parent_id: 88421, + body: "+1, 30 days is what we agreed.", + }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res.recipients).toBe(2); // owner + alice (bob excluded) + expect(res.notified).toBe(2); + const tos = mocks.sendCommentEmail.mock.calls.map((c) => c[0].to).sort(); + expect(tos).toEqual(["alice@co.com", "owner@co.com"]); + // No recipient is bob. + expect(tos).not.toContain("bob@co.com"); + }); +}); + +// --------------------------------------------------------------------------- +// Footer flavor (owner vs participant). +// --------------------------------------------------------------------------- + +describe("footer flavor", () => { + it("owner recipient gets isOwnerRecipient:true; a non-owner participant gets false", async () => { + routeQuery({ + ownerRows: [{ email: "owner@co.com" }], + participantRows: [ + { id: OWNER_ID, email: "owner@co.com" }, + { id: ALICE_ID, email: "alice@co.com" }, + ], + parentRows: [{ email: "alice@co.com", body: "root body" }], + }); + const doc = makeDoc(); + const comment = makeComment({ + author_user_id: BOB_ID, + author_email: "bob@co.com", + parent_id: 88421, + }); + + await sendCommentNotification({ req, doc, comment }); + + const byTo = Object.fromEntries( + mocks.sendCommentEmail.mock.calls.map((c) => [c[0].to, c[0].isOwnerRecipient]) + ); + expect(byTo["owner@co.com"]).toBe(true); + expect(byTo["alice@co.com"]).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Rate cap (dedicated namespace) + the EMAIL_SEND_LIMITS guard. +// --------------------------------------------------------------------------- + +describe("per-recipient rate cap", () => { + it("a tripped cap skips that recipient's send and audits the trip", async () => { + mocks.checkLimits.mockResolvedValueOnce({ key: "cmt-notify:addr:owner@co.com", limit: 30, retryAfter: 10 }); + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: ALICE_ID }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res.notified).toBe(0); + expect(mocks.sendCommentEmail).not.toHaveBeenCalled(); + expect(mocks.audit).toHaveBeenCalledWith( + req, + "rate_limit.tripped", + expect.objectContaining({ meta: expect.objectContaining({ key: "cmt-notify:addr:owner@co.com" }) }) + ); + }); + + it("uses the DEDICATED cmt-notify:addr namespace, 30/day — and NEVER EMAIL_SEND_LIMITS", async () => { + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: ALICE_ID }); + + await sendCommentNotification({ req, doc, comment }); + + expect(mocks.checkLimits).toHaveBeenCalledTimes(1); + expect(mocks.checkLimits.mock.calls[0][0]).toEqual([ + { key: "cmt-notify:addr:owner@co.com", limit: 30, window: "day" }, + ]); + // The load-bearing budget-isolation guarantee: this path must not consult + // the shared email-send caps. + expect(mocks.EMAIL_SEND_LIMITS).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Token mint + idempotency + snippet shape (happy path). +// --------------------------------------------------------------------------- + +describe("token mint + email params (happy path)", () => { + it("mints a 'share' token for the lowercased email with the 7-day TTL and the right idempotency key", async () => { + const tokenParams: unknown[][] = []; + routeQuery({ + ownerRows: [{ email: "Owner@CO.com" }], // mixed case → must be lowercased + onTokenInsert: (p) => tokenParams.push(p), + }); + const doc = makeDoc(); + const comment = makeComment({ id: 555, author_user_id: ALICE_ID }); + + await sendCommentNotification({ req, doc, comment }); + + // INSERT params: [email, token_hash, ttlSeconds]. + expect(tokenParams).toHaveLength(1); + const [email, tokenHash, ttl] = tokenParams[0]; + expect(email).toBe("owner@co.com"); + expect(typeof tokenHash).toBe("string"); + expect(ttl).toBe(String(SHARE_TOKEN_TTL_S)); // 604800 + + const sent = mocks.sendCommentEmail.mock.calls[0][0]; + expect(sent.idempotencyKey).toBe(`comment-notify-555-${OWNER_ID}`); + // The login link carries the freshly minted plaintext token + next=/d/:slug. + expect(sent.link).toContain("https://justhtml.sh/login/verify?token=lt_"); + expect(sent.link).toContain("next=%2Fd%2Ffierce-tiger-12345"); + expect(sent.docUrl).toBe("https://justhtml.sh/d/fierce-tiger-12345"); + }); + + it("truncates a long body to ~180 chars with an ellipsis", async () => { + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const longBody = "x".repeat(400); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: ALICE_ID, body: longBody }); + + await sendCommentNotification({ req, doc, comment }); + + const { bodySnippet } = mocks.sendCommentEmail.mock.calls[0][0]; + expect(bodySnippet.endsWith("…")).toBe(true); + expect(bodySnippet.length).toBeLessThanOrEqual(181); // 180 + the ellipsis char + }); + + it("top-level anchored comment passes anchor.exact as the anchored quote; reply does not", async () => { + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const doc = makeDoc(); + const comment = makeComment({ + author_user_id: ALICE_ID, + anchor: { type: "text", exact: "Each segment retains a full snapshot." }, + orphaned: false, + }); + + await sendCommentNotification({ req, doc, comment }); + + const sent = mocks.sendCommentEmail.mock.calls[0][0]; + expect(sent.isReply).toBe(false); + expect(sent.anchoredQuote).toBe("Each segment retains a full snapshot."); + }); + + it("an orphaned anchor is NOT surfaced as the anchored quote", async () => { + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const doc = makeDoc(); + const comment = makeComment({ + author_user_id: ALICE_ID, + anchor: { type: "text", exact: "stale passage" }, + orphaned: true, + }); + + await sendCommentNotification({ req, doc, comment }); + + expect(mocks.sendCommentEmail.mock.calls[0][0].anchoredQuote).toBeNull(); + }); + + it("reply: looks up parent author + body and passes them as parent context (isReply branch)", async () => { + routeQuery({ + ownerRows: [{ email: "owner@co.com" }], + participantRows: [{ id: OWNER_ID, email: "owner@co.com" }], + parentRows: [{ email: "alice@co.com", body: "name the retention cap here?" }], + }); + const doc = makeDoc(); + const comment = makeComment({ + id: 88422, + author_user_id: BOB_ID, + author_email: "bob@co.com", + parent_id: 88421, + }); + + await sendCommentNotification({ req, doc, comment }); + + const sent = mocks.sendCommentEmail.mock.calls[0][0]; + expect(sent.isReply).toBe(true); + expect(sent.parentAuthorEmail).toBe("alice@co.com"); + expect(sent.parentSnippet).toBe("name the retention cap here?"); + }); +}); + +// --------------------------------------------------------------------------- +// Send-failure rollback + audit. +// --------------------------------------------------------------------------- + +describe("send-failure rollback", () => { + it("deletes the just-minted token row when the send throws, and does not audit a sent event", async () => { + const deletedIds: unknown[] = []; + routeQuery({ + ownerRows: [{ email: "owner@co.com" }], + onTokenDelete: (p) => deletedIds.push(p[0]), + }); + mocks.sendCommentEmail.mockRejectedValueOnce(new Error("resend down")); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: ALICE_ID }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res.notified).toBe(0); + expect(deletedIds).toEqual([9000]); // the id the INSERT returned + expect(mocks.audit).not.toHaveBeenCalledWith( + req, + "comment_notification.sent", + expect.anything() + ); + }); + + it("audits comment_notification.sent on a successful send", async () => { + routeQuery({ ownerRows: [{ email: "owner@co.com" }] }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: ALICE_ID }); + + await sendCommentNotification({ req, doc, comment }); + + expect(mocks.audit).toHaveBeenCalledWith( + req, + "comment_notification.sent", + expect.objectContaining({ + userId: OWNER_ID, + meta: expect.objectContaining({ doc_id: 100, comment_id: 88421, recipient_email: "owner@co.com" }), + }) + ); + }); +}); + +// --------------------------------------------------------------------------- +// Thread-participant query shape. +// --------------------------------------------------------------------------- + +describe("thread-participant query shape", () => { + it("queries DISTINCT live, non-null authors across the root and its replies (rootId = parent_id)", async () => { + const seen: Array<{ sql: string; params: unknown[] }> = []; + mocks.query.mockImplementation(async (sql: string, params?: unknown[]): Promise<Rows> => { + seen.push({ sql, params: params ?? [] }); + if (sql.includes("FROM users WHERE id =")) return { rows: [{ email: "owner@co.com" }] }; + if (sql.includes("SELECT DISTINCT u.id, u.email")) return { rows: [] }; + if (sql.includes("SELECT u.email, c.body")) return { rows: [{ email: "alice@co.com", body: "b" }] }; + if (sql.includes("INSERT INTO login_tokens")) return { rows: [{ id: 1 }] }; + return { rows: [] }; + }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: BOB_ID, parent_id: 88421 }); + + await sendCommentNotification({ req, doc, comment }); + + const partQ = seen.find((q) => q.sql.includes("SELECT DISTINCT u.id, u.email")); + expect(partQ).toBeDefined(); + expect(partQ!.sql).toContain("c.id = $2 OR c.parent_id = $2"); + expect(partQ!.sql).toContain("c.deleted_at IS NULL"); + expect(partQ!.sql).toContain("c.author_user_id IS NOT NULL"); + expect(partQ!.params).toEqual([doc.id, 88421]); // [doc_id, rootId = parent_id] + }); +}); diff --git a/lib/docs/comment-notify.ts b/lib/docs/comment-notify.ts new file mode 100644 index 0000000..8016497 --- /dev/null +++ b/lib/docs/comment-notify.ts @@ -0,0 +1,214 @@ +import { query } from "@/lib/db"; +import { mintLoginToken, sha256Hex } from "@/lib/auth/tokens"; +import { sendCommentEmail } from "@/lib/auth/email"; +import { audit } from "@/lib/auth/audit"; +import { checkLimits } from "@/lib/auth/ratelimit"; +import { ORIGIN, SHARE_TOKEN_TTL_S } from "@/lib/auth/config"; +import type { DocRow } from "@/lib/docs/store"; +import type { CommentRow } from "@/lib/docs/comments"; + +// Comment notification — the sibling of share-notify.ts. When a comment is +// posted, we email the people who should hear about it, each with their OWN +// 7-day share-kind login link to /d/:slug (same mechanics as the share email). +// +// RECIPIENTS (the agreed model): +// - top-level comment (parent_id null) → the document OWNER only. +// - reply (parent_id set) → the OWNER PLUS every other participant in that +// thread. 1-level threads, so the thread root id = the reply's parent_id; +// participants = the distinct author_user_id across {root, all its replies}. +// - ALWAYS exclude the new comment's author (no self-notification). +// - De-dupe by user_id (the owner may also be a participant). +// +// SUPPRESSION. A DEDICATED rate-limit namespace (cmt-notify:*) — never +// EMAIL_SEND_LIMITS — so comment volume cannot burn the owner's login/claim/ +// share email budget or inflate email:global. Per-recipient safety cap only: +// cmt-notify:addr:<email>, 30/day. No per-doc coalescing; notify on every +// comment; no digest. +// +// BEST-EFFORT. The comment is already committed, so we catch everything and +// never throw into the request path. On a send failure we roll back only that +// recipient's just-minted token row (the /d/:slug "was this shared with you?" +// fallback recovers a missed link). + +// Per-recipient daily safety cap. NOT EMAIL_SEND_LIMITS — a doc's comment +// traffic must never consume the recipient's magic-link/claim/share budget. +const COMMENT_NOTIFY_PER_EMAIL_PER_DAY = 30; + +// Body snippet length in the email (the reference truncates the preview). +const BODY_SNIPPET_MAX = 180; +// Parent-context snippet (reply) and anchored-passage (top-level) are tighter — +// they are one-line context, not the payload. +const CONTEXT_SNIPPET_MAX = 120; + +export type CommentNotifyResult = { notified: number; recipients: number }; + +type Recipient = { userId: number; email: string; isOwner: boolean }; + +/** Truncate to roughly `max` chars, appending an ellipsis when cut. */ +function snippet(s: string, max: number): string { + const t = s.trim(); + if (t.length <= max) return t; + return t.slice(0, max).trimEnd() + "…"; +} + +/** + * Build the deduped recipient list for a comment, excluding its author. For a + * top-level comment that's the owner alone; for a reply it's the owner plus the + * distinct thread participants. Each recipient carries their resolved email and + * whether they own the doc (drives the footer flavor). + */ +async function resolveRecipients( + doc: DocRow, + comment: CommentRow +): Promise<Recipient[]> { + const authorId = comment.author_user_id; + + // Owner email. + const { rows: ownerRows } = await query<{ email: string }>( + `SELECT email FROM users WHERE id = $1`, + [doc.owner_id] + ); + const ownerEmail = ownerRows[0]?.email ?? null; + + // user_id -> recipient, deduped. Owner first so its isOwner flag wins over a + // participant row for the same id. + const byUser = new Map<number, Recipient>(); + if (ownerEmail && doc.owner_id !== authorId) { + byUser.set(doc.owner_id, { userId: doc.owner_id, email: ownerEmail, isOwner: true }); + } + + if (comment.parent_id !== null) { + // Thread participants: the distinct authors across the root + all its + // replies (1-level model, so rootId = parent_id), resolved to emails. + const rootId = comment.parent_id; + const { rows: partRows } = await query<{ id: number; email: string }>( + `SELECT DISTINCT u.id, u.email + FROM comments c + JOIN users u ON u.id = c.author_user_id + WHERE c.doc_id = $1 + AND (c.id = $2 OR c.parent_id = $2) + AND c.deleted_at IS NULL + AND c.author_user_id IS NOT NULL`, + [doc.id, rootId] + ); + for (const p of partRows) { + if (p.id === authorId) continue; // never self-notify + if (byUser.has(p.id)) continue; // already in (owner) + byUser.set(p.id, { userId: p.id, email: p.email, isOwner: p.id === doc.owner_id }); + } + } + + return [...byUser.values()]; +} + +/** + * Notify the right people that `comment` was posted on `doc`. Best-effort: + * resolves recipients, and for each one checks the per-recipient cap, mints a + * 7-day share login link, sends the email, audits, and rolls back the token on + * a send failure. Never throws into the caller; returns how many emails were + * actually sent. + */ +export async function sendCommentNotification(opts: { + req: Request; + doc: DocRow; + comment: CommentRow; +}): Promise<CommentNotifyResult> { + try { + const { req, doc, comment } = opts; + const recipients = await resolveRecipients(doc, comment); + if (recipients.length === 0) return { notified: 0, recipients: 0 }; + + const isReply = comment.parent_id !== null; + const title = doc.title || doc.slug; + const authorEmail = comment.author_email || "someone"; + const bodySnippet = snippet(comment.body, BODY_SNIPPET_MAX); + + // Top-level anchored passage: only when the comment is anchored AND still + // resolves (not orphaned). anchor.exact is the W3C text-quote selector's + // verbatim span. + const anchoredQuote = + !isReply && comment.anchor && !comment.orphaned && comment.anchor.exact + ? snippet(comment.anchor.exact, CONTEXT_SNIPPET_MAX) + : null; + + // Reply parent context: the parent comment's author email + a body snippet. + let parentAuthorEmail: string | null = null; + let parentSnippet: string | null = null; + if (isReply && comment.parent_id !== null) { + const { rows: parentRows } = await query<{ email: string | null; body: string }>( + `SELECT u.email, c.body + FROM comments c + LEFT JOIN users u ON u.id = c.author_user_id + WHERE c.id = $1`, + [comment.parent_id] + ); + const parent = parentRows[0]; + if (parent) { + parentAuthorEmail = parent.email; + parentSnippet = snippet(parent.body, CONTEXT_SNIPPET_MAX); + } + } + + const next = `/d/${encodeURIComponent(doc.slug)}`; + const docUrl = `${ORIGIN}${next}`; + + let notified = 0; + for (const r of recipients) { + const to = r.email.toLowerCase(); + + // Per-recipient daily safety cap, dedicated namespace. A tripped cap skips + // this recipient (the rest still go out). + const tripped = await checkLimits([ + { key: `cmt-notify:addr:${to}`, limit: COMMENT_NOTIFY_PER_EMAIL_PER_DAY, window: "day" }, + ]); + if (tripped) { + audit(req, "rate_limit.tripped", { meta: { key: tripped.key, limit: tripped.limit } }); + continue; + } + + // Mint a share-kind login token (7-day TTL). Roll back if the send fails. + const token = mintLoginToken(); + const { rows } = await query<{ id: number }>( + `INSERT INTO login_tokens (email, token_hash, kind, expires_at) + VALUES ($1, $2, 'share', now() + ($3 || ' seconds')::interval) + RETURNING id`, + [to, sha256Hex(token), String(SHARE_TOKEN_TTL_S)] + ); + const tokenId = rows[0].id; + + const link = `${ORIGIN}/login/verify?token=${token}&next=${encodeURIComponent(next)}`; + + let resendId: string | null = null; + try { + resendId = await sendCommentEmail({ + to, + authorEmail, + title, + isReply, + isOwnerRecipient: r.isOwner, + bodySnippet, + anchoredQuote, + parentAuthorEmail, + parentSnippet, + link, + docUrl, + idempotencyKey: `comment-notify-${comment.id}-${r.userId}`, + }); + } catch { + await query(`DELETE FROM login_tokens WHERE id = $1`, [tokenId]).catch(() => {}); + continue; + } + + audit(req, "comment_notification.sent", { + userId: r.userId, + meta: { doc_id: doc.id, comment_id: comment.id, recipient_email: to, resend_id: resendId }, + }); + notified += 1; + } + + return { notified, recipients: recipients.length }; + } catch { + // Best-effort: a comment notification must never fail the comment write. + return { notified: 0, recipients: 0 }; + } +} diff --git a/lib/docs/schemas.ts b/lib/docs/schemas.ts index 71e7875..0a668d3 100644 --- a/lib/docs/schemas.ts +++ b/lib/docs/schemas.ts @@ -861,10 +861,18 @@ export const CommentsListResponse = registry.register( .openapi("CommentsListResponse", { description: "The complete all-threads view." }) ); -// POST /comments 201 — { comment }. +// POST /comments 201 — { comment, notified }. export const CommentCreatedResponse = registry.register( "CommentCreatedResponse", - z.object({ comment: Comment }).openapi("CommentCreatedResponse", { description: "Comment created." }) + z + .object({ + comment: Comment, + notified: z.number().int().openapi({ + description: + "How many notification emails were sent for this comment (the owner on a top-level comment; the owner plus thread participants on a reply, minus the author). 0 when there is no one to notify or sends were suppressed.", + }), + }) + .openapi("CommentCreatedResponse", { description: "Comment created." }) ); // PATCH /comments/{id} 200 — { comment }. diff --git a/lib/openapi/generated-spec.ts b/lib/openapi/generated-spec.ts index 63791d4..f81f5a7 100644 --- a/lib/openapi/generated-spec.ts +++ b/lib/openapi/generated-spec.ts @@ -6,4 +6,4 @@ // the e2e response-schema validator reads. scripts/spec-check.ts asserts this // committed artifact matches a fresh generation, so it can never drift. -export const SPEC_YAML = "openapi: 3.1.0\ninfo:\n title: justhtml.sh API\n version: 1.0.0\n description: |\n An agent-first minimal HTML document host. Agents self-onboard via the\n auth.md service_auth flow (see https://justhtml.sh/auth.md), receive a\n long-lived API key, and publish HTML documents to stable URLs.\n\n Terse usage with curl examples: https://justhtml.sh/llms.txt\n license:\n name: Proprietary\n url: https://justhtml.sh/\nservers:\n - url: https://justhtml.sh\n description: Production\ntags:\n - name: auth\n description: auth.md service_auth registration + OAuth token/revoke\n - name: discovery\n description: Machine-readable OAuth discovery metadata\n - name: docs\n description: Document CRUD, patch editing, versions\n - name: sharing\n description: Per-document grants (email or domain)\n - name: collaboration\n description: Comments (W3C text-quote anchors, 1-level threads) and reactions\nsecurity:\n - bearerApiKey: []\ncomponents:\n securitySchemes:\n bearerApiKey:\n type: http\n scheme: bearer\n bearerFormat: jh_live_...\n description: >-\n Long-lived API key obtained via the auth.md service_auth flow. Carries scopes docs.read\n docs.write. 401s include a WWW-Authenticate header pointing at the protected-resource\n metadata.\n schemas:\n CreateDocBody:\n type: object\n properties:\n html:\n type: string\n description: The document HTML.\n example: <h1>Hello</h1>\n title:\n type:\n - string\n - 'null'\n maxLength: 300\n description: Optional document title.\n example: My doc\n public:\n type: boolean\n default: false\n description: Whether the document is public.\n required:\n - html\n description: Create a document. html is required; title and public are optional.\n UpdateDocBody:\n type: object\n properties:\n html:\n type: string\n description: Replacement HTML (full rewrite, bumps version).\n example: <h1>Hi</h1>\n title:\n type:\n - string\n - 'null'\n maxLength: 300\n description: New title, or null to clear it.\n public:\n type: boolean\n description: New visibility flag (owner only).\n description: >-\n Update html (full rewrite), title, or visibility. At least one field is required. Editors\n may rewrite html; only the owner may change title or public.\n OwnerDoc:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n version:\n type: integer\n public:\n type: boolean\n view_token:\n type: string\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - url\n - title\n - version\n - public\n - view_token\n - created_at\n - updated_at\n description: Document as seen by its owner (includes view_token).\n GranteeDoc:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n version:\n type: integer\n public:\n type: boolean\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - url\n - title\n - version\n - public\n - role\n - created_at\n - updated_at\n description: Document as seen by a non-owner grantee (role instead of view_token).\n DocWithHtml:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n version:\n type: integer\n public:\n type: boolean\n view_token:\n type: string\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - url\n - title\n - version\n - public\n - created_at\n - updated_at\n description: >-\n Owner sees view_token; a grantee sees role (editor/commenter/viewer) instead. html is\n included on single-doc fetches and after writes.\n DocListItem:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n access:\n type: string\n enum:\n - owner\n - editor\n - commenter\n - viewer\n description: >-\n The caller's access to this doc. owner for docs you own; otherwise the resolved grant\n role (an explicit email grant beats a domain grant for the same email).\n version:\n type: integer\n public:\n type: boolean\n comment_count:\n type: integer\n description: >-\n Live (non-deleted) comments + replies on the doc. 0 when there are none. The /docs\n dashboard surfaces the same count.\n view_token:\n type: string\n description: Present only when access=owner.\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n required:\n - slug\n - url\n - title\n - access\n - version\n - public\n - comment_count\n - created_at\n - updated_at\n description: >-\n A document as returned by GET /api/v1/docs (any scope). Carries access\n (owner|editor|commenter|viewer). Owned items (access=owner) additionally carry view_token;\n shared items omit it.\n DocListResponse:\n type: object\n properties:\n docs:\n type: array\n items:\n $ref: '#/components/schemas/DocListItem'\n required:\n - docs\n description: The matched documents.\n DeleteDocResponse:\n type: object\n properties:\n slug:\n type: string\n deleted:\n type: boolean\n required:\n - slug\n - deleted\n description: Soft-delete acknowledgement.\n ApiError:\n type: object\n properties:\n error:\n type: string\n message:\n type: string\n required:\n - error\n - message\n additionalProperties: {}\n description: 'Structured API error: { error, message, ...extra }.'\n GrantBody:\n type: object\n properties:\n email:\n type:\n - string\n - 'null'\n format: email\n description: Grantee email (provide exactly one of email or domain).\n domain:\n type:\n - string\n - 'null'\n example: kernel.sh\n description: Grantee email-domain (provide exactly one of email or domain).\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n description: Grant role.\n notify:\n type: boolean\n default: true\n description: >-\n Email-grants only. Send the grantee a share-notification email (default true). Ignored\n for domain grants.\n required:\n - role\n description: >-\n Share with an email or a domain. Provide exactly one of email or domain. role is editor,\n commenter, or viewer. notify (email grants only) defaults to true.\n Grant:\n type: object\n properties:\n id:\n type: integer\n grantee_type:\n type: string\n enum:\n - email\n - domain\n grantee:\n type: string\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n created_at:\n type: string\n format: date-time\n required:\n - id\n - grantee_type\n - grantee\n - role\n - created_at\n description: A single grant (email or domain) on a document.\n GrantListResponse:\n type: object\n properties:\n slug:\n type: string\n grants:\n type: array\n items:\n $ref: '#/components/schemas/Grant'\n count:\n type: integer\n max:\n type: integer\n example: 50\n required:\n - slug\n - grants\n - count\n - max\n description: Grants on the document (owner only).\n GrantCreatedResponse:\n type: object\n properties:\n slug:\n type: string\n grant:\n $ref: '#/components/schemas/Grant'\n notified:\n type: boolean\n description: >-\n Present only for email grants: true if the share-notification email was sent, false if\n suppressed (notify:false) or skipped (rate-limited / send failed).\n required:\n - slug\n - grant\n description: Grant created.\n GrantUnchangedResponse:\n type: object\n properties:\n slug:\n type: string\n grant:\n $ref: '#/components/schemas/Grant'\n unchanged:\n type: boolean\n required:\n - slug\n - grant\n - unchanged\n description: Idempotent re-grant (same target + role).\n GrantDeletedResponse:\n type: object\n properties:\n slug:\n type: string\n grant_id:\n type: integer\n deleted:\n type: boolean\n required:\n - slug\n - grant_id\n - deleted\n description: Grant revoked.\n VersionMeta:\n type: object\n properties:\n version:\n type: integer\n edit_kind:\n type: string\n enum:\n - create\n - patch\n - rewrite\n author_user_id:\n type:\n - integer\n - 'null'\n description: User who authored this version (null for legacy/system writes).\n patch:\n type: array\n items:\n type: object\n properties:\n oldText:\n type: string\n newText:\n type: string\n required:\n - oldText\n - newText\n description: >-\n The edits payload as requested, present only when edit_kind=patch (the list of\n {oldText,newText} applied). Omitted otherwise.\n bytes:\n type: integer\n created_at:\n type: string\n format: date-time\n required:\n - version\n - edit_kind\n - author_user_id\n - bytes\n - created_at\n description: Metadata for one retained version (no html).\n VersionListResponse:\n type: object\n properties:\n slug:\n type: string\n current_version:\n type: integer\n versions:\n type: array\n items:\n $ref: '#/components/schemas/VersionMeta'\n required:\n - slug\n - current_version\n - versions\n description: Version metadata (no html), newest first.\n VersionSnapshot:\n type: object\n properties:\n slug:\n type: string\n version:\n type: integer\n edit_kind:\n type: string\n enum:\n - create\n - patch\n - rewrite\n author_user_id:\n type:\n - integer\n - 'null'\n patch:\n type: array\n items:\n type: object\n properties:\n oldText:\n type: string\n newText:\n type: string\n required:\n - oldText\n - newText\n bytes:\n type: integer\n created_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - version\n - edit_kind\n - author_user_id\n - bytes\n - created_at\n - html\n description: A version's metadata plus its full html snapshot.\n EditsBody:\n type: object\n properties:\n edits:\n type: array\n items:\n type: object\n properties:\n oldText:\n type: string\n newText:\n type: string\n required:\n - oldText\n - newText\n minItems: 1\n maxItems: 200\n description: The patches to apply, in order. 1–200 edits.\n base_version:\n type:\n - integer\n - 'null'\n minimum: 1\n description: The version the edits were derived against; a mismatch returns 409.\n required:\n - edits\n description: >-\n Apply deterministic patches. edits is a non-empty list of {oldText,newText}. Always send\n base_version; a mismatch returns 409.\n TextAnchor:\n type: object\n properties:\n type:\n type: string\n enum:\n - text\n exact:\n type: string\n example: deterministic compaction\n prefix:\n type: string\n example: 'record store with '\n suffix:\n type: string\n example: .\n start:\n type: integer\n end:\n type: integer\n required:\n - exact\n description: >-\n W3C text-quote selector (TextQuoteSelector + position hint). exact is the verbatim quoted\n passage; prefix/suffix (~32 chars) disambiguate repeated text and survive surrounding\n shifts; start/end are offsets into the document's text content (a fast-path hint, not\n authoritative).\n CreateCommentBody:\n type: object\n properties:\n body:\n type: string\n description: Comment text (<= 10 KB).\n example: is this right?\n anchor:\n description: W3C text-quote selector; null/omitted = doc-level.\n parent_id:\n type: integer\n description: Root comment id to reply to (1-level threads only).\n required:\n - body\n description: >-\n Comment on a span by QUOTING it (anchor), at the doc level (omit anchor), or reply to a root\n comment (parent_id).\n UpdateCommentBody:\n type: object\n properties:\n body:\n type: string\n description: Author only. The new comment text (<= 10 KB).\n resolved:\n type: boolean\n description: Resolve/unresolve. Anyone who can comment.\n description: >-\n Edit body (author) and/or resolve/unresolve (anyone who can comment). At least one field is\n required.\n CreateReactionBody:\n type: object\n properties:\n emoji:\n type: string\n enum:\n - 👍\n - 👎\n - 🎉\n - 🤔\n - ❤️\n - 🚀\n - 👀\n - 😄\n - 🙏\n - 🔥\n - ✅\n - 💯\n description: >-\n One of the curated set: 👍 👎 🎉 🤔 ❤️ 🚀 👀 😄 🙏 🔥 ✅ 💯. Anything else → 400\n invalid_request with an \"allowed\" array listing the full set.\n example: 🚀\n comment_id:\n type: integer\n description: Target comment; omit/null = not a comment reaction. Mutually exclusive with anchor.\n anchor:\n description: >-\n Target span (W3C text-quote selector). Mutually exclusive with comment_id; omit/null =\n react on the doc (or comment).\n required:\n - emoji\n description: >-\n Add an emoji reaction. The target is 3-way and mutually exclusive: comment_id (a comment),\n anchor (a span), or neither (the whole doc). Supplying both comment_id and anchor → 400.\n ReactionGroup:\n type: object\n properties:\n emoji:\n type: string\n count:\n type: integer\n authors:\n type: array\n items:\n type: string\n description: Author email.\n required:\n - emoji\n - count\n - authors\n description: Reactions collapsed by emoji, with the attributed authors.\n AnchoredReactionGroup:\n type: object\n properties:\n sig:\n type: string\n description: Anchor signature (prefix|exact|suffix) — the grouping key.\n anchor:\n $ref: '#/components/schemas/TextAnchor'\n anchored_version:\n type:\n - integer\n - 'null'\n reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n required:\n - sig\n - anchor\n - anchored_version\n - reactions\n description: >-\n All reactions on one text span, grouped by anchor signature, then collapsed per emoji. The\n viewer paints one highlight on the span and a chip per emoji at the span's end.\n Comment:\n type: object\n properties:\n id:\n type: integer\n parent_id:\n type:\n - integer\n - 'null'\n author:\n type:\n - string\n - 'null'\n description: Author email.\n author_avatar:\n type:\n - string\n - 'null'\n format: uri\n description: Gravatar URL.\n body:\n type: string\n anchor:\n allOf:\n - $ref: '#/components/schemas/TextAnchor'\n - type:\n - object\n - 'null'\n anchored_version:\n type:\n - integer\n - 'null'\n orphaned:\n type: boolean\n description: Anchor no longer resolves; kept, shown unanchored.\n resolved:\n type: boolean\n resolved_at:\n type:\n - string\n - 'null'\n format: date-time\n created_at:\n type: string\n format: date-time\n edited_at:\n type:\n - string\n - 'null'\n format: date-time\n reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n required:\n - id\n - parent_id\n - author\n - author_avatar\n - body\n - anchor\n - anchored_version\n - orphaned\n - resolved\n - resolved_at\n - created_at\n - edited_at\n - reactions\n description: A single comment (with its aggregated reactions).\n CommentThread:\n type: object\n properties:\n id:\n type: integer\n parent_id:\n type:\n - integer\n - 'null'\n author:\n type:\n - string\n - 'null'\n author_avatar:\n type:\n - string\n - 'null'\n body:\n type: string\n anchor:\n allOf:\n - $ref: '#/components/schemas/TextAnchor'\n - type:\n - object\n - 'null'\n anchored_version:\n type:\n - integer\n - 'null'\n orphaned:\n type: boolean\n resolved:\n type: boolean\n resolved_at:\n type:\n - string\n - 'null'\n created_at:\n type: string\n format: date-time\n edited_at:\n type:\n - string\n - 'null'\n reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n group:\n type: string\n enum:\n - anchored\n - doc\n - orphaned\n description: Which group this thread sorts into in the all-threads view.\n replies:\n type: array\n items:\n $ref: '#/components/schemas/Comment'\n required:\n - id\n - parent_id\n - author\n - author_avatar\n - body\n - anchor\n - anchored_version\n - orphaned\n - resolved\n - resolved_at\n - created_at\n - edited_at\n - reactions\n - group\n - replies\n description: A root comment with its group tag and 1-level replies.\n CommentsListResponse:\n type: object\n properties:\n slug:\n type: string\n version:\n type: integer\n total:\n type: integer\n description: Live comment + reply count.\n can_comment:\n type: boolean\n can_react:\n type: boolean\n threads:\n type: array\n items:\n $ref: '#/components/schemas/CommentThread'\n doc_reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n description: >-\n Doc-level reactions (present only when any exist). Includes orphaned anchored reactions\n degraded to doc-level.\n anchored_reactions:\n type: array\n items:\n $ref: '#/components/schemas/AnchoredReactionGroup'\n description: >-\n Span reactions grouped by anchor signature, in document order, so clients stack/count\n without re-grouping (present only when any exist).\n required:\n - slug\n - version\n - total\n - can_comment\n - can_react\n - threads\n description: The complete all-threads view.\n CommentCreatedResponse:\n type: object\n properties:\n comment:\n $ref: '#/components/schemas/Comment'\n required:\n - comment\n description: Comment created.\n CommentUpdatedResponse:\n type: object\n properties:\n comment:\n $ref: '#/components/schemas/Comment'\n required:\n - comment\n description: Comment updated.\n CommentDeletedResponse:\n type: object\n properties:\n id:\n type: integer\n deleted:\n type: boolean\n required:\n - id\n - deleted\n description: Comment soft-deleted.\n ReactionCreatedResponse:\n type: object\n properties:\n reaction:\n type: object\n properties:\n id:\n type: integer\n comment_id:\n type:\n - integer\n - 'null'\n anchor:\n allOf:\n - $ref: '#/components/schemas/TextAnchor'\n - type:\n - object\n - 'null'\n anchored_version:\n type:\n - integer\n - 'null'\n orphaned:\n type: boolean\n emoji:\n type: string\n author:\n type:\n - string\n - 'null'\n created_at:\n type: string\n format: date-time\n required:\n - id\n - comment_id\n - anchor\n - anchored_version\n - orphaned\n - emoji\n - author\n - created_at\n required:\n - reaction\n description: Reaction added.\n ReactionToggledResponse:\n type: object\n properties:\n toggled:\n type: boolean\n removed:\n type: boolean\n required:\n - toggled\n - removed\n description: Reaction toggled off (the same reaction already existed).\n ReactionDeletedResponse:\n type: object\n properties:\n id:\n type: integer\n deleted:\n type: boolean\n required:\n - id\n - deleted\n description: Reaction removed.\n ClaimBlock:\n type: object\n properties:\n complete_url:\n type: string\n format: uri\n description: POST {claim_token, user_code} here to complete the claim.\n expires_in:\n type: integer\n example: 600\n interval:\n type: integer\n example: 5\n required:\n - complete_url\n - expires_in\n - interval\n description: >-\n The claim block. The user_code is intentionally omitted — it is emailed to the human (the\n only place it appears). The human reads it back to the agent, which POSTs {claim_token,\n user_code} to complete_url (/agent/identity/claim/complete).\n AgentError:\n type: object\n properties:\n error:\n type: string\n message:\n type: string\n required:\n - error\n - message\n description: 'Agent ceremony error: { error, message }.'\n OAuthError:\n type: object\n properties:\n error:\n type: string\n error_description:\n type: string\n required:\n - error\n description: 'OAuth error envelope (RFC 6749): { error, error_description? }.'\n StartRegistrationBody:\n type: object\n properties:\n type:\n type: string\n enum:\n - service_auth\n description: The registration type.\n login_hint:\n type: string\n format: email\n example: you@example.com\n description: The human's email address.\n required:\n - type\n - login_hint\n description: Start a service_auth registration; the 6-digit code is emailed to login_hint.\n RemintClaimBody:\n type: object\n properties:\n claim_token:\n type: string\n email:\n type: string\n format: email\n description: Corrected email; updates the registration's login_hint.\n required:\n - claim_token\n - email\n description: Re-mint an expired code; a fresh code is emailed to the human.\n CompleteClaimBody:\n type: object\n properties:\n claim_token:\n type: string\n user_code:\n type: string\n pattern: ^[0-9]{6}$\n example: '428117'\n required:\n - claim_token\n - user_code\n description: Complete a claim by reading the emailed 6-digit code back to the agent.\n TokenForm:\n type: object\n properties:\n grant_type:\n type: string\n enum:\n - urn:workos:agent-auth:grant-type:claim\n description: The claim grant type.\n claim_token:\n type: string\n required:\n - grant_type\n - claim_token\n description: Claim-grant token request (form-encoded).\n RevokeForm:\n type: object\n properties:\n token:\n type: string\n token_type_hint:\n type: string\n enum:\n - access_token\n required:\n - token\n description: RFC 7009 revocation request (form-encoded).\n StartRegistrationResponse:\n type: object\n properties:\n registration_id:\n type: string\n registration_type:\n type: string\n enum:\n - service_auth\n claim_url:\n type: string\n format: uri\n claim_token:\n type: string\n description: Secret; returned once. Hold in memory only.\n claim_token_expires:\n type: string\n format: date-time\n post_claim_scopes:\n type: array\n items:\n type: string\n example:\n - docs.read\n - docs.write\n claim:\n $ref: '#/components/schemas/ClaimBlock'\n required:\n - registration_id\n - registration_type\n - claim_url\n - claim_token\n - claim_token_expires\n - post_claim_scopes\n - claim\n description: Pending registration created; code emailed to the human.\n RemintClaimResponse:\n type: object\n properties:\n registration_id:\n type: string\n claim_attempt_id:\n type: string\n status:\n type: string\n example: initiated\n claim_attempt:\n $ref: '#/components/schemas/ClaimBlock'\n required:\n - registration_id\n - claim_attempt_id\n - status\n - claim_attempt\n description: Fresh code emailed.\n CompleteClaimResponse:\n type: object\n properties:\n registration_id:\n type: string\n status:\n type: string\n example: claimed\n message:\n type: string\n required:\n - registration_id\n - status\n - message\n description: Claim confirmed; poll /oauth2/token for the key.\n TokenResponse:\n type: object\n properties:\n access_token:\n type: string\n example: jh_live_...\n token_type:\n type: string\n enum:\n - Bearer\n scope:\n type: string\n example: docs.read docs.write\n credential_type:\n type: string\n enum:\n - api_key\n registration_id:\n type: string\n required:\n - access_token\n - token_type\n - scope\n - credential_type\n - registration_id\n description: Credential issued (once).\n ProtectedResourceMetadata:\n type: object\n properties: {}\n additionalProperties: {}\n description: RFC 9728 protected-resource metadata.\n AuthServerMetadata:\n type: object\n properties: {}\n additionalProperties: {}\n description: RFC 8414 authorization-server metadata (with agent_auth block).\n Slug:\n type: string\n example: fierce-tiger-12345\n VersionNum:\n type: integer\n minimum: 1\n example: 3\n GrantId:\n type: integer\n minimum: 1\n example: 1\n CommentId:\n type: integer\n minimum: 1\n example: 42\n ReactionId:\n type: integer\n minimum: 1\n example: 7\n parameters:\n Slug:\n schema:\n $ref: '#/components/schemas/Slug'\n required: true\n name: slug\n in: path\n VersionNum:\n schema:\n $ref: '#/components/schemas/VersionNum'\n required: true\n name: 'n'\n in: path\n GrantId:\n schema:\n $ref: '#/components/schemas/GrantId'\n required: true\n name: id\n in: path\n CommentId:\n schema:\n $ref: '#/components/schemas/CommentId'\n required: true\n name: id\n in: path\n ReactionId:\n schema:\n $ref: '#/components/schemas/ReactionId'\n required: true\n name: id\n in: path\npaths:\n /api/v1/docs:\n post:\n tags:\n - docs\n summary: Create a document\n operationId: createDoc\n security:\n - bearerApiKey: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CreateDocBody'\n responses:\n '201':\n description: Created\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OwnerDoc'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: A resource quota was exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: HTML exceeds the 2 MB per-document size limit\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n get:\n tags:\n - docs\n summary: List documents (owned, shared, or both)\n description: >-\n Lists documents by scope. Every item carries an access role (owner|editor|commenter|viewer).\n For a doc matched by both an email grant and a domain grant, the email grant wins\n (precedence ladder). Owned items additionally carry view_token; shared items do not (the\n view token is an owner-only capability). The web equivalent for a signed-in human is\n https://justhtml.sh/docs.\n operationId: listDocs\n security:\n - bearerApiKey: []\n parameters:\n - schema:\n type: string\n enum:\n - owned\n - shared\n - all\n default: owned\n description: >-\n owned (default): docs the caller owns. shared: docs granted to the caller's email or\n email-domain, excluding docs the caller owns. all: owned then shared.\n required: false\n description: >-\n owned (default): docs the caller owns. shared: docs granted to the caller's email or\n email-domain, excluding docs the caller owns. all: owned then shared.\n name: scope\n in: query\n - schema:\n type: integer\n minimum: 1\n maximum: 500\n default: 100\n required: false\n name: limit\n in: query\n responses:\n '200':\n description: The matched documents\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocListResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}:\n get:\n tags:\n - docs\n summary: Fetch a document (metadata + html)\n operationId: getDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Owner sees view_token; a grantee sees role instead of view_token.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocWithHtml'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n patch:\n tags:\n - docs\n summary: Update html (full rewrite), title, or visibility\n description: >-\n Owner or editor grant may rewrite html. Only the owner may change title or public\n (visibility).\n operationId: updateDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/UpdateDocBody'\n responses:\n '200':\n description: Updated\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocWithHtml'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Editor tried to change title/visibility\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: HTML exceeds the 2 MB per-document size limit\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n delete:\n tags:\n - docs\n summary: Soft-delete a document (owner only)\n operationId: deleteDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Deleted\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DeleteDocResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/edits:\n post:\n tags:\n - docs\n summary: Apply deterministic patches\n description: >-\n exact-match-then-fuzzy edit application. Owner or editor grant. Always send base_version; a\n mismatch returns 409. Ambiguous, no-match, or overlapping edits return 422 naming the\n failing edit index.\n operationId: editDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/EditsBody'\n responses:\n '200':\n description: Patched\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocWithHtml'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '409':\n description: base_version conflict\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: HTML exceeds the 2 MB per-document size limit\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '422':\n description: An edit could not be applied deterministically\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/rotate-token:\n post:\n tags:\n - docs\n summary: Rotate the view token (un-share; owner only)\n operationId: rotateViewToken\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: New view token issued\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OwnerDoc'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/versions:\n get:\n tags:\n - docs\n summary: List retained version history (newest first)\n operationId: listVersions\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Version metadata (no html)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/VersionListResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/versions/{n}:\n get:\n tags:\n - docs\n summary: Fetch a specific version's full html\n operationId: getVersion\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/VersionNum'\n responses:\n '200':\n description: Version snapshot with html\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/VersionSnapshot'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/grants:\n get:\n tags:\n - sharing\n summary: List grants (owner only)\n operationId: listGrants\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Grants on the document\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantListResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n post:\n tags:\n - sharing\n summary: Share with an email or a domain (owner only)\n description: >-\n Provide exactly one of email or domain. role is editor, commenter, or viewer. Consumer email\n providers (gmail.com, ...) are rejected with 422. Re-granting the same target+role is\n idempotent (200 with unchanged:true). Email grants send the grantee a share-notification\n email containing ONE single-use, 7-day login link with next=/d/:slug; set notify:false to\n suppress it. DOMAIN grants NEVER notify (notify is ignored for them).\n operationId: createGrant\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantBody'\n responses:\n '200':\n description: Idempotent re-grant (same target + role)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantUnchangedResponse'\n '201':\n description: Grant created\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantCreatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: A resource quota was exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '422':\n description: Consumer email domain rejected\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/grants/{id}:\n delete:\n tags:\n - sharing\n summary: Revoke a grant (owner only)\n operationId: deleteGrant\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/GrantId'\n responses:\n '200':\n description: Grant revoked\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantDeletedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/comments:\n get:\n tags:\n - collaboration\n summary: List all comment threads (the complete all-threads view)\n description: >-\n Returns every live thread the caller can see, exactly as the viewer shell shows humans:\n anchored threads in document order, then doc-level threads, then orphaned threads, each\n carrying resolved/orphaned flags, 1-level replies, and reactions. Read access required\n (owner/grant via identity, a valid view token, or a public doc).\n operationId: listComments\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - schema:\n type: string\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not\n needed for owner/grantee sessions or API keys.\n required: false\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not needed\n for owner/grantee sessions or API keys.\n name: viewtoken\n in: query\n responses:\n '200':\n description: All threads\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentsListResponse'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n post:\n tags:\n - collaboration\n summary: Post a comment (anchored to a quote, doc-level, or a reply)\n description: >-\n Comment on a span by QUOTING it (anchor), at the doc level (omit anchor), or reply to a root\n comment (parent_id). Identity required: API key OR signed-in session — anonymous never\n writes. Permission to comment: owner, editor or commenter grant, view-token holder with\n identity, or any identity on a public doc.\n operationId: createComment\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - schema:\n type: string\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not\n needed for owner/grantee sessions or API keys.\n required: false\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not needed\n for owner/grantee sessions or API keys.\n name: viewtoken\n in: query\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CreateCommentBody'\n responses:\n '201':\n description: Created\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentCreatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Can view but not comment (e.g. a viewer-only grant)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: Comment body exceeds 10 KB\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/comments/{id}:\n patch:\n tags:\n - collaboration\n summary: Edit body (author) and/or resolve/unresolve (anyone who can comment)\n operationId: updateComment\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/CommentId'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/UpdateCommentBody'\n responses:\n '200':\n description: Updated\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentUpdatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Editing another author's body, or resolving without comment rights\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n delete:\n tags:\n - collaboration\n summary: Soft-delete a comment (author own, owner any)\n operationId: deleteComment\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/CommentId'\n responses:\n '200':\n description: Deleted\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentDeletedResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Not the author and not the owner\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/reactions:\n post:\n tags:\n - collaboration\n summary: React to a doc, a comment, or a quoted span (attributed; re-post toggles off)\n description: >-\n Add an emoji reaction. The target is 3-way and MUTUALLY EXCLUSIVE: comment_id set → react on\n that comment; anchor set → react on a text span (W3C text-quote, same shape + validation as\n a comment anchor; an agent \"highlights\" by quoting); neither set → react on the whole\n document. Supplying BOTH comment_id and anchor → 400. Attributed-only (identity required);\n unique per (target, author, emoji) — for span reactions the \"target\" is the anchor\n signature, so the same emoji on two different spans are two distinct reactions. Re-posting\n the same reaction removes it (toggle). Anchored reactions re-anchor on every doc edit\n exactly like comments (move, or orphan + later un-orphan); an orphaned anchored reaction\n degrades to doc-level display. React permission: anyone who can view, with identity.\n operationId: addReaction\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CreateReactionBody'\n responses:\n '200':\n description: Reaction toggled off (the same reaction already existed)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ReactionToggledResponse'\n '201':\n description: Reaction added\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ReactionCreatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '422':\n description: comment_id does not reference a live comment on this document\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/reactions/{id}:\n delete:\n tags:\n - collaboration\n summary: Remove your own reaction\n operationId: deleteReaction\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/ReactionId'\n responses:\n '200':\n description: Removed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ReactionDeletedResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /agent/identity:\n post:\n tags:\n - auth\n summary: Start a service_auth registration\n description: >-\n Creates a pending registration (no user account is created yet), emails the human a 6-digit\n code, and returns a claim_token plus a claim block. There is exactly one flow: justhtml.sh\n emails the login_hint the code (the code and nothing else — no links). The user_code is\n NEVER returned in the response (the email is the binding proof). The human reads the code\n back to the agent, which submits it to POST /agent/identity/claim/complete; the agent then\n polls /oauth2/token for the key. There is no claim_delivery parameter, no approve link, and\n no hosted claim form.\n operationId: startRegistration\n security: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/StartRegistrationBody'\n responses:\n '200':\n description: Pending registration created; code emailed to the human\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/StartRegistrationResponse'\n '400':\n description: >-\n Bad body, bad login_hint, unsupported type, or a now-removed parameter (claim_delivery\n is rejected with invalid_request).\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '503':\n description: >-\n email_send_failed — the code email could not be sent; the registration is voided. Retry\n registration.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n /agent/identity/claim:\n post:\n tags:\n - auth\n summary: Re-mint an expired code\n description: >-\n Invalidates the prior code and emails a fresh 6-digit code (the 24h registration window must\n still be open). A corrected email updates the registration's login_hint. The new code is NOT\n returned in the response — it goes to the human's inbox.\n operationId: remintClaim\n security: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/RemintClaimBody'\n responses:\n '200':\n description: Fresh code emailed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/RemintClaimResponse'\n '400':\n description: Bad body\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '401':\n description: Unknown claim_token\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '409':\n description: Already claimed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '410':\n description: Registration window closed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n /agent/identity/claim/complete:\n post:\n tags:\n - auth\n summary: Complete a claim by reading the emailed code back\n description: >-\n The human reads the 6-digit code from the emailed message back to the agent, which submits\n it here to confirm the claim WITHOUT a browser session (the binding proof is that the code\n only reached the human via their inbox). Constant-time compare; 5 wrong attempts kill the\n code (410 code_dead), then re-mint via POST /agent/identity/claim. On success the agent's\n /oauth2/token poll returns the key.\n operationId: completeClaim\n security: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CompleteClaimBody'\n responses:\n '200':\n description: Claim confirmed; poll /oauth2/token for the key\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CompleteClaimResponse'\n '400':\n description: Bad body\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '401':\n description: >-\n invalid_claim_token (unknown token) or invalid_user_code (wrong code; message names\n attempts remaining).\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '409':\n description: claimed_or_in_flight (already claimed).\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '410':\n description: >-\n claim_expired (registration window closed), code_dead (5 wrong attempts), or\n expired_token (user_code window closed). Re-mint.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n /oauth2/token:\n post:\n tags:\n - auth\n summary: Poll the claim grant for the API key\n description: >-\n RFC 8628-style polling. While the human has not finished, returns 400 authorization_pending\n (or slow_down if polled under 5s apart). On confirm, returns the long-lived API key exactly\n once.\n operationId: claimGrantToken\n security: []\n requestBody:\n required: true\n content:\n application/x-www-form-urlencoded:\n schema:\n $ref: '#/components/schemas/TokenForm'\n responses:\n '200':\n description: Credential issued (once)\n headers:\n Cache-Control:\n schema:\n type: string\n description: no-store\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/TokenResponse'\n '400':\n description: >-\n OAuth error envelope. error one of: authorization_pending, slow_down, expired_token,\n invalid_grant, invalid_request, unsupported_grant_type.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n /oauth2/revoke:\n post:\n tags:\n - auth\n summary: Revoke an API key (RFC 7009)\n description: Idempotent. Returns 200 with an empty body whether or not the token existed.\n operationId: revokeToken\n security: []\n requestBody:\n required: true\n content:\n application/x-www-form-urlencoded:\n schema:\n $ref: '#/components/schemas/RevokeForm'\n responses:\n '200':\n description: Revoked (or no-op); empty body\n '400':\n description: Malformed body\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n /.well-known/oauth-protected-resource:\n get:\n tags:\n - discovery\n summary: RFC 9728 protected-resource metadata\n operationId: protectedResourceMetadata\n security: []\n responses:\n '200':\n description: Resource metadata\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ProtectedResourceMetadata'\n /.well-known/oauth-authorization-server:\n get:\n tags:\n - discovery\n summary: RFC 8414 authorization-server metadata (with agent_auth block)\n operationId: authServerMetadata\n security: []\n responses:\n '200':\n description: Authorization-server metadata\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AuthServerMetadata'\nwebhooks: {}\n"; +export const SPEC_YAML = "openapi: 3.1.0\ninfo:\n title: justhtml.sh API\n version: 1.0.0\n description: |\n An agent-first minimal HTML document host. Agents self-onboard via the\n auth.md service_auth flow (see https://justhtml.sh/auth.md), receive a\n long-lived API key, and publish HTML documents to stable URLs.\n\n Terse usage with curl examples: https://justhtml.sh/llms.txt\n license:\n name: Proprietary\n url: https://justhtml.sh/\nservers:\n - url: https://justhtml.sh\n description: Production\ntags:\n - name: auth\n description: auth.md service_auth registration + OAuth token/revoke\n - name: discovery\n description: Machine-readable OAuth discovery metadata\n - name: docs\n description: Document CRUD, patch editing, versions\n - name: sharing\n description: Per-document grants (email or domain)\n - name: collaboration\n description: Comments (W3C text-quote anchors, 1-level threads) and reactions\nsecurity:\n - bearerApiKey: []\ncomponents:\n securitySchemes:\n bearerApiKey:\n type: http\n scheme: bearer\n bearerFormat: jh_live_...\n description: >-\n Long-lived API key obtained via the auth.md service_auth flow. Carries scopes docs.read\n docs.write. 401s include a WWW-Authenticate header pointing at the protected-resource\n metadata.\n schemas:\n CreateDocBody:\n type: object\n properties:\n html:\n type: string\n description: The document HTML.\n example: <h1>Hello</h1>\n title:\n type:\n - string\n - 'null'\n maxLength: 300\n description: Optional document title.\n example: My doc\n public:\n type: boolean\n default: false\n description: Whether the document is public.\n required:\n - html\n description: Create a document. html is required; title and public are optional.\n UpdateDocBody:\n type: object\n properties:\n html:\n type: string\n description: Replacement HTML (full rewrite, bumps version).\n example: <h1>Hi</h1>\n title:\n type:\n - string\n - 'null'\n maxLength: 300\n description: New title, or null to clear it.\n public:\n type: boolean\n description: New visibility flag (owner only).\n description: >-\n Update html (full rewrite), title, or visibility. At least one field is required. Editors\n may rewrite html; only the owner may change title or public.\n OwnerDoc:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n version:\n type: integer\n public:\n type: boolean\n view_token:\n type: string\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - url\n - title\n - version\n - public\n - view_token\n - created_at\n - updated_at\n description: Document as seen by its owner (includes view_token).\n GranteeDoc:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n version:\n type: integer\n public:\n type: boolean\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - url\n - title\n - version\n - public\n - role\n - created_at\n - updated_at\n description: Document as seen by a non-owner grantee (role instead of view_token).\n DocWithHtml:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n version:\n type: integer\n public:\n type: boolean\n view_token:\n type: string\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - url\n - title\n - version\n - public\n - created_at\n - updated_at\n description: >-\n Owner sees view_token; a grantee sees role (editor/commenter/viewer) instead. html is\n included on single-doc fetches and after writes.\n DocListItem:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type: string\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n title:\n type:\n - string\n - 'null'\n access:\n type: string\n enum:\n - owner\n - editor\n - commenter\n - viewer\n description: >-\n The caller's access to this doc. owner for docs you own; otherwise the resolved grant\n role (an explicit email grant beats a domain grant for the same email).\n version:\n type: integer\n public:\n type: boolean\n comment_count:\n type: integer\n description: >-\n Live (non-deleted) comments + replies on the doc. 0 when there are none. The /docs\n dashboard surfaces the same count.\n view_token:\n type: string\n description: Present only when access=owner.\n created_at:\n type: string\n format: date-time\n updated_at:\n type: string\n format: date-time\n required:\n - slug\n - url\n - title\n - access\n - version\n - public\n - comment_count\n - created_at\n - updated_at\n description: >-\n A document as returned by GET /api/v1/docs (any scope). Carries access\n (owner|editor|commenter|viewer). Owned items (access=owner) additionally carry view_token;\n shared items omit it.\n DocListResponse:\n type: object\n properties:\n docs:\n type: array\n items:\n $ref: '#/components/schemas/DocListItem'\n required:\n - docs\n description: The matched documents.\n DeleteDocResponse:\n type: object\n properties:\n slug:\n type: string\n deleted:\n type: boolean\n required:\n - slug\n - deleted\n description: Soft-delete acknowledgement.\n ApiError:\n type: object\n properties:\n error:\n type: string\n message:\n type: string\n required:\n - error\n - message\n additionalProperties: {}\n description: 'Structured API error: { error, message, ...extra }.'\n GrantBody:\n type: object\n properties:\n email:\n type:\n - string\n - 'null'\n format: email\n description: Grantee email (provide exactly one of email or domain).\n domain:\n type:\n - string\n - 'null'\n example: kernel.sh\n description: Grantee email-domain (provide exactly one of email or domain).\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n description: Grant role.\n notify:\n type: boolean\n default: true\n description: >-\n Email-grants only. Send the grantee a share-notification email (default true). Ignored\n for domain grants.\n required:\n - role\n description: >-\n Share with an email or a domain. Provide exactly one of email or domain. role is editor,\n commenter, or viewer. notify (email grants only) defaults to true.\n Grant:\n type: object\n properties:\n id:\n type: integer\n grantee_type:\n type: string\n enum:\n - email\n - domain\n grantee:\n type: string\n role:\n type: string\n enum:\n - editor\n - commenter\n - viewer\n created_at:\n type: string\n format: date-time\n required:\n - id\n - grantee_type\n - grantee\n - role\n - created_at\n description: A single grant (email or domain) on a document.\n GrantListResponse:\n type: object\n properties:\n slug:\n type: string\n grants:\n type: array\n items:\n $ref: '#/components/schemas/Grant'\n count:\n type: integer\n max:\n type: integer\n example: 50\n required:\n - slug\n - grants\n - count\n - max\n description: Grants on the document (owner only).\n GrantCreatedResponse:\n type: object\n properties:\n slug:\n type: string\n grant:\n $ref: '#/components/schemas/Grant'\n notified:\n type: boolean\n description: >-\n Present only for email grants: true if the share-notification email was sent, false if\n suppressed (notify:false) or skipped (rate-limited / send failed).\n required:\n - slug\n - grant\n description: Grant created.\n GrantUnchangedResponse:\n type: object\n properties:\n slug:\n type: string\n grant:\n $ref: '#/components/schemas/Grant'\n unchanged:\n type: boolean\n required:\n - slug\n - grant\n - unchanged\n description: Idempotent re-grant (same target + role).\n GrantDeletedResponse:\n type: object\n properties:\n slug:\n type: string\n grant_id:\n type: integer\n deleted:\n type: boolean\n required:\n - slug\n - grant_id\n - deleted\n description: Grant revoked.\n VersionMeta:\n type: object\n properties:\n version:\n type: integer\n edit_kind:\n type: string\n enum:\n - create\n - patch\n - rewrite\n author_user_id:\n type:\n - integer\n - 'null'\n description: User who authored this version (null for legacy/system writes).\n patch:\n type: array\n items:\n type: object\n properties:\n oldText:\n type: string\n newText:\n type: string\n required:\n - oldText\n - newText\n description: >-\n The edits payload as requested, present only when edit_kind=patch (the list of\n {oldText,newText} applied). Omitted otherwise.\n bytes:\n type: integer\n created_at:\n type: string\n format: date-time\n required:\n - version\n - edit_kind\n - author_user_id\n - bytes\n - created_at\n description: Metadata for one retained version (no html).\n VersionListResponse:\n type: object\n properties:\n slug:\n type: string\n current_version:\n type: integer\n versions:\n type: array\n items:\n $ref: '#/components/schemas/VersionMeta'\n required:\n - slug\n - current_version\n - versions\n description: Version metadata (no html), newest first.\n VersionSnapshot:\n type: object\n properties:\n slug:\n type: string\n version:\n type: integer\n edit_kind:\n type: string\n enum:\n - create\n - patch\n - rewrite\n author_user_id:\n type:\n - integer\n - 'null'\n patch:\n type: array\n items:\n type: object\n properties:\n oldText:\n type: string\n newText:\n type: string\n required:\n - oldText\n - newText\n bytes:\n type: integer\n created_at:\n type: string\n format: date-time\n html:\n type: string\n required:\n - slug\n - version\n - edit_kind\n - author_user_id\n - bytes\n - created_at\n - html\n description: A version's metadata plus its full html snapshot.\n EditsBody:\n type: object\n properties:\n edits:\n type: array\n items:\n type: object\n properties:\n oldText:\n type: string\n newText:\n type: string\n required:\n - oldText\n - newText\n minItems: 1\n maxItems: 200\n description: The patches to apply, in order. 1–200 edits.\n base_version:\n type:\n - integer\n - 'null'\n minimum: 1\n description: The version the edits were derived against; a mismatch returns 409.\n required:\n - edits\n description: >-\n Apply deterministic patches. edits is a non-empty list of {oldText,newText}. Always send\n base_version; a mismatch returns 409.\n TextAnchor:\n type: object\n properties:\n type:\n type: string\n enum:\n - text\n exact:\n type: string\n example: deterministic compaction\n prefix:\n type: string\n example: 'record store with '\n suffix:\n type: string\n example: .\n start:\n type: integer\n end:\n type: integer\n required:\n - exact\n description: >-\n W3C text-quote selector (TextQuoteSelector + position hint). exact is the verbatim quoted\n passage; prefix/suffix (~32 chars) disambiguate repeated text and survive surrounding\n shifts; start/end are offsets into the document's text content (a fast-path hint, not\n authoritative).\n CreateCommentBody:\n type: object\n properties:\n body:\n type: string\n description: Comment text (<= 10 KB).\n example: is this right?\n anchor:\n description: W3C text-quote selector; null/omitted = doc-level.\n parent_id:\n type: integer\n description: Root comment id to reply to (1-level threads only).\n required:\n - body\n description: >-\n Comment on a span by QUOTING it (anchor), at the doc level (omit anchor), or reply to a root\n comment (parent_id).\n UpdateCommentBody:\n type: object\n properties:\n body:\n type: string\n description: Author only. The new comment text (<= 10 KB).\n resolved:\n type: boolean\n description: Resolve/unresolve. Anyone who can comment.\n description: >-\n Edit body (author) and/or resolve/unresolve (anyone who can comment). At least one field is\n required.\n CreateReactionBody:\n type: object\n properties:\n emoji:\n type: string\n enum:\n - 👍\n - 👎\n - 🎉\n - 🤔\n - ❤️\n - 🚀\n - 👀\n - 😄\n - 🙏\n - 🔥\n - ✅\n - 💯\n description: >-\n One of the curated set: 👍 👎 🎉 🤔 ❤️ 🚀 👀 😄 🙏 🔥 ✅ 💯. Anything else → 400\n invalid_request with an \"allowed\" array listing the full set.\n example: 🚀\n comment_id:\n type: integer\n description: Target comment; omit/null = not a comment reaction. Mutually exclusive with anchor.\n anchor:\n description: >-\n Target span (W3C text-quote selector). Mutually exclusive with comment_id; omit/null =\n react on the doc (or comment).\n required:\n - emoji\n description: >-\n Add an emoji reaction. The target is 3-way and mutually exclusive: comment_id (a comment),\n anchor (a span), or neither (the whole doc). Supplying both comment_id and anchor → 400.\n ReactionGroup:\n type: object\n properties:\n emoji:\n type: string\n count:\n type: integer\n authors:\n type: array\n items:\n type: string\n description: Author email.\n required:\n - emoji\n - count\n - authors\n description: Reactions collapsed by emoji, with the attributed authors.\n AnchoredReactionGroup:\n type: object\n properties:\n sig:\n type: string\n description: Anchor signature (prefix|exact|suffix) — the grouping key.\n anchor:\n $ref: '#/components/schemas/TextAnchor'\n anchored_version:\n type:\n - integer\n - 'null'\n reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n required:\n - sig\n - anchor\n - anchored_version\n - reactions\n description: >-\n All reactions on one text span, grouped by anchor signature, then collapsed per emoji. The\n viewer paints one highlight on the span and a chip per emoji at the span's end.\n Comment:\n type: object\n properties:\n id:\n type: integer\n parent_id:\n type:\n - integer\n - 'null'\n author:\n type:\n - string\n - 'null'\n description: Author email.\n author_avatar:\n type:\n - string\n - 'null'\n format: uri\n description: Gravatar URL.\n body:\n type: string\n anchor:\n allOf:\n - $ref: '#/components/schemas/TextAnchor'\n - type:\n - object\n - 'null'\n anchored_version:\n type:\n - integer\n - 'null'\n orphaned:\n type: boolean\n description: Anchor no longer resolves; kept, shown unanchored.\n resolved:\n type: boolean\n resolved_at:\n type:\n - string\n - 'null'\n format: date-time\n created_at:\n type: string\n format: date-time\n edited_at:\n type:\n - string\n - 'null'\n format: date-time\n reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n required:\n - id\n - parent_id\n - author\n - author_avatar\n - body\n - anchor\n - anchored_version\n - orphaned\n - resolved\n - resolved_at\n - created_at\n - edited_at\n - reactions\n description: A single comment (with its aggregated reactions).\n CommentThread:\n type: object\n properties:\n id:\n type: integer\n parent_id:\n type:\n - integer\n - 'null'\n author:\n type:\n - string\n - 'null'\n author_avatar:\n type:\n - string\n - 'null'\n body:\n type: string\n anchor:\n allOf:\n - $ref: '#/components/schemas/TextAnchor'\n - type:\n - object\n - 'null'\n anchored_version:\n type:\n - integer\n - 'null'\n orphaned:\n type: boolean\n resolved:\n type: boolean\n resolved_at:\n type:\n - string\n - 'null'\n created_at:\n type: string\n format: date-time\n edited_at:\n type:\n - string\n - 'null'\n reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n group:\n type: string\n enum:\n - anchored\n - doc\n - orphaned\n description: Which group this thread sorts into in the all-threads view.\n replies:\n type: array\n items:\n $ref: '#/components/schemas/Comment'\n required:\n - id\n - parent_id\n - author\n - author_avatar\n - body\n - anchor\n - anchored_version\n - orphaned\n - resolved\n - resolved_at\n - created_at\n - edited_at\n - reactions\n - group\n - replies\n description: A root comment with its group tag and 1-level replies.\n CommentsListResponse:\n type: object\n properties:\n slug:\n type: string\n version:\n type: integer\n total:\n type: integer\n description: Live comment + reply count.\n can_comment:\n type: boolean\n can_react:\n type: boolean\n threads:\n type: array\n items:\n $ref: '#/components/schemas/CommentThread'\n doc_reactions:\n type: array\n items:\n $ref: '#/components/schemas/ReactionGroup'\n description: >-\n Doc-level reactions (present only when any exist). Includes orphaned anchored reactions\n degraded to doc-level.\n anchored_reactions:\n type: array\n items:\n $ref: '#/components/schemas/AnchoredReactionGroup'\n description: >-\n Span reactions grouped by anchor signature, in document order, so clients stack/count\n without re-grouping (present only when any exist).\n required:\n - slug\n - version\n - total\n - can_comment\n - can_react\n - threads\n description: The complete all-threads view.\n CommentCreatedResponse:\n type: object\n properties:\n comment:\n $ref: '#/components/schemas/Comment'\n notified:\n type: integer\n description: >-\n How many notification emails were sent for this comment (the owner on a top-level\n comment; the owner plus thread participants on a reply, minus the author). 0 when there\n is no one to notify or sends were suppressed.\n required:\n - comment\n - notified\n description: Comment created.\n CommentUpdatedResponse:\n type: object\n properties:\n comment:\n $ref: '#/components/schemas/Comment'\n required:\n - comment\n description: Comment updated.\n CommentDeletedResponse:\n type: object\n properties:\n id:\n type: integer\n deleted:\n type: boolean\n required:\n - id\n - deleted\n description: Comment soft-deleted.\n ReactionCreatedResponse:\n type: object\n properties:\n reaction:\n type: object\n properties:\n id:\n type: integer\n comment_id:\n type:\n - integer\n - 'null'\n anchor:\n allOf:\n - $ref: '#/components/schemas/TextAnchor'\n - type:\n - object\n - 'null'\n anchored_version:\n type:\n - integer\n - 'null'\n orphaned:\n type: boolean\n emoji:\n type: string\n author:\n type:\n - string\n - 'null'\n created_at:\n type: string\n format: date-time\n required:\n - id\n - comment_id\n - anchor\n - anchored_version\n - orphaned\n - emoji\n - author\n - created_at\n required:\n - reaction\n description: Reaction added.\n ReactionToggledResponse:\n type: object\n properties:\n toggled:\n type: boolean\n removed:\n type: boolean\n required:\n - toggled\n - removed\n description: Reaction toggled off (the same reaction already existed).\n ReactionDeletedResponse:\n type: object\n properties:\n id:\n type: integer\n deleted:\n type: boolean\n required:\n - id\n - deleted\n description: Reaction removed.\n ClaimBlock:\n type: object\n properties:\n complete_url:\n type: string\n format: uri\n description: POST {claim_token, user_code} here to complete the claim.\n expires_in:\n type: integer\n example: 600\n interval:\n type: integer\n example: 5\n required:\n - complete_url\n - expires_in\n - interval\n description: >-\n The claim block. The user_code is intentionally omitted — it is emailed to the human (the\n only place it appears). The human reads it back to the agent, which POSTs {claim_token,\n user_code} to complete_url (/agent/identity/claim/complete).\n AgentError:\n type: object\n properties:\n error:\n type: string\n message:\n type: string\n required:\n - error\n - message\n description: 'Agent ceremony error: { error, message }.'\n OAuthError:\n type: object\n properties:\n error:\n type: string\n error_description:\n type: string\n required:\n - error\n description: 'OAuth error envelope (RFC 6749): { error, error_description? }.'\n StartRegistrationBody:\n type: object\n properties:\n type:\n type: string\n enum:\n - service_auth\n description: The registration type.\n login_hint:\n type: string\n format: email\n example: you@example.com\n description: The human's email address.\n required:\n - type\n - login_hint\n description: Start a service_auth registration; the 6-digit code is emailed to login_hint.\n RemintClaimBody:\n type: object\n properties:\n claim_token:\n type: string\n email:\n type: string\n format: email\n description: Corrected email; updates the registration's login_hint.\n required:\n - claim_token\n - email\n description: Re-mint an expired code; a fresh code is emailed to the human.\n CompleteClaimBody:\n type: object\n properties:\n claim_token:\n type: string\n user_code:\n type: string\n pattern: ^[0-9]{6}$\n example: '428117'\n required:\n - claim_token\n - user_code\n description: Complete a claim by reading the emailed 6-digit code back to the agent.\n TokenForm:\n type: object\n properties:\n grant_type:\n type: string\n enum:\n - urn:workos:agent-auth:grant-type:claim\n description: The claim grant type.\n claim_token:\n type: string\n required:\n - grant_type\n - claim_token\n description: Claim-grant token request (form-encoded).\n RevokeForm:\n type: object\n properties:\n token:\n type: string\n token_type_hint:\n type: string\n enum:\n - access_token\n required:\n - token\n description: RFC 7009 revocation request (form-encoded).\n StartRegistrationResponse:\n type: object\n properties:\n registration_id:\n type: string\n registration_type:\n type: string\n enum:\n - service_auth\n claim_url:\n type: string\n format: uri\n claim_token:\n type: string\n description: Secret; returned once. Hold in memory only.\n claim_token_expires:\n type: string\n format: date-time\n post_claim_scopes:\n type: array\n items:\n type: string\n example:\n - docs.read\n - docs.write\n claim:\n $ref: '#/components/schemas/ClaimBlock'\n required:\n - registration_id\n - registration_type\n - claim_url\n - claim_token\n - claim_token_expires\n - post_claim_scopes\n - claim\n description: Pending registration created; code emailed to the human.\n RemintClaimResponse:\n type: object\n properties:\n registration_id:\n type: string\n claim_attempt_id:\n type: string\n status:\n type: string\n example: initiated\n claim_attempt:\n $ref: '#/components/schemas/ClaimBlock'\n required:\n - registration_id\n - claim_attempt_id\n - status\n - claim_attempt\n description: Fresh code emailed.\n CompleteClaimResponse:\n type: object\n properties:\n registration_id:\n type: string\n status:\n type: string\n example: claimed\n message:\n type: string\n required:\n - registration_id\n - status\n - message\n description: Claim confirmed; poll /oauth2/token for the key.\n TokenResponse:\n type: object\n properties:\n access_token:\n type: string\n example: jh_live_...\n token_type:\n type: string\n enum:\n - Bearer\n scope:\n type: string\n example: docs.read docs.write\n credential_type:\n type: string\n enum:\n - api_key\n registration_id:\n type: string\n required:\n - access_token\n - token_type\n - scope\n - credential_type\n - registration_id\n description: Credential issued (once).\n ProtectedResourceMetadata:\n type: object\n properties: {}\n additionalProperties: {}\n description: RFC 9728 protected-resource metadata.\n AuthServerMetadata:\n type: object\n properties: {}\n additionalProperties: {}\n description: RFC 8414 authorization-server metadata (with agent_auth block).\n Slug:\n type: string\n example: fierce-tiger-12345\n VersionNum:\n type: integer\n minimum: 1\n example: 3\n GrantId:\n type: integer\n minimum: 1\n example: 1\n CommentId:\n type: integer\n minimum: 1\n example: 42\n ReactionId:\n type: integer\n minimum: 1\n example: 7\n parameters:\n Slug:\n schema:\n $ref: '#/components/schemas/Slug'\n required: true\n name: slug\n in: path\n VersionNum:\n schema:\n $ref: '#/components/schemas/VersionNum'\n required: true\n name: 'n'\n in: path\n GrantId:\n schema:\n $ref: '#/components/schemas/GrantId'\n required: true\n name: id\n in: path\n CommentId:\n schema:\n $ref: '#/components/schemas/CommentId'\n required: true\n name: id\n in: path\n ReactionId:\n schema:\n $ref: '#/components/schemas/ReactionId'\n required: true\n name: id\n in: path\npaths:\n /api/v1/docs:\n post:\n tags:\n - docs\n summary: Create a document\n operationId: createDoc\n security:\n - bearerApiKey: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CreateDocBody'\n responses:\n '201':\n description: Created\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OwnerDoc'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: A resource quota was exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: HTML exceeds the 2 MB per-document size limit\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n get:\n tags:\n - docs\n summary: List documents (owned, shared, or both)\n description: >-\n Lists documents by scope. Every item carries an access role (owner|editor|commenter|viewer).\n For a doc matched by both an email grant and a domain grant, the email grant wins\n (precedence ladder). Owned items additionally carry view_token; shared items do not (the\n view token is an owner-only capability). The web equivalent for a signed-in human is\n https://justhtml.sh/docs.\n operationId: listDocs\n security:\n - bearerApiKey: []\n parameters:\n - schema:\n type: string\n enum:\n - owned\n - shared\n - all\n default: owned\n description: >-\n owned (default): docs the caller owns. shared: docs granted to the caller's email or\n email-domain, excluding docs the caller owns. all: owned then shared.\n required: false\n description: >-\n owned (default): docs the caller owns. shared: docs granted to the caller's email or\n email-domain, excluding docs the caller owns. all: owned then shared.\n name: scope\n in: query\n - schema:\n type: integer\n minimum: 1\n maximum: 500\n default: 100\n required: false\n name: limit\n in: query\n responses:\n '200':\n description: The matched documents\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocListResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}:\n get:\n tags:\n - docs\n summary: Fetch a document (metadata + html)\n operationId: getDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Owner sees view_token; a grantee sees role instead of view_token.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocWithHtml'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n patch:\n tags:\n - docs\n summary: Update html (full rewrite), title, or visibility\n description: >-\n Owner or editor grant may rewrite html. Only the owner may change title or public\n (visibility).\n operationId: updateDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/UpdateDocBody'\n responses:\n '200':\n description: Updated\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocWithHtml'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Editor tried to change title/visibility\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: HTML exceeds the 2 MB per-document size limit\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n delete:\n tags:\n - docs\n summary: Soft-delete a document (owner only)\n operationId: deleteDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Deleted\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DeleteDocResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/edits:\n post:\n tags:\n - docs\n summary: Apply deterministic patches\n description: >-\n exact-match-then-fuzzy edit application. Owner or editor grant. Always send base_version; a\n mismatch returns 409. Ambiguous, no-match, or overlapping edits return 422 naming the\n failing edit index.\n operationId: editDoc\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/EditsBody'\n responses:\n '200':\n description: Patched\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/DocWithHtml'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '409':\n description: base_version conflict\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: HTML exceeds the 2 MB per-document size limit\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '422':\n description: An edit could not be applied deterministically\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/rotate-token:\n post:\n tags:\n - docs\n summary: Rotate the view token (un-share; owner only)\n operationId: rotateViewToken\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: New view token issued\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OwnerDoc'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/versions:\n get:\n tags:\n - docs\n summary: List retained version history (newest first)\n operationId: listVersions\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Version metadata (no html)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/VersionListResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/versions/{n}:\n get:\n tags:\n - docs\n summary: Fetch a specific version's full html\n operationId: getVersion\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/VersionNum'\n responses:\n '200':\n description: Version snapshot with html\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/VersionSnapshot'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/grants:\n get:\n tags:\n - sharing\n summary: List grants (owner only)\n operationId: listGrants\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Grants on the document\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantListResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n post:\n tags:\n - sharing\n summary: Share with an email or a domain (owner only)\n description: >-\n Provide exactly one of email or domain. role is editor, commenter, or viewer. Consumer email\n providers (gmail.com, ...) are rejected with 422. Re-granting the same target+role is\n idempotent (200 with unchanged:true). Email grants send the grantee a share-notification\n email containing ONE single-use, 7-day login link with next=/d/:slug; set notify:false to\n suppress it. DOMAIN grants NEVER notify (notify is ignored for them).\n operationId: createGrant\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantBody'\n responses:\n '200':\n description: Idempotent re-grant (same target + role)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantUnchangedResponse'\n '201':\n description: Grant created\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantCreatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: A resource quota was exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '422':\n description: Consumer email domain rejected\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/grants/{id}:\n delete:\n tags:\n - sharing\n summary: Revoke a grant (owner only)\n operationId: deleteGrant\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/GrantId'\n responses:\n '200':\n description: Grant revoked\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/GrantDeletedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/comments:\n get:\n tags:\n - collaboration\n summary: List all comment threads (the complete all-threads view)\n description: >-\n Returns every live thread the caller can see, exactly as the viewer shell shows humans:\n anchored threads in document order, then doc-level threads, then orphaned threads, each\n carrying resolved/orphaned flags, 1-level replies, and reactions. Read access required\n (owner/grant via identity, a valid view token, or a public doc).\n operationId: listComments\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - schema:\n type: string\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not\n needed for owner/grantee sessions or API keys.\n required: false\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not needed\n for owner/grantee sessions or API keys.\n name: viewtoken\n in: query\n responses:\n '200':\n description: All threads\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentsListResponse'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n post:\n tags:\n - collaboration\n summary: Post a comment (anchored to a quote, doc-level, or a reply)\n description: >-\n Comment on a span by QUOTING it (anchor), at the doc level (omit anchor), or reply to a root\n comment (parent_id). Identity required: API key OR signed-in session — anonymous never\n writes. Permission to comment: owner, editor or commenter grant, view-token holder with\n identity, or any identity on a public doc.\n operationId: createComment\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - schema:\n type: string\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not\n needed for owner/grantee sessions or API keys.\n required: false\n description: >-\n Present a doc's view token to comment/read as a token-holder (with identity). Not needed\n for owner/grantee sessions or API keys.\n name: viewtoken\n in: query\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CreateCommentBody'\n responses:\n '201':\n description: Created\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentCreatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Can view but not comment (e.g. a viewer-only grant)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '413':\n description: Comment body exceeds 10 KB\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/comments/{id}:\n patch:\n tags:\n - collaboration\n summary: Edit body (author) and/or resolve/unresolve (anyone who can comment)\n operationId: updateComment\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/CommentId'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/UpdateCommentBody'\n responses:\n '200':\n description: Updated\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentUpdatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Editing another author's body, or resolving without comment rights\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n delete:\n tags:\n - collaboration\n summary: Soft-delete a comment (author own, owner any)\n operationId: deleteComment\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/CommentId'\n responses:\n '200':\n description: Deleted\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CommentDeletedResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '403':\n description: Not the author and not the owner\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/reactions:\n post:\n tags:\n - collaboration\n summary: React to a doc, a comment, or a quoted span (attributed; re-post toggles off)\n description: >-\n Add an emoji reaction. The target is 3-way and MUTUALLY EXCLUSIVE: comment_id set → react on\n that comment; anchor set → react on a text span (W3C text-quote, same shape + validation as\n a comment anchor; an agent \"highlights\" by quoting); neither set → react on the whole\n document. Supplying BOTH comment_id and anchor → 400. Attributed-only (identity required);\n unique per (target, author, emoji) — for span reactions the \"target\" is the anchor\n signature, so the same emoji on two different spans are two distinct reactions. Re-posting\n the same reaction removes it (toggle). Anchored reactions re-anchor on every doc edit\n exactly like comments (move, or orphan + later un-orphan); an orphaned anchored reaction\n degrades to doc-level display. React permission: anyone who can view, with identity.\n operationId: addReaction\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CreateReactionBody'\n responses:\n '200':\n description: Reaction toggled off (the same reaction already existed)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ReactionToggledResponse'\n '201':\n description: Reaction added\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ReactionCreatedResponse'\n '400':\n description: Invalid request body or parameters\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '422':\n description: comment_id does not reference a live comment on this document\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /api/v1/docs/{slug}/reactions/{id}:\n delete:\n tags:\n - collaboration\n summary: Remove your own reaction\n operationId: deleteReaction\n security:\n - bearerApiKey: []\n - {}\n parameters:\n - $ref: '#/components/parameters/Slug'\n - $ref: '#/components/parameters/ReactionId'\n responses:\n '200':\n description: Removed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ReactionDeletedResponse'\n '401':\n description: Missing/invalid credential\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n '404':\n description: No such document (also returned for inaccessible docs; no existence oracle)\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ApiError'\n /agent/identity:\n post:\n tags:\n - auth\n summary: Start a service_auth registration\n description: >-\n Creates a pending registration (no user account is created yet), emails the human a 6-digit\n code, and returns a claim_token plus a claim block. There is exactly one flow: justhtml.sh\n emails the login_hint the code (the code and nothing else — no links). The user_code is\n NEVER returned in the response (the email is the binding proof). The human reads the code\n back to the agent, which submits it to POST /agent/identity/claim/complete; the agent then\n polls /oauth2/token for the key. There is no claim_delivery parameter, no approve link, and\n no hosted claim form.\n operationId: startRegistration\n security: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/StartRegistrationBody'\n responses:\n '200':\n description: Pending registration created; code emailed to the human\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/StartRegistrationResponse'\n '400':\n description: >-\n Bad body, bad login_hint, unsupported type, or a now-removed parameter (claim_delivery\n is rejected with invalid_request).\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '503':\n description: >-\n email_send_failed — the code email could not be sent; the registration is voided. Retry\n registration.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n /agent/identity/claim:\n post:\n tags:\n - auth\n summary: Re-mint an expired code\n description: >-\n Invalidates the prior code and emails a fresh 6-digit code (the 24h registration window must\n still be open). A corrected email updates the registration's login_hint. The new code is NOT\n returned in the response — it goes to the human's inbox.\n operationId: remintClaim\n security: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/RemintClaimBody'\n responses:\n '200':\n description: Fresh code emailed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/RemintClaimResponse'\n '400':\n description: Bad body\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '401':\n description: Unknown claim_token\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '409':\n description: Already claimed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '410':\n description: Registration window closed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n /agent/identity/claim/complete:\n post:\n tags:\n - auth\n summary: Complete a claim by reading the emailed code back\n description: >-\n The human reads the 6-digit code from the emailed message back to the agent, which submits\n it here to confirm the claim WITHOUT a browser session (the binding proof is that the code\n only reached the human via their inbox). Constant-time compare; 5 wrong attempts kill the\n code (410 code_dead), then re-mint via POST /agent/identity/claim. On success the agent's\n /oauth2/token poll returns the key.\n operationId: completeClaim\n security: []\n requestBody:\n required: true\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CompleteClaimBody'\n responses:\n '200':\n description: Claim confirmed; poll /oauth2/token for the key\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/CompleteClaimResponse'\n '400':\n description: Bad body\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '401':\n description: >-\n invalid_claim_token (unknown token) or invalid_user_code (wrong code; message names\n attempts remaining).\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '409':\n description: claimed_or_in_flight (already claimed).\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '410':\n description: >-\n claim_expired (registration window closed), code_dead (5 wrong attempts), or\n expired_token (user_code window closed). Re-mint.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AgentError'\n /oauth2/token:\n post:\n tags:\n - auth\n summary: Poll the claim grant for the API key\n description: >-\n RFC 8628-style polling. While the human has not finished, returns 400 authorization_pending\n (or slow_down if polled under 5s apart). On confirm, returns the long-lived API key exactly\n once.\n operationId: claimGrantToken\n security: []\n requestBody:\n required: true\n content:\n application/x-www-form-urlencoded:\n schema:\n $ref: '#/components/schemas/TokenForm'\n responses:\n '200':\n description: Credential issued (once)\n headers:\n Cache-Control:\n schema:\n type: string\n description: no-store\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/TokenResponse'\n '400':\n description: >-\n OAuth error envelope. error one of: authorization_pending, slow_down, expired_token,\n invalid_grant, invalid_request, unsupported_grant_type.\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n /oauth2/revoke:\n post:\n tags:\n - auth\n summary: Revoke an API key (RFC 7009)\n description: Idempotent. Returns 200 with an empty body whether or not the token existed.\n operationId: revokeToken\n security: []\n requestBody:\n required: true\n content:\n application/x-www-form-urlencoded:\n schema:\n $ref: '#/components/schemas/RevokeForm'\n responses:\n '200':\n description: Revoked (or no-op); empty body\n '400':\n description: Malformed body\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n '429':\n description: Rate limit exceeded\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OAuthError'\n /.well-known/oauth-protected-resource:\n get:\n tags:\n - discovery\n summary: RFC 9728 protected-resource metadata\n operationId: protectedResourceMetadata\n security: []\n responses:\n '200':\n description: Resource metadata\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/ProtectedResourceMetadata'\n /.well-known/oauth-authorization-server:\n get:\n tags:\n - discovery\n summary: RFC 8414 authorization-server metadata (with agent_auth block)\n operationId: authServerMetadata\n security: []\n responses:\n '200':\n description: Authorization-server metadata\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/AuthServerMetadata'\nwebhooks: {}\n"; diff --git a/lib/openapi/generated.yaml b/lib/openapi/generated.yaml index 5470e17..3cd358a 100644 --- a/lib/openapi/generated.yaml +++ b/lib/openapi/generated.yaml @@ -871,8 +871,15 @@ components: properties: comment: $ref: '#/components/schemas/Comment' + notified: + type: integer + description: >- + How many notification emails were sent for this comment (the owner on a top-level + comment; the owner plus thread participants on a reply, minus the author). 0 when there + is no one to notify or sends were suppressed. required: - comment + - notified description: Comment created. CommentUpdatedResponse: type: object From e4ddf5d3a4361ce9987d4396ab778d99de9dff79 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:44:25 +0000 Subject: [PATCH 2/4] Scope comment reply notifications to participants who still have access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply recipients were derived purely from comment-authorship history, so a thread participant who had since lost access (grant revoked, or doc flipped from public to private) kept receiving reply notifications — leaking thread activity and other participants' email addresses to someone who can no longer view the doc. Filter each non-owner participant through a live access check (public, or an owner/email/domain grant) before notifying; the owner is always notified. Also scope the reply parent-context lookup by doc_id and deleted_at IS NULL to match the rest of the comments module. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- lib/docs/comment-notify.test.ts | 94 +++++++++++++++++++++++++++++++++ lib/docs/comment-notify.ts | 26 ++++++--- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/lib/docs/comment-notify.test.ts b/lib/docs/comment-notify.test.ts index 44adb2a..272e6a3 100644 --- a/lib/docs/comment-notify.test.ts +++ b/lib/docs/comment-notify.test.ts @@ -80,14 +80,24 @@ type Rows = { rows: unknown[]; rowCount?: number }; * Route the orchestrator's queries by SQL shape. `opts` supplies the rows each * logical query returns; the token INSERT auto-assigns ids and records the * params so tests can assert on them. + * + * Reply participants are filtered through resolveAccess (a doc_grants lookup) on + * a private doc, so `grantedEmails` lists the participant emails that still hold + * a live grant (defaults to everyone returned by the participant query). A + * public doc short-circuits resolveAccess, so that lookup never fires there. */ function routeQuery(opts: { ownerRows?: Array<{ email: string }>; participantRows?: Array<{ id: number; email: string }>; parentRows?: Array<{ email: string | null; body: string }>; + grantedEmails?: string[]; onTokenInsert?: (params: unknown[]) => void; onTokenDelete?: (params: unknown[]) => void; }): void { + // Default: every participant still has access (keeps the access filter a no-op + // for tests that aren't exercising revocation). + const granted = + opts.grantedEmails ?? (opts.participantRows ?? []).map((p) => p.email.toLowerCase()); let nextTokenId = 9000; mocks.query.mockImplementation(async (sql: string, params?: unknown[]): Promise<Rows> => { if (sql.includes("FROM users WHERE id =")) { @@ -96,6 +106,12 @@ function routeQuery(opts: { if (sql.includes("SELECT DISTINCT u.id, u.email")) { return { rows: opts.participantRows ?? [] }; } + if (sql.includes("FROM doc_grants")) { + // resolveAccess: $2 is the lowercased principal email. Return a grant row + // iff this email is in the granted set. + const email = String(params?.[1] ?? "").toLowerCase(); + return { rows: granted.includes(email) ? [{ grantee_type: "email", role: "commenter" }] : [] }; + } if (sql.includes("SELECT u.email, c.body")) { return { rows: opts.parentRows ?? [] }; } @@ -193,6 +209,62 @@ describe("recipient model", () => { // No recipient is bob. expect(tos).not.toContain("bob@co.com"); }); + + it("reply: a participant who LOST access is dropped (owner still notified)", async () => { + // Carol authored in the thread while she had a grant, then the grant was + // revoked. She must NOT receive the reply (no post-revocation leakage); the + // owner still does. + const CAROL_ID = 4; + routeQuery({ + ownerRows: [{ email: "owner@co.com" }], + participantRows: [ + { id: OWNER_ID, email: "owner@co.com" }, + { id: CAROL_ID, email: "carol@ex.com" }, + ], + grantedEmails: [], // carol no longer holds a grant + parentRows: [{ email: "owner@co.com", body: "root body" }], + }); + const doc = makeDoc(); + const comment = makeComment({ + author_user_id: ALICE_ID, + author_email: "alice@co.com", + parent_id: 88421, + }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res.notified).toBe(1); + const tos = mocks.sendCommentEmail.mock.calls.map((c) => c[0].to); + expect(tos).toEqual(["owner@co.com"]); + expect(tos).not.toContain("carol@ex.com"); + }); + + it("reply on a PUBLIC doc: every participant is notified without an access lookup", async () => { + const CAROL_ID = 4; + routeQuery({ + ownerRows: [{ email: "owner@co.com" }], + participantRows: [ + { id: OWNER_ID, email: "owner@co.com" }, + { id: CAROL_ID, email: "carol@ex.com" }, + ], + grantedEmails: [], // no grants — but a public doc skips the check entirely + parentRows: [{ email: "owner@co.com", body: "root body" }], + }); + const doc = makeDoc({ is_public: true }); + const comment = makeComment({ + author_user_id: ALICE_ID, + author_email: "alice@co.com", + parent_id: 88421, + }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res.notified).toBe(2); + const tos = mocks.sendCommentEmail.mock.calls.map((c) => c[0].to).sort(); + expect(tos).toEqual(["carol@ex.com", "owner@co.com"]); + // A public doc must not consult doc_grants for participant access. + expect(mocks.query.mock.calls.some((c) => String(c[0]).includes("FROM doc_grants"))).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -433,4 +505,26 @@ describe("thread-participant query shape", () => { expect(partQ!.sql).toContain("c.author_user_id IS NOT NULL"); expect(partQ!.params).toEqual([doc.id, 88421]); // [doc_id, rootId = parent_id] }); + + it("scopes the parent-context lookup by doc_id and live (deleted_at IS NULL)", async () => { + const seen: Array<{ sql: string; params: unknown[] }> = []; + mocks.query.mockImplementation(async (sql: string, params?: unknown[]): Promise<Rows> => { + seen.push({ sql, params: params ?? [] }); + if (sql.includes("FROM users WHERE id =")) return { rows: [{ email: "owner@co.com" }] }; + if (sql.includes("SELECT DISTINCT u.id, u.email")) return { rows: [] }; + if (sql.includes("SELECT u.email, c.body")) return { rows: [{ email: "alice@co.com", body: "b" }] }; + if (sql.includes("INSERT INTO login_tokens")) return { rows: [{ id: 1 }] }; + return { rows: [] }; + }); + const doc = makeDoc(); + const comment = makeComment({ author_user_id: BOB_ID, parent_id: 88421 }); + + await sendCommentNotification({ req, doc, comment }); + + const parentQ = seen.find((q) => q.sql.includes("SELECT u.email, c.body")); + expect(parentQ).toBeDefined(); + expect(parentQ!.sql).toContain("c.doc_id = $2"); + expect(parentQ!.sql).toContain("c.deleted_at IS NULL"); + expect(parentQ!.params).toEqual([88421, doc.id]); // [parentId, doc_id] + }); }); diff --git a/lib/docs/comment-notify.ts b/lib/docs/comment-notify.ts index 8016497..484f9ff 100644 --- a/lib/docs/comment-notify.ts +++ b/lib/docs/comment-notify.ts @@ -4,18 +4,23 @@ import { sendCommentEmail } from "@/lib/auth/email"; import { audit } from "@/lib/auth/audit"; import { checkLimits } from "@/lib/auth/ratelimit"; import { ORIGIN, SHARE_TOKEN_TTL_S } from "@/lib/auth/config"; +import { resolveAccess } from "@/lib/docs/grants"; import type { DocRow } from "@/lib/docs/store"; import type { CommentRow } from "@/lib/docs/comments"; -// Comment notification — the sibling of share-notify.ts. When a comment is +// Comment notification — the share-notify.ts companion. When a comment is // posted, we email the people who should hear about it, each with their OWN // 7-day share-kind login link to /d/:slug (same mechanics as the share email). // // RECIPIENTS (the agreed model): // - top-level comment (parent_id null) → the document OWNER only. -// - reply (parent_id set) → the OWNER PLUS every other participant in that -// thread. 1-level threads, so the thread root id = the reply's parent_id; -// participants = the distinct author_user_id across {root, all its replies}. +// - reply (parent_id set) → the OWNER PLUS every other thread participant who +// STILL has access. 1-level threads, so the thread root id = the reply's +// parent_id; candidate participants = the distinct author_user_id across +// {root, all its replies}, each then filtered through a live access check +// (public, or an owner/email/domain grant) — a participant who has since +// lost access is dropped so we don't email post-revocation thread activity +// or leak other participants' emails to someone who can no longer view. // - ALWAYS exclude the new comment's author (no self-notification). // - De-dupe by user_id (the owner may also be a participant). // @@ -94,6 +99,15 @@ async function resolveRecipients( for (const p of partRows) { if (p.id === authorId) continue; // never self-notify if (byUser.has(p.id)) continue; // already in (owner) + // Only notify participants who CURRENTLY retain access. Authorship history + // outlives a grant — a participant whose grant was revoked, or who + // commented while the doc was public before it went private, must not keep + // receiving thread activity (or other participants' emails). Public, or a + // live owner/email/domain grant, qualifies; anything else is dropped. + if (!doc.is_public) { + const access = await resolveAccess(doc, p.email, p.id); + if (access.kind === "none") continue; + } byUser.set(p.id, { userId: p.id, email: p.email, isOwner: p.id === doc.owner_id }); } } @@ -139,8 +153,8 @@ export async function sendCommentNotification(opts: { `SELECT u.email, c.body FROM comments c LEFT JOIN users u ON u.id = c.author_user_id - WHERE c.id = $1`, - [comment.parent_id] + WHERE c.id = $1 AND c.doc_id = $2 AND c.deleted_at IS NULL`, + [comment.parent_id, doc.id] ); const parent = parentRows[0]; if (parent) { From fd981c4b5051368f50070dfea210e8a1d5244827 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:12:05 +0000 Subject: [PATCH 3/4] Tailor comment-email recovery copy to the recipient The owner has an account, so the share-grantee recovery copy ("no account needed" / "was this shared with you? sign in") is wrong for them. Branch the footer so owners are told to sign in normally and participants keep the grantee-oriented copy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- lib/auth/email.ts | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/auth/email.ts b/lib/auth/email.ts index 416bff1..3ade3f9 100644 --- a/lib/auth/email.ts +++ b/lib/auth/email.ts @@ -324,10 +324,17 @@ ${gap(10)}`; context = gap(16); } + // The owner signs in normally (they have an account); a non-owner participant + // may be a share grantee with no account, so they get the "no account needed" + // + "was this shared with you?" recovery copy (the share email's). + const footer = opts.isOwnerRecipient + ? `<tr><td style="${CAVEAT}">Good for ${COMMENT_EXPIRY_DAYS} days. ${whyLine(true)} If the link expires, <a href="${esc(opts.docUrl)}" style="color:#666666;">open the document</a> and sign in.</td></tr>` + : `<tr><td style="${CAVEAT}">Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days. ${whyLine(false)} If it expires, <a href="${esc(opts.docUrl)}" style="color:#666666;">open the document</a> and choose "was this shared with you? sign in".</td></tr>`; + const rows = `${lead} ${context}${quoteBlock(opts.bodySnippet)} ${gap(16)}<tr><td style="${LEAD}"><a href="${esc(opts.link)}" style="${LINK}">Open the document →</a></td></tr> -${gap(16)}<tr><td style="${CAVEAT}">Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days. ${whyLine(opts.isOwnerRecipient)} If it expires, <a href="${esc(opts.docUrl)}" style="color:#666666;">open the document</a> and choose "was this shared with you? sign in".</td></tr>`; +${gap(16)}${footer}`; return shell(opts.isReply ? "new reply on justhtml.sh" : "new comment on justhtml.sh", rows); } @@ -344,17 +351,22 @@ function commentTextBody(opts: CommentEmailParts): string { lines.push(`On: "${opts.anchoredQuote}"`, ""); } - lines.push( - ` ${opts.bodySnippet}`, - "", - "Open the document:", - ` ${opts.link}`, - "", - `Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days.`, - whyLine(opts.isOwnerRecipient), - 'If it expires, open the document directly and choose "was this shared with you? sign in":', - ` ${opts.docUrl}` - ); + lines.push(` ${opts.bodySnippet}`, "", "Open the document:", ` ${opts.link}`, ""); + if (opts.isOwnerRecipient) { + lines.push( + `This sign-in link is good for ${COMMENT_EXPIRY_DAYS} days.`, + whyLine(true), + "If it expires, sign in at justhtml.sh and open the document:", + ` ${opts.docUrl}` + ); + } else { + lines.push( + `Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days.`, + whyLine(false), + 'If it expires, open the document directly and choose "was this shared with you? sign in":', + ` ${opts.docUrl}` + ); + } return lines.join("\n"); } From 0b1d5e3b79d7a31034aa48b0e8c2d13a3791b5e4 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:19:47 +0000 Subject: [PATCH 4/4] Harden comment notification recipient handling Re-read the document's current is_public at notification time so a public->private flip between the POST's initial doc load and the notification can't skip the participant access gate (which would email participants who no longer have access). Isolate each recipient and keep the notified count outside the try block, so a mid-loop failure (cap check or token mint) neither under-reports how many emails were sent nor aborts the remaining recipients. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- lib/docs/comment-notify.test.ts | 33 +++++++++ lib/docs/comment-notify.ts | 122 ++++++++++++++++++-------------- 2 files changed, 102 insertions(+), 53 deletions(-) diff --git a/lib/docs/comment-notify.test.ts b/lib/docs/comment-notify.test.ts index 272e6a3..0a40acd 100644 --- a/lib/docs/comment-notify.test.ts +++ b/lib/docs/comment-notify.test.ts @@ -91,6 +91,7 @@ function routeQuery(opts: { participantRows?: Array<{ id: number; email: string }>; parentRows?: Array<{ email: string | null; body: string }>; grantedEmails?: string[]; + isPublic?: boolean; onTokenInsert?: (params: unknown[]) => void; onTokenDelete?: (params: unknown[]) => void; }): void { @@ -103,6 +104,9 @@ function routeQuery(opts: { if (sql.includes("FROM users WHERE id =")) { return { rows: opts.ownerRows ?? [{ email: "owner@co.com" }] }; } + if (sql.includes("SELECT is_public FROM documents")) { + return { rows: [{ is_public: opts.isPublic ?? false }] }; + } if (sql.includes("SELECT DISTINCT u.id, u.email")) { return { rows: opts.participantRows ?? [] }; } @@ -248,6 +252,7 @@ describe("recipient model", () => { { id: CAROL_ID, email: "carol@ex.com" }, ], grantedEmails: [], // no grants — but a public doc skips the check entirely + isPublic: true, // the orchestrator re-reads is_public; this doc is public parentRows: [{ email: "owner@co.com", body: "root body" }], }); const doc = makeDoc({ is_public: true }); @@ -265,6 +270,34 @@ describe("recipient model", () => { // A public doc must not consult doc_grants for participant access. expect(mocks.query.mock.calls.some((c) => String(c[0]).includes("FROM doc_grants"))).toBe(false); }); + + it("reply: uses the CURRENT is_public (a public→private flip after the POST load drops a participant with no live grant)", async () => { + const CAROL_ID = 4; + routeQuery({ + ownerRows: [{ email: "owner@co.com" }], + participantRows: [ + { id: OWNER_ID, email: "owner@co.com" }, + { id: CAROL_ID, email: "carol@ex.com" }, + ], + grantedEmails: [], // carol holds no grant + isPublic: false, // the DB now says private… + parentRows: [{ email: "owner@co.com", body: "root body" }], + }); + // …even though the doc row captured at POST time still said public. + const doc = makeDoc({ is_public: true }); + const comment = makeComment({ + author_user_id: ALICE_ID, + author_email: "alice@co.com", + parent_id: 88421, + }); + + const res = await sendCommentNotification({ req, doc, comment }); + + expect(res.notified).toBe(1); + const tos = mocks.sendCommentEmail.mock.calls.map((c) => c[0].to); + expect(tos).toEqual(["owner@co.com"]); + expect(tos).not.toContain("carol@ex.com"); + }); }); // --------------------------------------------------------------------------- diff --git a/lib/docs/comment-notify.ts b/lib/docs/comment-notify.ts index 484f9ff..c866edd 100644 --- a/lib/docs/comment-notify.ts +++ b/lib/docs/comment-notify.ts @@ -96,6 +96,17 @@ async function resolveRecipients( AND c.author_user_id IS NOT NULL`, [doc.id, rootId] ); + + // Re-read the CURRENT public flag instead of trusting doc.is_public captured + // when the POST first loaded the doc: a public→private flip in that window + // must not let the access gate below be skipped (which would email + // participants who no longer have access). + const { rows: pubRows } = await query<{ is_public: boolean }>( + `SELECT is_public FROM documents WHERE id = $1`, + [doc.id] + ); + const isPublic = pubRows[0]?.is_public ?? false; + for (const p of partRows) { if (p.id === authorId) continue; // never self-notify if (byUser.has(p.id)) continue; // already in (owner) @@ -104,7 +115,7 @@ async function resolveRecipients( // commented while the doc was public before it went private, must not keep // receiving thread activity (or other participants' emails). Public, or a // live owner/email/domain grant, qualifies; anything else is dropped. - if (!doc.is_public) { + if (!isPublic) { const access = await resolveAccess(doc, p.email, p.id); if (access.kind === "none") continue; } @@ -127,9 +138,12 @@ export async function sendCommentNotification(opts: { doc: DocRow; comment: CommentRow; }): Promise<CommentNotifyResult> { + const { req, doc, comment } = opts; + let notified = 0; + let recipientCount = 0; try { - const { req, doc, comment } = opts; const recipients = await resolveRecipients(doc, comment); + recipientCount = recipients.length; if (recipients.length === 0) return { notified: 0, recipients: 0 }; const isReply = comment.parent_id !== null; @@ -166,63 +180,65 @@ export async function sendCommentNotification(opts: { const next = `/d/${encodeURIComponent(doc.slug)}`; const docUrl = `${ORIGIN}${next}`; - let notified = 0; for (const r of recipients) { - const to = r.email.toLowerCase(); - - // Per-recipient daily safety cap, dedicated namespace. A tripped cap skips - // this recipient (the rest still go out). - const tripped = await checkLimits([ - { key: `cmt-notify:addr:${to}`, limit: COMMENT_NOTIFY_PER_EMAIL_PER_DAY, window: "day" }, - ]); - if (tripped) { - audit(req, "rate_limit.tripped", { meta: { key: tripped.key, limit: tripped.limit } }); - continue; - } - - // Mint a share-kind login token (7-day TTL). Roll back if the send fails. - const token = mintLoginToken(); - const { rows } = await query<{ id: number }>( - `INSERT INTO login_tokens (email, token_hash, kind, expires_at) - VALUES ($1, $2, 'share', now() + ($3 || ' seconds')::interval) - RETURNING id`, - [to, sha256Hex(token), String(SHARE_TOKEN_TTL_S)] - ); - const tokenId = rows[0].id; - - const link = `${ORIGIN}/login/verify?token=${token}&next=${encodeURIComponent(next)}`; - - let resendId: string | null = null; + // Each recipient is independent: a failure capping/minting/sending for one + // must neither abort the rest nor drop the running count. try { - resendId = await sendCommentEmail({ - to, - authorEmail, - title, - isReply, - isOwnerRecipient: r.isOwner, - bodySnippet, - anchoredQuote, - parentAuthorEmail, - parentSnippet, - link, - docUrl, - idempotencyKey: `comment-notify-${comment.id}-${r.userId}`, - }); + const to = r.email.toLowerCase(); + + // Per-recipient daily safety cap, dedicated namespace. A tripped cap + // skips this recipient (the rest still go out). + const tripped = await checkLimits([ + { key: `cmt-notify:addr:${to}`, limit: COMMENT_NOTIFY_PER_EMAIL_PER_DAY, window: "day" }, + ]); + if (tripped) { + audit(req, "rate_limit.tripped", { meta: { key: tripped.key, limit: tripped.limit } }); + continue; + } + + // Mint a share-kind login token (7-day TTL). Roll back if the send fails. + const token = mintLoginToken(); + const { rows } = await query<{ id: number }>( + `INSERT INTO login_tokens (email, token_hash, kind, expires_at) + VALUES ($1, $2, 'share', now() + ($3 || ' seconds')::interval) + RETURNING id`, + [to, sha256Hex(token), String(SHARE_TOKEN_TTL_S)] + ); + const tokenId = rows[0].id; + + const link = `${ORIGIN}/login/verify?token=${token}&next=${encodeURIComponent(next)}`; + + try { + const resendId = await sendCommentEmail({ + to, + authorEmail, + title, + isReply, + isOwnerRecipient: r.isOwner, + bodySnippet, + anchoredQuote, + parentAuthorEmail, + parentSnippet, + link, + docUrl, + idempotencyKey: `comment-notify-${comment.id}-${r.userId}`, + }); + audit(req, "comment_notification.sent", { + userId: r.userId, + meta: { doc_id: doc.id, comment_id: comment.id, recipient_email: to, resend_id: resendId }, + }); + notified += 1; + } catch { + // Send failed for this recipient — roll back the just-minted token. + await query(`DELETE FROM login_tokens WHERE id = $1`, [tokenId]).catch(() => {}); + } } catch { - await query(`DELETE FROM login_tokens WHERE id = $1`, [tokenId]).catch(() => {}); - continue; + // Skip this recipient; the others still go out. } - - audit(req, "comment_notification.sent", { - userId: r.userId, - meta: { doc_id: doc.id, comment_id: comment.id, recipient_email: to, resend_id: resendId }, - }); - notified += 1; } - - return { notified, recipients: recipients.length }; } catch { // Best-effort: a comment notification must never fail the comment write. - return { notified: 0, recipients: 0 }; + // notified/recipientCount hold whatever completed before the error. } + return { notified, recipients: recipientCount }; }