diff --git a/app/api/v1/bookmarks/route.ts b/app/api/v1/bookmarks/route.ts new file mode 100644 index 0000000..bbafa11 --- /dev/null +++ b/app/api/v1/bookmarks/route.ts @@ -0,0 +1,85 @@ +import { apiError, json, requireApiKey } from "@/lib/docs/api"; +import { docUrl, listBookmarks, type BookmarkDocRow } from "@/lib/docs/store"; +import { resolveAccess } from "@/lib/docs/grants"; +import { safeEqualStr } from "@/lib/auth/tokens"; +import { resolveBookmarkView } from "@/lib/docs/bookmarks-view"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_LIST_LIMIT = 100; +const MAX_LIST_LIMIT = 500; + +// One bookmark's API view: re-resolve the caller's access (no session — the key +// acts as its authenticated email), then shape the item. A revoked bookmark +// carries no url and the bookmark-time title snapshot (the caller can no longer +// see the doc's current title). +async function itemView(doc: BookmarkDocRow, principalEmail: string, principalUserId: number) { + const access = await resolveAccess(doc, principalEmail, principalUserId); + const grantRole = + access.kind === "email_grant" || access.kind === "domain_grant" ? access.role : null; + const view = resolveBookmarkView({ + deleted: doc.deleted_at != null, + isOwner: access.kind === "owner", + grantRole, + isPublic: doc.is_public, + tokenValid: doc.bookmark_token != null && safeEqualStr(doc.bookmark_token, doc.view_token), + }); + + const url = view.revoked + ? null + : view.usesToken && doc.bookmark_token + ? `${docUrl(doc.slug)}?viewtoken=${encodeURIComponent(doc.bookmark_token)}` + : docUrl(doc.slug); + + return { + slug: doc.slug, + url, + title: view.revoked ? doc.bookmark_title : doc.title, + access: view.access, + revoked: view.revoked, + public: doc.is_public, + bookmarked_at: doc.bookmarked_at, + }; +} + +// GET /api/v1/bookmarks — list the caller's bookmarks, newest first. Scope: +// docs.read. +// +// ?scope=all (default) | owned | shared: +// - owned : bookmarked docs the key's user owns. +// - shared: bookmarked docs shared with (not owned by) the caller. +// - all : both. The web equivalent for a signed-in human is /bookmarks. +export async function GET(req: Request): Promise { + const auth = await requireApiKey(req, "docs.read", "read"); + if ("response" in auth) return auth.response; + const { principal } = auth; + + const url = new URL(req.url); + let limit = DEFAULT_LIST_LIMIT; + const limitParam = url.searchParams.get("limit"); + if (limitParam != null) { + const n = Number(limitParam); + if (!Number.isInteger(n) || n < 1) { + return apiError(400, "invalid_request", "Query 'limit' must be a positive integer."); + } + limit = Math.min(n, MAX_LIST_LIMIT); + } + + const scope = (url.searchParams.get("scope") ?? "all") as "owned" | "shared" | "all"; + if (scope !== "owned" && scope !== "shared" && scope !== "all") { + return apiError(400, "invalid_request", "Query 'scope' must be one of: owned, shared, all."); + } + + // Filter by scope in SQL so `limit` bounds the returned scope, not the full + // bookmark set (an owned/shared request must not underfill because out-of-scope + // rows consumed the limit first). + const rows = await listBookmarks(principal.email, limit, { + scope, + ownerId: principal.userId, + }); + const bookmarks = []; + for (const doc of rows) { + bookmarks.push(await itemView(doc, principal.email, principal.userId)); + } + return json({ bookmarks }); +} diff --git a/app/api/v1/docs/[slug]/bookmark/route.ts b/app/api/v1/docs/[slug]/bookmark/route.ts new file mode 100644 index 0000000..8e8997c --- /dev/null +++ b/app/api/v1/docs/[slug]/bookmark/route.ts @@ -0,0 +1,60 @@ +import { json, notFoundDoc, requireApiKey } from "@/lib/docs/api"; +import { findBySlug, removeBookmarkBySlug, saveBookmark } from "@/lib/docs/store"; +import { resolveAccess } from "@/lib/docs/grants"; +import { canView } from "@/lib/docs/access"; +import { safeEqualStr } from "@/lib/auth/tokens"; + +export const dynamic = "force-dynamic"; + +type Ctx = { params: Promise<{ slug: string }> }; + +// PUT /api/v1/docs/:slug/bookmark — idempotently bookmark a doc the caller can +// view. Keyed by the key's email, so it unifies with the account's signed-in +// web bookmarks. Requires view access (owner / grant via identity, a public +// doc, or a matching ?viewtoken=); an inaccessible doc 404s (no existence +// oracle). Scope: docs.read — bookmarking only needs to read the doc, and it +// writes the caller's own personal state, not the document. +export async function PUT(req: Request, ctx: Ctx): Promise { + const auth = await requireApiKey(req, "docs.read", "write"); + if ("response" in auth) return auth.response; + const { principal } = auth; + + const { slug } = await ctx.params; + const viewtoken = new URL(req.url).searchParams.get("viewtoken"); + + const doc = await findBySlug(slug); + if (!doc) return notFoundDoc(); + + // Owner / email / domain grant via the authenticated email, or public / a + // matching view token (the viewer-route path an API key may still present). + const access = await resolveAccess(doc, principal.email, principal.userId); + if (access.kind === "none" && !canView(doc, viewtoken)) return notFoundDoc(); + + // Persist the token only when it is the actual basis for access — a matching + // token on an otherwise private doc reached with no grant. Access via + // ownership / a grant / public makes the submitted token irrelevant, and + // storing a non-matching token would gate the later re-check on a token that + // was never the basis for access. Mirrors the web bookmark POST. + const tokenToStore = + access.kind === "none" && + !doc.is_public && + viewtoken && + safeEqualStr(viewtoken, doc.view_token) + ? viewtoken + : null; + await saveBookmark(principal.email, doc.id, tokenToStore, doc.title); + return json({ bookmarked: true }); +} + +// DELETE /api/v1/docs/:slug/bookmark — idempotently remove the caller's +// bookmark. Keyed by slug through the doc row so it still drops a bookmark for +// a revoked/deleted doc; a no-op when nothing was bookmarked. Scope: docs.read. +export async function DELETE(req: Request, ctx: Ctx): Promise { + const auth = await requireApiKey(req, "docs.read", "write"); + if ("response" in auth) return auth.response; + const { principal } = auth; + + const { slug } = await ctx.params; + await removeBookmarkBySlug(principal.email, slug); + return json({ removed: true }); +} diff --git a/lib/docs/bookmarks-view.test.ts b/lib/docs/bookmarks-view.test.ts index e0280e4..3dc4ca7 100644 --- a/lib/docs/bookmarks-view.test.ts +++ b/lib/docs/bookmarks-view.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { bookmarkRow, fmtDate } from "@/lib/docs/bookmarks-view"; +import { bookmarkRow, fmtDate, resolveBookmarkView } from "@/lib/docs/bookmarks-view"; const base = { docId: 42, @@ -69,3 +69,47 @@ describe("fmtDate", () => { expect(fmtDate("not-a-date")).toBe("not-a-date"); }); }); + +describe("resolveBookmarkView", () => { + const live = { deleted: false, isOwner: false, grantRole: null, isPublic: false, tokenValid: false }; + + it("a deleted doc is revoked, ahead of any live access", () => { + expect(resolveBookmarkView({ ...live, deleted: true, isOwner: true })).toEqual({ + access: "revoked", + revoked: true, + usesToken: false, + }); + }); + + it("owner beats a grant", () => { + expect(resolveBookmarkView({ ...live, isOwner: true, grantRole: "viewer" })).toEqual({ + access: "owner", + revoked: false, + usesToken: false, + }); + }); + + it("reports the identity grant role", () => { + expect(resolveBookmarkView({ ...live, grantRole: "commenter" }).access).toBe("commenter"); + }); + + it("a public doc with no grant reads public", () => { + expect(resolveBookmarkView({ ...live, isPublic: true })).toEqual({ + access: "public", + revoked: false, + usesToken: false, + }); + }); + + it("private + no grant + a valid token reads link and uses the token", () => { + expect(resolveBookmarkView({ ...live, tokenValid: true })).toEqual({ + access: "link", + revoked: false, + usesToken: true, + }); + }); + + it("private + no grant + no valid token is revoked (access withdrawn)", () => { + expect(resolveBookmarkView(live)).toEqual({ access: "revoked", revoked: true, usesToken: false }); + }); +}); diff --git a/lib/docs/bookmarks-view.ts b/lib/docs/bookmarks-view.ts index a16923d..ff6ab9c 100644 --- a/lib/docs/bookmarks-view.ts +++ b/lib/docs/bookmarks-view.ts @@ -51,3 +51,42 @@ export function bookmarkRow(m: RowModel): string { const vis = m.isPublic ? "public" : "private"; return `
${esc(label)}  ${esc(m.access)} ${vis} · ${esc(fmtDate(m.bookmarkedAt))}
${removeForm(m.docId)}
`; } + +// Inputs for re-resolving a bookmarked doc's access at read time, reduced to +// primitives so the mapping is pure (no DB, no session). The API list handler +// (GET /api/v1/bookmarks) derives these from resolveAccess + the doc row. +export type BookmarkAccessInput = { + // The doc has been (soft-)deleted. + deleted: boolean; + // The caller owns the doc. + isOwner: boolean; + // The caller's resolved identity grant role, or null when there is no grant. + grantRole: "editor" | "commenter" | "viewer" | null; + // The doc is public. + isPublic: boolean; + // The view token the doc was bookmarked through still matches the doc. + tokenValid: boolean; +}; + +export type BookmarkAccessView = { + // owner | editor | commenter | viewer | public | link | revoked. + access: string; + revoked: boolean; + // The row should link through the stored view token (access === "link"). + usesToken: boolean; +}; + +/** + * Re-resolve a bookmarked doc's access, mirroring the web /bookmarks page: + * a deleted doc or one the caller can no longer reach is "revoked"; otherwise + * the live identity role, or "public"/"link" when reachable only through the + * doc being public / the stored view token. Order matches the access ladder. + */ +export function resolveBookmarkView(i: BookmarkAccessInput): BookmarkAccessView { + if (i.deleted) return { access: "revoked", revoked: true, usesToken: false }; + if (i.isOwner) return { access: "owner", revoked: false, usesToken: false }; + if (i.grantRole) return { access: i.grantRole, revoked: false, usesToken: false }; + if (i.isPublic) return { access: "public", revoked: false, usesToken: false }; + if (i.tokenValid) return { access: "link", revoked: false, usesToken: true }; + return { access: "revoked", revoked: true, usesToken: false }; +} diff --git a/lib/docs/paths.ts b/lib/docs/paths.ts index 63a4284..650b713 100644 --- a/lib/docs/paths.ts +++ b/lib/docs/paths.ts @@ -7,6 +7,9 @@ import { registry, z } from "@/lib/openapi/registry"; import { ApiError, + BookmarkListResponse, + BookmarkRemovedResponse, + BookmarkSavedResponse, CommentCreatedResponse, CommentDeletedResponse, CommentUpdatedResponse, @@ -596,3 +599,95 @@ registry.registerPath({ }, }, }); + +// ========================================================================= +// Bookmarks. A personal saved-doc list keyed by the caller's email (unifies +// with the signed-in web /bookmarks). Save/remove need only view access to the +// doc; the list re-resolves access per item so a withdrawn doc reads revoked. +// ========================================================================= + +// PUT /api/v1/docs/{slug}/bookmark — idempotently bookmark a viewable doc +registry.registerPath({ + method: "put", + path: "/api/v1/docs/{slug}/bookmark", + tags: ["bookmarks"], + summary: "Bookmark a document (idempotent)", + description: + "Saves the doc to the caller's bookmarks. Requires view access (owner, a grant, a public doc, or a matching ?viewtoken=); an inaccessible doc returns 404 (no existence oracle). Keyed by the key's email, so it unifies with the account's signed-in web bookmarks.", + operationId: "addBookmark", + security, + request: { params: z.object({ slug: slugParam }), query: viewtokenQuery }, + responses: { + 200: { + description: "Bookmarked", + content: { "application/json": { schema: BookmarkSavedResponse } }, + }, + 401: { description: "Missing/invalid credential", content: jsonError }, + 404: { + description: "No such document (also returned for inaccessible docs; no existence oracle)", + content: jsonError, + }, + 429: { description: "Rate limit exceeded", content: jsonError }, + }, +}); + +// DELETE /api/v1/docs/{slug}/bookmark — idempotently remove the caller's bookmark +registry.registerPath({ + method: "delete", + path: "/api/v1/docs/{slug}/bookmark", + tags: ["bookmarks"], + summary: "Remove a bookmark (idempotent)", + description: + "Removes the caller's bookmark for this doc. Idempotent (succeeds when none existed) and works on a revoked or deleted doc.", + operationId: "removeBookmark", + security, + request: { params: z.object({ slug: slugParam }) }, + responses: { + 200: { + description: "Removed", + content: { "application/json": { schema: BookmarkRemovedResponse } }, + }, + 401: { description: "Missing/invalid credential", content: jsonError }, + 429: { description: "Rate limit exceeded", content: jsonError }, + }, +}); + +// GET /api/v1/bookmarks — list the caller's bookmarks (owned, shared, or both) +registry.registerPath({ + method: "get", + path: "/api/v1/bookmarks", + tags: ["bookmarks"], + summary: "List bookmarked documents (owned, shared, or both)", + description: + "The caller's bookmarks, newest first, each with access re-resolved at read time (owner|editor|commenter|viewer|public|link|revoked). The signed-in web equivalent is https://justhtml.sh/bookmarks.", + operationId: "listBookmarks", + security, + request: { + query: z.object({ + scope: z + .enum(["owned", "shared", "all"]) + .default("all") + .openapi({ + param: { name: "scope", in: "query" }, + description: + "owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the caller. all (default): both.", + }), + limit: z + .number() + .int() + .min(1) + .max(500) + .default(100) + .openapi({ param: { name: "limit", in: "query" } }), + }), + }, + responses: { + 200: { + description: "The caller's bookmarks", + content: { "application/json": { schema: BookmarkListResponse } }, + }, + 400: { description: "Invalid request body or parameters", content: jsonError }, + 401: { description: "Missing/invalid credential", content: jsonError }, + 429: { description: "Rate limit exceeded", content: jsonError }, + }, +}); diff --git a/lib/docs/schemas.ts b/lib/docs/schemas.ts index 7f6c6c0..7e2da63 100644 --- a/lib/docs/schemas.ts +++ b/lib/docs/schemas.ts @@ -244,6 +244,76 @@ export const ApiError = registry.register( .openapi("ApiError", { description: "Structured API error: { error, message, ...extra }." }) ); +// ========================================================================= +// Bookmarks — PUT/DELETE /api/v1/docs/{slug}/bookmark, GET /api/v1/bookmarks. +// A personal saved-doc list keyed by the caller's email (unifies with the +// signed-in web /bookmarks). +// ========================================================================= + +// PUT /api/v1/docs/{slug}/bookmark 200 — { bookmarked }. +export const BookmarkSavedResponse = registry.register( + "BookmarkSavedResponse", + z + .object({ bookmarked: z.boolean() }) + .openapi("BookmarkSavedResponse", { description: "The doc is bookmarked (idempotent)." }) +); + +// DELETE /api/v1/docs/{slug}/bookmark 200 — { removed }. +export const BookmarkRemovedResponse = registry.register( + "BookmarkRemovedResponse", + z + .object({ removed: z.boolean() }) + .openapi("BookmarkRemovedResponse", { + description: "The bookmark is removed (idempotent; also succeeds when none existed).", + }) +); + +// One item of GET /api/v1/bookmarks. access is re-resolved at read time, so a +// doc whose access was withdrawn (grant removed, token rotated, or doc deleted) +// reads as revoked with a null url and the bookmark-time title snapshot. +export const BookmarkListItem = registry.register( + "BookmarkListItem", + z + .object({ + slug, + url: url + .nullable() + .openapi({ + description: + "Link to the doc (carries ?viewtoken= when reachable only through the stored token). null when access is revoked.", + }), + title: z + .string() + .nullable() + .openapi({ + description: + "Live title while the doc is reachable; the title captured at bookmark time once revoked.", + }), + access: z + .enum(["owner", "editor", "commenter", "viewer", "public", "link", "revoked"]) + .openapi({ + description: + "Re-resolved per read: owner|editor|commenter|viewer for identity access, public for a public doc, link when reachable only via the stored view token, revoked when the doc was deleted or access was withdrawn.", + }), + revoked: z + .boolean() + .openapi({ description: "True when the doc was deleted or the caller can no longer access it." }), + public: z.boolean(), + bookmarked_at: dateTime, + }) + .openapi("BookmarkListItem", { + description: "A bookmarked document, with the caller's access re-resolved at read time.", + }) +); + +// GET /api/v1/bookmarks response envelope: { bookmarks: BookmarkListItem[] }. +export const BookmarkListResponse = registry.register( + "BookmarkListResponse", + z + .object({ bookmarks: z.array(BookmarkListItem) }) + .openapi("BookmarkListResponse", { description: "The caller's bookmarked documents, newest first." }) +); + // ========================================================================= // Z2 — docs sub-resources: grants, versions, rotate-token, edits. // Same contract as Z1: every 400 maps to the SAME apiError(400, diff --git a/lib/docs/store.ts b/lib/docs/store.ts index bc36407..72ba7fa 100644 --- a/lib/docs/store.ts +++ b/lib/docs/store.ts @@ -696,21 +696,59 @@ export async function removeBookmark(bookmarkerEmail: string, docId: number): Pr ]); } -/** List a user's bookmarks, newest first, with the live document row and the - * token the doc was bookmarked through. */ +/** + * Remove a bookmark by slug, resolving the doc id even when the doc is + * soft-deleted (findBySlug skips deleted docs, so the slug-keyed API DELETE + * routes through this to still drop a bookmark for a revoked/deleted doc). + * Idempotent: removes zero rows when there is no such bookmark. + */ +export async function removeBookmarkBySlug(bookmarkerEmail: string, slug: string): Promise { + await query( + `DELETE FROM bookmarks b + USING documents d + WHERE b.doc_id = d.id + AND b.bookmarker_email = $1 + AND d.slug = $2`, + [bookmarkerEmail.toLowerCase(), slug] + ); +} + +/** + * List a user's bookmarks, newest first, with the live document row and the + * token the doc was bookmarked through. `opts.scope` filters in SQL so `limit` + * applies AFTER the owned/shared split (owned = docs the caller owns) — a scoped + * request never underfills because out-of-scope rows ate the limit. The owner + * comparison needs the caller's account id; without one (an account-less + * grantee) nothing is owned, so `owned` is empty and `shared` is everything. + */ export async function listBookmarks( bookmarkerEmail: string, - limit: number + limit: number, + opts?: { scope?: "owned" | "shared" | "all"; ownerId?: number | null } ): Promise { + const scope = opts?.scope ?? "all"; + const ownerId = opts?.ownerId ?? null; + const params: (string | number)[] = [bookmarkerEmail.toLowerCase(), limit]; + + let scopeClause = ""; + if (scope === "owned") { + if (ownerId == null) return []; // an account-less caller owns nothing + params.push(ownerId); + scopeClause = `AND d.owner_id = $${params.length}`; + } else if (scope === "shared" && ownerId != null) { + params.push(ownerId); + scopeClause = `AND d.owner_id <> $${params.length}`; + } + const { rows } = await query( `SELECT d.*, b.created_at AS bookmarked_at, b.view_token AS bookmark_token, b.doc_title AS bookmark_title FROM bookmarks b JOIN documents d ON d.id = b.doc_id - WHERE b.bookmarker_email = $1 + WHERE b.bookmarker_email = $1 ${scopeClause} ORDER BY b.created_at DESC, b.id DESC LIMIT $2`, - [bookmarkerEmail.toLowerCase(), limit] + params ); return rows; } diff --git a/lib/openapi/generated-spec.ts b/lib/openapi/generated-spec.ts index 8408078..79b5d63 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:

Hello

\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:

Hi

\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"; +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:

Hello

\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:

Hi

\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 BookmarkSavedResponse:\n type: object\n properties:\n bookmarked:\n type: boolean\n required:\n - bookmarked\n description: The doc is bookmarked (idempotent).\n BookmarkRemovedResponse:\n type: object\n properties:\n removed:\n type: boolean\n required:\n - removed\n description: The bookmark is removed (idempotent; also succeeds when none existed).\n BookmarkListItem:\n type: object\n properties:\n slug:\n type: string\n example: fierce-tiger-12345\n url:\n type:\n - string\n - 'null'\n format: uri\n example: https://justhtml.sh/d/fierce-tiger-12345\n description: >-\n Link to the doc (carries ?viewtoken= when reachable only through the stored token). null\n when access is revoked.\n title:\n type:\n - string\n - 'null'\n description: Live title while the doc is reachable; the title captured at bookmark time once revoked.\n access:\n type: string\n enum:\n - owner\n - editor\n - commenter\n - viewer\n - public\n - link\n - revoked\n description: >-\n Re-resolved per read: owner|editor|commenter|viewer for identity access, public for a\n public doc, link when reachable only via the stored view token, revoked when the doc was\n deleted or access was withdrawn.\n revoked:\n type: boolean\n description: True when the doc was deleted or the caller can no longer access it.\n public:\n type: boolean\n bookmarked_at:\n type: string\n format: date-time\n required:\n - slug\n - url\n - title\n - access\n - revoked\n - public\n - bookmarked_at\n description: A bookmarked document, with the caller's access re-resolved at read time.\n BookmarkListResponse:\n type: object\n properties:\n bookmarks:\n type: array\n items:\n $ref: '#/components/schemas/BookmarkListItem'\n required:\n - bookmarks\n description: The caller's bookmarked documents, newest first.\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 /api/v1/docs/{slug}/bookmark:\n put:\n tags:\n - bookmarks\n summary: Bookmark a document (idempotent)\n description: >-\n Saves the doc to the caller's bookmarks. Requires view access (owner, a grant, a public doc,\n or a matching ?viewtoken=); an inaccessible doc returns 404 (no existence oracle). Keyed by\n the key's email, so it unifies with the account's signed-in web bookmarks.\n operationId: addBookmark\n security:\n - bearerApiKey: []\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: Bookmarked\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/BookmarkSavedResponse'\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 delete:\n tags:\n - bookmarks\n summary: Remove a bookmark (idempotent)\n description: >-\n Removes the caller's bookmark for this doc. Idempotent (succeeds when none existed) and\n works on a revoked or deleted doc.\n operationId: removeBookmark\n security:\n - bearerApiKey: []\n parameters:\n - $ref: '#/components/parameters/Slug'\n responses:\n '200':\n description: Removed\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/BookmarkRemovedResponse'\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/bookmarks:\n get:\n tags:\n - bookmarks\n summary: List bookmarked documents (owned, shared, or both)\n description: >-\n The caller's bookmarks, newest first, each with access re-resolved at read time\n (owner|editor|commenter|viewer|public|link|revoked). The signed-in web equivalent is\n https://justhtml.sh/bookmarks.\n operationId: listBookmarks\n security:\n - bearerApiKey: []\n parameters:\n - schema:\n type: string\n enum:\n - owned\n - shared\n - all\n default: all\n description: >-\n owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the\n caller. all (default): both.\n required: false\n description: >-\n owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the caller.\n all (default): both.\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 caller's bookmarks\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/BookmarkListResponse'\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 /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.json b/lib/openapi/generated.json index b038f61..8638524 100644 --- a/lib/openapi/generated.json +++ b/lib/openapi/generated.json @@ -380,6 +380,104 @@ "additionalProperties": {}, "description": "Structured API error: { error, message, ...extra }." }, + "BookmarkSavedResponse": { + "type": "object", + "properties": { + "bookmarked": { + "type": "boolean" + } + }, + "required": [ + "bookmarked" + ], + "description": "The doc is bookmarked (idempotent)." + }, + "BookmarkRemovedResponse": { + "type": "object", + "properties": { + "removed": { + "type": "boolean" + } + }, + "required": [ + "removed" + ], + "description": "The bookmark is removed (idempotent; also succeeds when none existed)." + }, + "BookmarkListItem": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "fierce-tiger-12345" + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri", + "example": "https://justhtml.sh/d/fierce-tiger-12345", + "description": "Link to the doc (carries ?viewtoken= when reachable only through the stored token). null when access is revoked." + }, + "title": { + "type": [ + "string", + "null" + ], + "description": "Live title while the doc is reachable; the title captured at bookmark time once revoked." + }, + "access": { + "type": "string", + "enum": [ + "owner", + "editor", + "commenter", + "viewer", + "public", + "link", + "revoked" + ], + "description": "Re-resolved per read: owner|editor|commenter|viewer for identity access, public for a public doc, link when reachable only via the stored view token, revoked when the doc was deleted or access was withdrawn." + }, + "revoked": { + "type": "boolean", + "description": "True when the doc was deleted or the caller can no longer access it." + }, + "public": { + "type": "boolean" + }, + "bookmarked_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "slug", + "url", + "title", + "access", + "revoked", + "public", + "bookmarked_at" + ], + "description": "A bookmarked document, with the caller's access re-resolved at read time." + }, + "BookmarkListResponse": { + "type": "object", + "properties": { + "bookmarks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookmarkListItem" + } + } + }, + "required": [ + "bookmarks" + ], + "description": "The caller's bookmarked documents, newest first." + }, "GrantBody": { "type": "object", "properties": { @@ -3064,6 +3162,214 @@ } } }, + "/api/v1/docs/{slug}/bookmark": { + "put": { + "tags": [ + "bookmarks" + ], + "summary": "Bookmark a document (idempotent)", + "description": "Saves the doc to the caller's bookmarks. Requires view access (owner, a grant, a public doc, or a matching ?viewtoken=); an inaccessible doc returns 404 (no existence oracle). Keyed by the key's email, so it unifies with the account's signed-in web bookmarks.", + "operationId": "addBookmark", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "schema": { + "type": "string", + "description": "Present a doc's view token to comment/read as a token-holder (with identity). Not needed for owner/grantee sessions or API keys." + }, + "required": false, + "description": "Present a doc's view token to comment/read as a token-holder (with identity). Not needed for owner/grantee sessions or API keys.", + "name": "viewtoken", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Bookmarked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookmarkSavedResponse" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "bookmarks" + ], + "summary": "Remove a bookmark (idempotent)", + "description": "Removes the caller's bookmark for this doc. Idempotent (succeeds when none existed) and works on a revoked or deleted doc.", + "operationId": "removeBookmark", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "description": "Removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookmarkRemovedResponse" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/bookmarks": { + "get": { + "tags": [ + "bookmarks" + ], + "summary": "List bookmarked documents (owned, shared, or both)", + "description": "The caller's bookmarks, newest first, each with access re-resolved at read time (owner|editor|commenter|viewer|public|link|revoked). The signed-in web equivalent is https://justhtml.sh/bookmarks.", + "operationId": "listBookmarks", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "owned", + "shared", + "all" + ], + "default": "all", + "description": "owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the caller. all (default): both." + }, + "required": false, + "description": "owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the caller. all (default): both.", + "name": "scope", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 100 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The caller's bookmarks", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookmarkListResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/agent/identity": { "post": { "tags": [ diff --git a/lib/openapi/generated.yaml b/lib/openapi/generated.yaml index bc97460..00f4754 100644 --- a/lib/openapi/generated.yaml +++ b/lib/openapi/generated.yaml @@ -292,6 +292,83 @@ components: - message additionalProperties: {} description: 'Structured API error: { error, message, ...extra }.' + BookmarkSavedResponse: + type: object + properties: + bookmarked: + type: boolean + required: + - bookmarked + description: The doc is bookmarked (idempotent). + BookmarkRemovedResponse: + type: object + properties: + removed: + type: boolean + required: + - removed + description: The bookmark is removed (idempotent; also succeeds when none existed). + BookmarkListItem: + type: object + properties: + slug: + type: string + example: fierce-tiger-12345 + url: + type: + - string + - 'null' + format: uri + example: https://justhtml.sh/d/fierce-tiger-12345 + description: >- + Link to the doc (carries ?viewtoken= when reachable only through the stored token). null + when access is revoked. + title: + type: + - string + - 'null' + description: Live title while the doc is reachable; the title captured at bookmark time once revoked. + access: + type: string + enum: + - owner + - editor + - commenter + - viewer + - public + - link + - revoked + description: >- + Re-resolved per read: owner|editor|commenter|viewer for identity access, public for a + public doc, link when reachable only via the stored view token, revoked when the doc was + deleted or access was withdrawn. + revoked: + type: boolean + description: True when the doc was deleted or the caller can no longer access it. + public: + type: boolean + bookmarked_at: + type: string + format: date-time + required: + - slug + - url + - title + - access + - revoked + - public + - bookmarked_at + description: A bookmarked document, with the caller's access re-resolved at read time. + BookmarkListResponse: + type: object + properties: + bookmarks: + type: array + items: + $ref: '#/components/schemas/BookmarkListItem' + required: + - bookmarks + description: The caller's bookmarked documents, newest first. GrantBody: type: object properties: @@ -2101,6 +2178,149 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiError' + /api/v1/docs/{slug}/bookmark: + put: + tags: + - bookmarks + summary: Bookmark a document (idempotent) + description: >- + Saves the doc to the caller's bookmarks. Requires view access (owner, a grant, a public doc, + or a matching ?viewtoken=); an inaccessible doc returns 404 (no existence oracle). Keyed by + the key's email, so it unifies with the account's signed-in web bookmarks. + operationId: addBookmark + security: + - bearerApiKey: [] + parameters: + - $ref: '#/components/parameters/Slug' + - schema: + type: string + description: >- + Present a doc's view token to comment/read as a token-holder (with identity). Not + needed for owner/grantee sessions or API keys. + required: false + description: >- + Present a doc's view token to comment/read as a token-holder (with identity). Not needed + for owner/grantee sessions or API keys. + name: viewtoken + in: query + responses: + '200': + description: Bookmarked + content: + application/json: + schema: + $ref: '#/components/schemas/BookmarkSavedResponse' + '401': + description: Missing/invalid credential + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '404': + description: No such document (also returned for inaccessible docs; no existence oracle) + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + delete: + tags: + - bookmarks + summary: Remove a bookmark (idempotent) + description: >- + Removes the caller's bookmark for this doc. Idempotent (succeeds when none existed) and + works on a revoked or deleted doc. + operationId: removeBookmark + security: + - bearerApiKey: [] + parameters: + - $ref: '#/components/parameters/Slug' + responses: + '200': + description: Removed + content: + application/json: + schema: + $ref: '#/components/schemas/BookmarkRemovedResponse' + '401': + description: Missing/invalid credential + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + /api/v1/bookmarks: + get: + tags: + - bookmarks + summary: List bookmarked documents (owned, shared, or both) + description: >- + The caller's bookmarks, newest first, each with access re-resolved at read time + (owner|editor|commenter|viewer|public|link|revoked). The signed-in web equivalent is + https://justhtml.sh/bookmarks. + operationId: listBookmarks + security: + - bearerApiKey: [] + parameters: + - schema: + type: string + enum: + - owned + - shared + - all + default: all + description: >- + owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the + caller. all (default): both. + required: false + description: >- + owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the caller. + all (default): both. + name: scope + in: query + - schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + required: false + name: limit + in: query + responses: + '200': + description: The caller's bookmarks + content: + application/json: + schema: + $ref: '#/components/schemas/BookmarkListResponse' + '400': + description: Invalid request body or parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Missing/invalid credential + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' /agent/identity: post: tags: diff --git a/lib/skill-content.ts b/lib/skill-content.ts index cffa248..667bf70 100644 --- a/lib/skill-content.ts +++ b/lib/skill-content.ts @@ -208,6 +208,35 @@ WITH identity, or any identity on a public doc. Who can react: anyone who can view, with identity. Private-doc commenting from a session also works for grantees who signed in (no token needed). +## Bookmarks + +Save docs to a personal list — the same list the signed-in web /bookmarks page +shows, keyed by your account email. Saving needs only view access to the doc. +The list re-resolves access per item, so a doc whose access is later withdrawn +(grant removed, token rotated, or doc deleted) stays listed as "revoked" with no +link and the title it had when you bookmarked it. + +Bookmark a doc (idempotent) -> PUT /docs/:slug/bookmark + curl -s -X PUT https://justhtml.sh/api/v1/docs/fierce-tiger-12345/bookmark -H "Authorization: Bearer $JUSTHTML_API_KEY" + # -> 200 { bookmarked: true }. Requires view access (owner / grant / public / + # ?viewtoken=); an inaccessible doc 404s. For a private doc you reach + # only through its view token, pass ?viewtoken= so the bookmark keeps + # it (a later grant/public path drops the now-needless token). + +Remove a bookmark (idempotent) -> DELETE /docs/:slug/bookmark + curl -s -X DELETE https://justhtml.sh/api/v1/docs/fierce-tiger-12345/bookmark -H "Authorization: Bearer $JUSTHTML_API_KEY" + # -> 200 { removed: true }. Succeeds even if it wasn't bookmarked, or the doc + # has since been deleted / had access revoked. + +List bookmarks -> GET /bookmarks?scope=owned|shared|all + curl -s 'https://justhtml.sh/api/v1/bookmarks?scope=all' -H "Authorization: Bearer $JUSTHTML_API_KEY" + # -> { bookmarks:[ { slug, url, title, access, revoked, public, bookmarked_at } ] } + # newest first. access is re-resolved per read: owner|editor|commenter|viewer, + # public (a public doc), link (reachable only via the stored view token — url + # carries ?viewtoken=), or revoked (deleted / access withdrawn — url null, + # title is the bookmark-time snapshot). scope=all (default) is owned+shared. + # The signed-in web equivalent is https://justhtml.sh/bookmarks + ## Viewing https://justhtml.sh/d/:slug viewer shell (chrome + sandboxed iframe) diff --git a/skills/just-html/SKILL.md b/skills/just-html/SKILL.md index 693214f..ea8a06d 100644 --- a/skills/just-html/SKILL.md +++ b/skills/just-html/SKILL.md @@ -200,6 +200,35 @@ WITH identity, or any identity on a public doc. Who can react: anyone who can view, with identity. Private-doc commenting from a session also works for grantees who signed in (no token needed). +## Bookmarks + +Save docs to a personal list — the same list the signed-in web /bookmarks page +shows, keyed by your account email. Saving needs only view access to the doc. +The list re-resolves access per item, so a doc whose access is later withdrawn +(grant removed, token rotated, or doc deleted) stays listed as "revoked" with no +link and the title it had when you bookmarked it. + +Bookmark a doc (idempotent) -> PUT /docs/:slug/bookmark + curl -s -X PUT https://justhtml.sh/api/v1/docs/fierce-tiger-12345/bookmark -H "Authorization: Bearer $JUSTHTML_API_KEY" + # -> 200 { bookmarked: true }. Requires view access (owner / grant / public / + # ?viewtoken=); an inaccessible doc 404s. For a private doc you reach + # only through its view token, pass ?viewtoken= so the bookmark keeps + # it (a later grant/public path drops the now-needless token). + +Remove a bookmark (idempotent) -> DELETE /docs/:slug/bookmark + curl -s -X DELETE https://justhtml.sh/api/v1/docs/fierce-tiger-12345/bookmark -H "Authorization: Bearer $JUSTHTML_API_KEY" + # -> 200 { removed: true }. Succeeds even if it wasn't bookmarked, or the doc + # has since been deleted / had access revoked. + +List bookmarks -> GET /bookmarks?scope=owned|shared|all + curl -s 'https://justhtml.sh/api/v1/bookmarks?scope=all' -H "Authorization: Bearer $JUSTHTML_API_KEY" + # -> { bookmarks:[ { slug, url, title, access, revoked, public, bookmarked_at } ] } + # newest first. access is re-resolved per read: owner|editor|commenter|viewer, + # public (a public doc), link (reachable only via the stored view token — url + # carries ?viewtoken=), or revoked (deleted / access withdrawn — url null, + # title is the bookmark-time snapshot). scope=all (default) is owned+shared. + # The signed-in web equivalent is https://justhtml.sh/bookmarks + ## Viewing https://justhtml.sh/d/:slug viewer shell (chrome + sandboxed iframe)