diff --git a/app/api/v1/docs/[slug]/comments/route.ts b/app/api/v1/docs/[slug]/comments/route.ts index 478b4a0..b399b5d 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,6 +135,12 @@ 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."); } + // 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. + await sendCommentNotification({ req, doc, comment: result.comment }); + return json({ comment: commentView(result.comment, []) }, 201); } diff --git a/app/api/v1/docs/[slug]/grants/route.ts b/app/api/v1/docs/[slug]/grants/route.ts index be8466e..2481435 100644 --- a/app/api/v1/docs/[slug]/grants/route.ts +++ b/app/api/v1/docs/[slug]/grants/route.ts @@ -137,28 +137,19 @@ export async function POST(req: Request, ctx: Ctx): Promise { // Share notification (birthday.md "Share notifications"). Sent only for a // freshly created or role-changed EMAIL grant, only when notify !== false. - // Domain grants never notify. The grant is already committed; a send failure - // or rate-limit never fails the request (the /d/:slug "was this shared with - // you?" fallback always recovers a missed/expired link). - let notified: boolean | undefined; - if (granteeType === "email") { - if (notify) { - const res = await sendShareNotification({ - req, - docId: doc.id, - slug: doc.slug, - title: doc.title || doc.slug, - ownerEmail: principal.email, - granteeEmail: grantee, - }); - notified = res.sent; - } else { - notified = false; - } + // Domain grants never notify. Best-effort: the grant is already committed, so a + // send failure or rate-limit never fails the request (the /d/:slug "was this + // shared with you?" fallback always recovers a missed/expired link). + if (granteeType === "email" && notify) { + await sendShareNotification({ + req, + docId: doc.id, + slug: doc.slug, + title: doc.title || doc.slug, + ownerEmail: principal.email, + granteeEmail: grantee, + }); } - return json( - { slug: doc.slug, grant: grantView(result.grant), ...(notified !== undefined ? { notified } : {}) }, - 201 - ); + return json({ slug: doc.slug, grant: grantView(result.grant) }, 201); } 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..3ade3f9 100644 --- a/lib/auth/email.ts +++ b/lib/auth/email.ts @@ -248,3 +248,173 @@ 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); + } + + // 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)}${footer}`; + 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}`, ""); + 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"); +} + +/** + * 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..0a40acd --- /dev/null +++ b/lib/docs/comment-notify.test.ts @@ -0,0 +1,563 @@ +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. + * + * 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[]; + isPublic?: boolean; + 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 =")) { + 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 ?? [] }; + } + 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 ?? [] }; + } + 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"); + }); + + 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 + 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 }); + 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); + }); + + 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"); + }); +}); + +// --------------------------------------------------------------------------- +// 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] + }); + + 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 new file mode 100644 index 0000000..c866edd --- /dev/null +++ b/lib/docs/comment-notify.ts @@ -0,0 +1,244 @@ +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 { resolveAccess } from "@/lib/docs/grants"; +import type { DocRow } from "@/lib/docs/store"; +import type { CommentRow } from "@/lib/docs/comments"; + +// 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 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). +// +// 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] + ); + + // 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) + // 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 (!isPublic) { + 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 }); + } + } + + 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> { + const { req, doc, comment } = opts; + let notified = 0; + let recipientCount = 0; + try { + const recipients = await resolveRecipients(doc, comment); + recipientCount = recipients.length; + 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 AND c.doc_id = $2 AND c.deleted_at IS NULL`, + [comment.parent_id, doc.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}`; + + for (const r of recipients) { + // Each recipient is independent: a failure capping/minting/sending for one + // must neither abort the rest nor drop the running count. + try { + 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 { + // Skip this recipient; the others still go out. + } + } + } catch { + // Best-effort: a comment notification must never fail the comment write. + // notified/recipientCount hold whatever completed before the error. + } + return { notified, recipients: recipientCount }; +} diff --git a/lib/docs/schemas.ts b/lib/docs/schemas.ts index 71e7875..7f6c6c0 100644 --- a/lib/docs/schemas.ts +++ b/lib/docs/schemas.ts @@ -346,17 +346,13 @@ export const GrantListResponse = registry.register( .openapi("GrantListResponse", { description: "Grants on the document (owner only)." }) ); -// POST /api/v1/docs/{slug}/grants 201: { slug, grant, notified? }. +// POST /api/v1/docs/{slug}/grants 201: { slug, grant }. export const GrantCreatedResponse = registry.register( "GrantCreatedResponse", z .object({ slug: z.string(), grant: Grant, - notified: z.boolean().optional().openapi({ - description: - "Present only for email grants: true if the share-notification email was sent, false if suppressed (notify:false) or skipped (rate-limited / send failed).", - }), }) .openapi("GrantCreatedResponse", { description: "Grant created." }) ); diff --git a/lib/openapi/generated-spec.ts b/lib/openapi/generated-spec.ts index 63791d4..8408078 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 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"; diff --git a/lib/openapi/generated.yaml b/lib/openapi/generated.yaml index 5470e17..bc97460 100644 --- a/lib/openapi/generated.yaml +++ b/lib/openapi/generated.yaml @@ -380,11 +380,6 @@ components: type: string grant: $ref: '#/components/schemas/Grant' - notified: - type: boolean - description: >- - Present only for email grants: true if the share-notification email was sent, false if - suppressed (notify:false) or skipped (rate-limited / send failed). required: - slug - grant diff --git a/lib/skill-content.ts b/lib/skill-content.ts index a67ab70..413119a 100644 --- a/lib/skill-content.ts +++ b/lib/skill-content.ts @@ -123,7 +123,7 @@ Share (owner only) -> POST /docs/:slug/grants { email|domain, role, notify? } curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/grants \\ -H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \\ -d '{"email":"teammate@co.com","role":"editor"}' - # -> 201 { slug, grant, notified: true } (notified present only for email grants) + # -> 201 { slug, grant } # Domain grants (e.g. {"domain":"co.com"}) work too; consumer providers # (gmail.com, ...) are rejected -> use public or the view token instead. # A teammate's agent registers via auth.md with that email and the grant diff --git a/scripts/e2e.ts b/scripts/e2e.ts index ac49869..07ba655 100644 --- a/scripts/e2e.ts +++ b/scripts/e2e.ts @@ -254,7 +254,7 @@ async function main() { }); const grantJson = await grant.json(); checkSchema("POST /grants", "POST", "/api/v1/docs/{slug}/grants", grant.status, grantJson); - check("editor grant created + notified", grant.status === 201 && grantJson.notified === true, `status ${grant.status}`); + check("editor grant created", grant.status === 201, `status ${grant.status}`); const shareEmail = await waitForEmail(granteeInbox, "shared"); const link = (shareEmail.text.match(/https:\/\/[^\s)]+\/login\/verify\?[^\s)]+/) ?? shareEmail.html.match(/https:\/\/[^\s"')]+\/login\/verify\?[^\s"')]+/))?.[0]?.replace(/&/g, "&"); diff --git a/skills/just-html/SKILL.md b/skills/just-html/SKILL.md index e0f2c40..c33986b 100644 --- a/skills/just-html/SKILL.md +++ b/skills/just-html/SKILL.md @@ -115,7 +115,7 @@ Share (owner only) -> POST /docs/:slug/grants { email|domain, role, notify? } curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/grants \ -H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \ -d '{"email":"teammate@co.com","role":"editor"}' - # -> 201 { slug, grant, notified: true } (notified present only for email grants) + # -> 201 { slug, grant } # Domain grants (e.g. {"domain":"co.com"}) work too; consumer providers # (gmail.com, ...) are rejected -> use public or the view token instead. # A teammate's agent registers via auth.md with that email and the grant