diff --git a/app/api/v1/docs/[slug]/comments/[id]/route.ts b/app/api/v1/docs/[slug]/comments/[id]/route.ts index b64faa7..c2b8b83 100644 --- a/app/api/v1/docs/[slug]/comments/[id]/route.ts +++ b/app/api/v1/docs/[slug]/comments/[id]/route.ts @@ -6,10 +6,12 @@ import { findBySlug } from "@/lib/docs/store"; import { canView } from "@/lib/docs/access"; import { isOwner } from "@/lib/docs/grants"; import { checkLimits } from "@/lib/auth/ratelimit"; +import { parseAnchor, type TextAnchor } from "@/lib/docs/anchor"; import { findComment, commentView, editCommentBody, + reanchorComment, setResolved, softDeleteComment, resolveCommentPrincipal, @@ -23,8 +25,10 @@ export const dynamic = "force-dynamic"; type Ctx = { params: Promise<{ slug: string; id: string }> }; // /api/v1/docs/:slug/comments/:id -// PATCH — edit body (author only) and/or resolve|unresolve (anyone who can -// comment). birthday.md "Permission matrix". +// PATCH — edit body (author only), re-anchor/detach (author own, doc owner +// any; the manual fix for an orphaned thread), and/or +// resolve|unresolve (anyone who can comment). birthday.md +// "Permission matrix". // DELETE — soft-delete (author own, owner any). // // Auth: API key OR session, same as POST /comments. @@ -73,9 +77,10 @@ export async function PATCH(req: Request, ctx: Ctx): Promise { const b = parsed.obj; const hasBody = b.body !== undefined; + const hasAnchor = b.anchor !== undefined; const hasResolved = b.resolved !== undefined; - if (!hasBody && !hasResolved) { - return apiError(400, "invalid_request", "Provide 'body' (edit) and/or 'resolved' (resolve/unresolve)."); + if (!hasBody && !hasAnchor && !hasResolved) { + return apiError(400, "invalid_request", "Provide 'body' (edit), 'anchor' (re-anchor/detach), and/or 'resolved' (resolve/unresolve)."); } // Edit body: AUTHOR ONLY. The author 403 precedes field validation (ordering @@ -98,6 +103,27 @@ export async function PATCH(req: Request, ctx: Ctx): Promise { await editCommentBody(doc.id, commentId, body); } + // Re-anchor / detach: AUTHOR (own) OR DOC OWNER (any) — same shape as + // delete. The owner can repair orphaned threads on their document regardless + // of who authored them. A new quote re-resolves against the current doc text + // (un-orphaning on success); null detaches to a doc-level comment. Replies + // carry no anchor, same rule as POST /comments. + if (hasAnchor) { + if (!isAuthor && !cap.isOwner) { + return apiError(403, "forbidden", "Only the comment's author or the document owner can re-anchor it."); + } + if (comment.parent_id !== null) { + return apiError(400, "invalid_request", "A reply cannot carry its own anchor; omit 'anchor' on replies."); + } + let anchor: TextAnchor | null = null; + if (b.anchor !== null) { + const parsed = parseAnchor(b.anchor); + if ("error" in parsed) return apiError(400, "invalid_request", parsed.error); + anchor = parsed.anchor; + } + await reanchorComment(doc, commentId, anchor); + } + // Resolve / unresolve: ANYONE WHO CAN COMMENT. if (hasResolved) { const resolvedParse = PatchResolvedField.safeParse(b.resolved); diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 1511d2a..fb59c2c 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -84,6 +84,9 @@ type Props = { // server-side by canEdit). Everyone else — including view-token holders who may // comment — never sees the affordance, and the API would refuse them anyway. canEdit: boolean; + // The viewer owns this doc (resolved server-side). Owners can re-anchor ANY + // orphaned thread on their document; everyone else only their own. + isOwner: boolean; signedIn: boolean; docId: number; bookmarked: boolean; @@ -164,6 +167,7 @@ export default function CommentsShell(props: Props) { canComment, canReact, canEdit, + isOwner, signedIn, docId, me, @@ -278,6 +282,10 @@ export default function CommentsShell(props: Props) { // Selection state (from the overlay) → the floating toolbar + a pending draft. const [selection, setSelection] = useState<{ anchor: NonNullable; top: number; viewTop: number } | null>(null); const [draft, setDraft] = useState<{ anchor: NonNullable; top: number } | null>(null); + // Re-anchor mode: the author of an orphaned thread is picking a new passage + // in the doc. While set, a selection drives a confirm bar (PATCH anchor) + // instead of the comment/react toolbar. + const [reanchorId, setReanchorId] = useState(null); const apiBase = `/api/v1/docs/${encodeURIComponent(slug)}`; const tokenQuery = viewtoken ? `?viewtoken=${encodeURIComponent(viewtoken)}` : ""; @@ -576,6 +584,22 @@ export default function CommentsShell(props: Props) { [apiBase, tokenQuery, reload] ); + // Re-anchor a thread to a fresh quote (the orphaned-thread fix), or detach + // when the caller passes anchor null. Server enforces author-own / owner-any. + const reanchor = useCallback( + async (id: number, anchor: NonNullable | null) => { + const r = await fetch(`${apiBase}/comments/${id}${tokenQuery}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ anchor }), + }); + if (r.ok) await reload(); + return r.ok; + }, + [apiBase, tokenQuery, reload] + ); + const deleteComment = useCallback( async (id: number) => { const r = await fetch(`${apiBase}/comments/${id}${tokenQuery}`, { @@ -1036,7 +1060,27 @@ export default function CommentsShell(props: Props) { selection's viewport-top within the iframe. Shows on selection when the viewer can comment OR react (react-only viewers still get the react affordance). */} - {selection && !editing && (canComment || canReact) ? ( + {selection && !editing && reanchorId != null ? ( + { + const ok = await reanchor(reanchorId, selection.anchor); + setSelection(null); + postToOverlay({ type: "jh:clearSelection" }); + if (ok) { + setReanchorId(null); + showToast("comment re-anchored"); + } else { + showToast("couldn't re-anchor — only the author or doc owner can"); + } + }} + onCancel={() => { + setSelection(null); + postToOverlay({ type: "jh:clearSelection" }); + }} + /> + ) : selection && !editing && (canComment || canReact) ? ( + {reanchorId != null ? ( +
+ re-anchoring — select the new passage in the document{" "} + { + setReanchorId(null); + setSelection(null); + postToOverlay({ type: "jh:clearSelection" }); + }} + > + cancel + +
+ ) : null} + {/* Doc-level reactions, compact in the rail header (birthday.md B11: "doc-level reactions render compactly in the rail header"). The react chip set + a mini picker for anyone who can react. */} @@ -1132,9 +1192,12 @@ export default function CommentsShell(props: Props) { else postToOverlay({ type: "jh:focus", key: null }); }} onHover={(id) => setActiveId(id)} + me={me} + isOwner={isOwner} onReply={postComment} onResolve={toggleResolve} onDelete={deleteComment} + onReanchor={(id) => setReanchorId(id)} onReact={react} onSubmitDraft={async (body) => { if (!draft) return; @@ -1254,6 +1317,37 @@ function ThemeToggle({ mode, onChange }: { mode: ThemeMode; onChange: (m: ThemeM ); } +// Re-anchor confirm bar — shown in place of the selection toolbar while the +// author of an orphaned thread is picking the replacement passage. Same floating +// style/positioning as the selection toolbar. +function ReanchorBar({ + viewTop, + exact, + onConfirm, + onCancel, +}: { + viewTop: number; + exact: string; + onConfirm: () => void; + onCancel: () => void; +}) { + const top = `max(8px, min(${Math.max(8, Math.round(viewTop))}px, calc(100% - 84px)))`; + const excerpt = exact.length > 60 ? `${exact.slice(0, 60)}…` : exact; + return ( +
+ + re-anchor to “{excerpt}”? + + + +
+ ); +} + function SelectionToolbar({ viewTop, canComment, @@ -1313,11 +1407,14 @@ function RailCards(props: { activeId: number | null; canComment: boolean; draft: { anchor: NonNullable; top: number } | null; + me: string | null; + isOwner: boolean; onPin: (id: number | null) => void; onHover: (id: number | null) => void; onReply: (body: string, anchor: null, parentId: number) => Promise; onResolve: (id: number, resolved: boolean) => void; onDelete: (id: number) => void; + onReanchor: (id: number) => void; onReact: (emoji: string, commentId: number | null) => void; onSubmitDraft: (body: string) => void; onCancelDraft: () => void; @@ -1333,11 +1430,14 @@ function RailCards(props: { activeId, canComment, draft, + me, + isOwner, onPin, onHover, onReply, onResolve, onDelete, + onReanchor, onReact, onSubmitDraft, onCancelDraft, @@ -1413,6 +1513,8 @@ function RailCards(props: { onReply={(body) => onReply(body, null, t.id)} onResolve={(resolved) => onResolve(t.id, resolved)} onDelete={() => onDelete(t.id)} + canReanchor={isOwner || (me != null && t.author === me)} + onReanchor={() => onReanchor(t.id)} onReact={(emoji) => onReact(emoji, t.id)} onCopyLink={() => onCopyLink(t.id)} /> @@ -1453,11 +1555,13 @@ const Card = forwardRef< onReply: (body: string) => Promise; onResolve: (resolved: boolean) => void; onDelete: () => void; + canReanchor: boolean; + onReanchor: () => void; onReact: (emoji: string) => void; onCopyLink: () => void; } >(function Card( - { thread: t, pinned, active, canComment, onPin, onHoverIn, onHoverOut, onReply, onResolve, onDelete, onReact, onCopyLink }, + { thread: t, pinned, active, canComment, onPin, onHoverIn, onHoverOut, onReply, onResolve, onDelete, canReanchor, onReanchor, onReact, onCopyLink }, ref ) { const [replyText, setReplyText] = useState(""); @@ -1580,6 +1684,11 @@ const Card = forwardRef< onDelete()}> delete + {t.orphaned && canReanchor ? ( + onReanchor()}> + re-anchor + + ) : null} {showEmoji ? ( {EMOJIS.map((e) => ( diff --git a/app/d/[slug]/page.tsx b/app/d/[slug]/page.tsx index 8cdaf8f..28c7d84 100644 --- a/app/d/[slug]/page.tsx +++ b/app/d/[slug]/page.tsx @@ -91,11 +91,13 @@ export default async function ViewerPage({ params, searchParams }: Props) { let canComment = false; let canReact = false; let canEditDoc = false; + let isOwner = false; if (principal) { const cap = await resolveCapability(doc, principal, canView(doc, viewtoken)); canComment = cap.canComment; canReact = cap.canReact; canEditDoc = canEdit(cap.access); + isOwner = cap.isOwner; } const threadData = await allThreads(doc); @@ -130,6 +132,7 @@ export default async function ViewerPage({ params, searchParams }: Props) { canComment={canComment} canReact={canReact} canEdit={canEditDoc} + isOwner={isOwner} signedIn={session !== null} docId={doc.id} bookmarked={bookmarked} diff --git a/lib/docs/comments.ts b/lib/docs/comments.ts index 6d6c884..ba77819 100644 --- a/lib/docs/comments.ts +++ b/lib/docs/comments.ts @@ -416,6 +416,41 @@ export async function createComment(opts: { } } +/** + * Re-anchor a comment to a new quote — the manual fix for an orphaned thread + * whose text was rewritten rather than deleted (automatic re-anchoring only + * un-orphans when the ORIGINAL quote comes back). Author (own) or doc owner + * (any) — enforced by caller. `anchor === null` detaches: the comment becomes + * doc-level. Otherwise + * the new selector is resolved against the current doc text exactly like at + * creation (resolveInitialAnchor), so a re-anchored comment and a freshly + * created one land identically: offsets re-stamped, anchored_version set to the + * current version, orphaned recomputed — a new quote that doesn't resolve yet + * keeps the comment orphaned with the NEW selector stored, so a later restoring + * edit can still un-orphan it. + */ +export async function reanchorComment( + doc: DocRow, + commentId: number, + anchor: TextAnchor | null +): Promise { + if (anchor === null) { + await query( + `UPDATE comments SET anchor = NULL, anchored_version = NULL, orphaned = false + WHERE id = $1 AND doc_id = $2 AND deleted_at IS NULL`, + [commentId, doc.id] + ); + } else { + const { anchorJson, orphaned, anchoredVersion } = resolveInitialAnchor(doc.html, anchor, doc.version); + await query( + `UPDATE comments SET anchor = $3, anchored_version = $4, orphaned = $5 + WHERE id = $1 AND doc_id = $2 AND deleted_at IS NULL`, + [commentId, doc.id, anchorJson, anchoredVersion, orphaned] + ); + } + return findComment(doc.id, commentId); +} + /** Edit a comment's body (author only — enforced by caller). Sets edited_at. */ export async function editCommentBody(docId: number, commentId: number, body: string): Promise { await query( diff --git a/lib/docs/paths.ts b/lib/docs/paths.ts index 41db2e8..7901355 100644 --- a/lib/docs/paths.ts +++ b/lib/docs/paths.ts @@ -490,12 +490,14 @@ registry.registerPath({ }, }); -// PATCH /api/v1/docs/{slug}/comments/{id} — edit body / resolve toggle +// PATCH /api/v1/docs/{slug}/comments/{id} — edit body / re-anchor / resolve toggle registry.registerPath({ method: "patch", path: "/api/v1/docs/{slug}/comments/{id}", tags: ["collaboration"], - summary: "Edit body (author) and/or resolve/unresolve (anyone who can comment)", + summary: "Edit body (author), re-anchor/detach (author or doc owner), and/or resolve/unresolve (anyone who can comment)", + description: + "anchor re-anchors the comment to a new quote (re-resolved against the current text; un-orphans on success) or, when null, detaches it to a doc-level comment — the manual fix for an orphaned thread whose quoted text was rewritten. The comment's author or the document owner; root comments only.", operationId: "updateComment", security: keyOrSessionSecurity, request: { @@ -510,7 +512,7 @@ registry.registerPath({ 400: { description: "Invalid request body or parameters", content: jsonError }, 401: { description: "Missing/invalid credential", content: jsonError }, 403: { - description: "Editing another author's body, or resolving without comment rights", + description: "Editing another author's body, re-anchoring without being the author or doc owner, or resolving without comment rights", content: jsonError, }, 404: { diff --git a/lib/docs/schemas.ts b/lib/docs/schemas.ts index 7e2da63..b309b84 100644 --- a/lib/docs/schemas.ts +++ b/lib/docs/schemas.ts @@ -710,10 +710,14 @@ export const CreateCommentBody = registry.register( // --- PATCH /comments/{id} body ------------------------------------------- -// UpdateCommentBody: { body?, resolved? }. At least one is required (the route's -// "Provide 'body' (edit) and/or 'resolved'…" 400 stays in the route — it is -// ordering-sensitive: it runs before the author/cap checks). body (author only) -// is a non-empty string ≤ cap (byte cap in route); resolved is a boolean. +// UpdateCommentBody: { body?, anchor?, resolved? }. At least one is required +// (the route's "Provide 'body' (edit), 'anchor' (re-anchor/detach), and/or +// 'resolved'…" 400 stays in the route — it is ordering-sensitive: it runs +// before the author/cap checks). body (author only) is a non-empty string ≤ cap +// (byte cap in route); anchor (author own, doc owner any) re-anchors to a new +// quote or, when null, detaches to a doc-level comment (parsed/normalized by +// parseAnchor in the route, exactly like CreateCommentBody's anchor); resolved +// is a boolean. export const UpdateCommentBody = registry.register( "UpdateCommentBody", z @@ -722,6 +726,16 @@ export const UpdateCommentBody = registry.register( .string() .optional() .openapi({ description: "Author only. The new comment text (<= 10 KB)." }), + // anchor stays PERMISSIVE at runtime: parseAnchor (lib/docs/anchor.ts) is + // the authoritative parse+normalize, and the author/owner 403 + the + // reply-cannot-anchor 400 sit around it in the route (ordering-sensitive). + anchor: z + .unknown() + .optional() + .openapi({ + description: + "The comment's author or the document owner; root comments only. Re-anchor to a new quote (W3C text-quote selector) — re-resolved against the current text, un-orphaning on success — or null to detach to a doc-level comment. The manual fix for an orphaned thread whose quoted text was rewritten.", + }), resolved: z .boolean() .optional() @@ -729,7 +743,7 @@ export const UpdateCommentBody = registry.register( }) .openapi("UpdateCommentBody", { description: - "Edit body (author) and/or resolve/unresolve (anyone who can comment). At least one field is required.", + "Edit body (author), re-anchor/detach (author or document owner), and/or resolve/unresolve (anyone who can comment). At least one field is required.", }) ); diff --git a/lib/openapi/generated-spec.ts b/lib/openapi/generated-spec.ts index da52566..81a13d5 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 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. Identity: API key OR\n signed-in session (the viewer's inline edit mode posts here). 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 - {}\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"; +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 anchor:\n description: >-\n The comment's author or the document owner; root comments only. Re-anchor to a new quote\n (W3C text-quote selector) — re-resolved against the current text, un-orphaning on\n success — or null to detach to a doc-level comment. The manual fix for an orphaned\n thread whose quoted text was rewritten.\n resolved:\n type: boolean\n description: Resolve/unresolve. Anyone who can comment.\n description: >-\n Edit body (author), re-anchor/detach (author or document owner), and/or resolve/unresolve\n (anyone who can comment). At least one field is 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. Identity: API key OR\n signed-in session (the viewer's inline edit mode posts here). 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 - {}\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: >-\n Edit body (author), re-anchor/detach (author or doc owner), and/or resolve/unresolve (anyone\n who can comment)\n description: >-\n anchor re-anchors the comment to a new quote (re-resolved against the current text;\n un-orphans on success) or, when null, detaches it to a doc-level comment — the manual fix\n for an orphaned thread whose quoted text was rewritten. The comment's author or the document\n owner; root comments only.\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: >-\n Editing another author's body, re-anchoring without being the author or doc owner, or\n 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 4e4a9c1..8c90863 100644 --- a/lib/openapi/generated.json +++ b/lib/openapi/generated.json @@ -881,12 +881,15 @@ "type": "string", "description": "Author only. The new comment text (<= 10 KB)." }, + "anchor": { + "description": "The comment's author or the document owner; root comments only. Re-anchor to a new quote (W3C text-quote selector) — re-resolved against the current text, un-orphaning on success — or null to detach to a doc-level comment. The manual fix for an orphaned thread whose quoted text was rewritten." + }, "resolved": { "type": "boolean", "description": "Resolve/unresolve. Anyone who can comment." } }, - "description": "Edit body (author) and/or resolve/unresolve (anyone who can comment). At least one field is required." + "description": "Edit body (author), re-anchor/detach (author or document owner), and/or resolve/unresolve (anyone who can comment). At least one field is required." }, "CreateReactionBody": { "type": "object", @@ -2852,7 +2855,8 @@ "tags": [ "collaboration" ], - "summary": "Edit body (author) and/or resolve/unresolve (anyone who can comment)", + "summary": "Edit body (author), re-anchor/detach (author or doc owner), and/or resolve/unresolve (anyone who can comment)", + "description": "anchor re-anchors the comment to a new quote (re-resolved against the current text; un-orphans on success) or, when null, detaches it to a doc-level comment — the manual fix for an orphaned thread whose quoted text was rewritten. The comment's author or the document owner; root comments only.", "operationId": "updateComment", "security": [ { @@ -2910,7 +2914,7 @@ } }, "403": { - "description": "Editing another author's body, or resolving without comment rights", + "description": "Editing another author's body, re-anchoring without being the author or doc owner, or resolving without comment rights", "content": { "application/json": { "schema": { diff --git a/lib/openapi/generated.yaml b/lib/openapi/generated.yaml index cd6f9b1..58275ac 100644 --- a/lib/openapi/generated.yaml +++ b/lib/openapi/generated.yaml @@ -672,12 +672,18 @@ components: body: type: string description: Author only. The new comment text (<= 10 KB). + anchor: + description: >- + The comment's author or the document owner; root comments only. Re-anchor to a new quote + (W3C text-quote selector) — re-resolved against the current text, un-orphaning on + success — or null to detach to a doc-level comment. The manual fix for an orphaned + thread whose quoted text was rewritten. resolved: type: boolean description: Resolve/unresolve. Anyone who can comment. description: >- - Edit body (author) and/or resolve/unresolve (anyone who can comment). At least one field is - required. + Edit body (author), re-anchor/detach (author or document owner), and/or resolve/unresolve + (anyone who can comment). At least one field is required. CreateReactionBody: type: object properties: @@ -1992,7 +1998,14 @@ paths: patch: tags: - collaboration - summary: Edit body (author) and/or resolve/unresolve (anyone who can comment) + summary: >- + Edit body (author), re-anchor/detach (author or doc owner), and/or resolve/unresolve (anyone + who can comment) + description: >- + anchor re-anchors the comment to a new quote (re-resolved against the current text; + un-orphans on success) or, when null, detaches it to a doc-level comment — the manual fix + for an orphaned thread whose quoted text was rewritten. The comment's author or the document + owner; root comments only. operationId: updateComment security: - bearerApiKey: [] @@ -2026,7 +2039,9 @@ paths: schema: $ref: '#/components/schemas/ApiError' '403': - description: Editing another author's body, or resolving without comment rights + description: >- + Editing another author's body, re-anchoring without being the author or doc owner, or + resolving without comment rights content: application/json: schema: diff --git a/lib/skill-content.ts b/lib/skill-content.ts index 1afc780..f6335ed 100644 --- a/lib/skill-content.ts +++ b/lib/skill-content.ts @@ -157,7 +157,9 @@ Omit "anchor" (or send null) for a DOC-LEVEL comment. "parent_id" makes a reply (1-level threads only). Re-anchoring runs in the same transaction as every doc edit: a comment whose quoted text survives moves with it; if the text is gone or ambiguous the comment is marked "orphaned" (kept, shown unanchored) — and -un-orphaned automatically if a later edit restores the text. +un-orphaned automatically if a later edit restores the text. If the text was +REWRITTEN (so the original quote never comes back), the author can re-anchor +the orphaned thread to a replacement quote manually (see PATCH below). Comment on a quote -> POST /docs/:slug/comments { body, anchor?, parent_id? } curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/comments \\ @@ -173,9 +175,14 @@ See the WHOLE picture (what humans see) -> GET /docs/:slug/comments # replies:[...] } ] } # anchored threads in document order, then # doc-level, then orphaned. Resolved threads carry resolved:true. -Reply / edit / resolve / delete -> PATCH|DELETE /docs/:slug/comments/:id +Reply / edit / re-anchor / resolve / delete -> PATCH|DELETE /docs/:slug/comments/:id # Reply: POST /comments with {"body":"+1","parent_id": } # Edit body (author only): PATCH /comments/:id {"body":"..."} + # Re-anchor an orphaned thread to a replacement quote (the comment's author + # or the document owner; root comments only): + # PATCH /comments/:id {"anchor":{"exact":"the new passage","prefix":"...","suffix":"..."}} + # -> 200 { comment: { ..., orphaned:false } } when the new quote resolves. + # Send {"anchor":null} instead to detach the thread to doc-level. # Resolve/unresolve (anyone who can comment): PATCH /comments/:id {"resolved":true} # Delete (author own, owner any; soft): DELETE /comments/:id diff --git a/scripts/e2e.ts b/scripts/e2e.ts index 07ba655..4be0b9a 100644 --- a/scripts/e2e.ts +++ b/scripts/e2e.ts @@ -243,6 +243,33 @@ async function main() { checkSchema("GET /comments", "GET", "/api/v1/docs/{slug}/comments", threadsRes.status, threads); check("GET /comments returns the thread", (threads.threads ?? []).length >= 1); + // Orphan + manual re-anchor: rewrite the doc so the quoted sentence is gone, + // then re-anchor the orphaned thread to a replacement quote, then detach it. + const rewrite = await jh(`/api/v1/docs/${slug}`, { + method: "PATCH", + headers: authJson(ownerKey), + body: JSON.stringify({ html: `

E2E ${marker}

replaced entirely.

` }), + }); + check("rewrite removing the quote succeeds", rewrite.status === 200, `status ${rewrite.status}`); + const orphanedThreads = await (await jh(`/api/v1/docs/${slug}/comments`, { headers: { Authorization: `Bearer ${ownerKey}` } })).json(); + const orphanedThread = (orphanedThreads.threads ?? []).find((t: { id: number }) => t.id === commentJson.comment.id); + check("thread orphaned after the quote is rewritten away", orphanedThread?.orphaned === true && orphanedThread?.group === "orphaned", JSON.stringify(orphanedThread).slice(0, 120)); + const reanchorRes = await jh(`/api/v1/docs/${slug}/comments/${commentJson.comment.id}`, { + method: "PATCH", + headers: authJson(ownerKey), + body: JSON.stringify({ anchor: { exact: "replaced entirely", prefix: "", suffix: "." } }), + }); + const reanchorJson = await reanchorRes.json(); + checkSchema("PATCH /comments/:id (re-anchor)", "PATCH", "/api/v1/docs/{slug}/comments/{id}", reanchorRes.status, reanchorJson); + check("re-anchor un-orphans the thread", reanchorRes.status === 200 && reanchorJson.comment?.orphaned === false, `status ${reanchorRes.status}`); + const detachRes = await jh(`/api/v1/docs/${slug}/comments/${commentJson.comment.id}`, { + method: "PATCH", + headers: authJson(ownerKey), + body: JSON.stringify({ anchor: null }), + }); + const detachJson = await detachRes.json(); + check("anchor:null detaches the thread to doc-level", detachRes.status === 200 && detachJson.comment?.anchor === null && detachJson.comment?.orphaned === false, `status ${detachRes.status}`); + // --- Phase 5: share by email -> grantee gets a one-click login that lands on the doc --- console.log("Phase 5 — share + grantee one-click login"); const granteeInbox = await createInbox(); @@ -280,6 +307,30 @@ async function main() { const sess = cookie.split(";")[0]; const asGrantee = await jh(`/d/${slug}`, { headers: { Cookie: sess } }); check("grantee session views the private doc (no token)", asGrantee.status === 200, `status ${asGrantee.status}`); + + // Re-anchor permissions: the doc owner can re-anchor ANY thread (here the + // grantee's); a non-author editor grantee cannot re-anchor someone else's. + const granteeComment = await jh(`/api/v1/docs/${slug}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: sess }, + body: JSON.stringify({ body: "grantee thread", anchor: { exact: "replaced entirely" } }), + }); + const granteeCommentJson = await granteeComment.json(); + check("grantee comment created", granteeComment.status === 201 && !!granteeCommentJson.comment?.id, `status ${granteeComment.status}`); + if (granteeCommentJson.comment?.id) { + const ownerReanchor = await jh(`/api/v1/docs/${slug}/comments/${granteeCommentJson.comment.id}`, { + method: "PATCH", + headers: authJson(ownerKey), + body: JSON.stringify({ anchor: { exact: `E2E ${marker}` } }), + }); + check("doc owner can re-anchor another author's thread", ownerReanchor.status === 200, `status ${ownerReanchor.status}`); + const granteeReanchor = await jh(`/api/v1/docs/${slug}/comments/${commentJson.comment.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Cookie: sess }, + body: JSON.stringify({ anchor: { exact: `E2E ${marker}` } }), + }); + check("non-author editor cannot re-anchor (403)", granteeReanchor.status === 403, `status ${granteeReanchor.status}`); + } } } diff --git a/skills/just-html/SKILL.md b/skills/just-html/SKILL.md index c6080e5..22f0ad8 100644 --- a/skills/just-html/SKILL.md +++ b/skills/just-html/SKILL.md @@ -149,7 +149,9 @@ Omit "anchor" (or send null) for a DOC-LEVEL comment. "parent_id" makes a reply (1-level threads only). Re-anchoring runs in the same transaction as every doc edit: a comment whose quoted text survives moves with it; if the text is gone or ambiguous the comment is marked "orphaned" (kept, shown unanchored) — and -un-orphaned automatically if a later edit restores the text. +un-orphaned automatically if a later edit restores the text. If the text was +REWRITTEN (so the original quote never comes back), the author can re-anchor +the orphaned thread to a replacement quote manually (see PATCH below). Comment on a quote -> POST /docs/:slug/comments { body, anchor?, parent_id? } curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/comments \ @@ -165,9 +167,14 @@ See the WHOLE picture (what humans see) -> GET /docs/:slug/comments # replies:[...] } ] } # anchored threads in document order, then # doc-level, then orphaned. Resolved threads carry resolved:true. -Reply / edit / resolve / delete -> PATCH|DELETE /docs/:slug/comments/:id +Reply / edit / re-anchor / resolve / delete -> PATCH|DELETE /docs/:slug/comments/:id # Reply: POST /comments with {"body":"+1","parent_id": } # Edit body (author only): PATCH /comments/:id {"body":"..."} + # Re-anchor an orphaned thread to a replacement quote (the comment's author + # or the document owner; root comments only): + # PATCH /comments/:id {"anchor":{"exact":"the new passage","prefix":"...","suffix":"..."}} + # -> 200 { comment: { ..., orphaned:false } } when the new quote resolves. + # Send {"anchor":null} instead to detach the thread to doc-level. # Resolve/unresolve (anyone who can comment): PATCH /comments/:id {"resolved":true} # Delete (author own, owner any; soft): DELETE /comments/:id