From 805e270c660b2489f751a36307d3baf9372eed90 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:05:06 +0000 Subject: [PATCH] Publish integration discovery metadata --- .github/workflows/spec-sync.yml | 11 +- DEVELOPMENT.md | 24 +- README.md | 2 +- .../agent-skills/index.json/route.ts | 33 + .../agent-skills/just-html/SKILL.md/route.ts | 12 + app/.well-known/integrations.json/route.ts | 60 + app/openapi.json/route.ts | 14 + app/route.ts | 4 +- lib/discovery.test.ts | 153 + lib/openapi/generated.json | 3449 +++++++++++++++++ lib/skill-content.ts | 10 +- scripts/gen-skill.ts | 15 +- scripts/gen-spec.ts | 36 +- scripts/spec-check.ts | 43 +- skills/just-html/SKILL.md | 3 +- 15 files changed, 3815 insertions(+), 54 deletions(-) create mode 100644 app/.well-known/agent-skills/index.json/route.ts create mode 100644 app/.well-known/agent-skills/just-html/SKILL.md/route.ts create mode 100644 app/.well-known/integrations.json/route.ts create mode 100644 app/openapi.json/route.ts create mode 100644 lib/discovery.test.ts create mode 100644 lib/openapi/generated.json diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index 698fc9a..db7aa85 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -1,7 +1,8 @@ # Keep the committed OpenAPI spec artifacts in sync with the Zod schemas they are -# generated from. The served spec (lib/openapi/generated-spec.ts, returned by -# GET /api/spec.yaml) and lib/openapi/generated.yaml are produced by -# `npm run gen:spec` from the registry in lib/{docs,auth}/{schemas,paths}.ts. +# generated from. The served specs (lib/openapi/generated-spec.ts, returned by +# GET /api/spec.yaml, and lib/openapi/generated.json, returned by /openapi.json) +# plus lib/openapi/generated.yaml are produced by `npm run gen:spec` from the +# registry in lib/{docs,auth}/{schemas,paths}.ts. # This job regenerates them whenever the schemas/paths/generator change and # commits the result if it drifted, so the served spec can never lag the schemas. # This is a CONTENT-SYNC job, not a deploy — production deploys come from the @@ -42,13 +43,13 @@ jobs: run: npm run gen:spec - name: Commit if changed run: | - if git diff --quiet -- lib/openapi/generated.yaml lib/openapi/generated-spec.ts; then + if git diff --quiet -- lib/openapi/generated.yaml lib/openapi/generated.json lib/openapi/generated-spec.ts; then echo "Spec artifacts already in sync." exit 0 fi git config user.name "justhtml-bot" git config user.email "noreply@anthropic.com" - git add lib/openapi/generated.yaml lib/openapi/generated-spec.ts + git add lib/openapi/generated.yaml lib/openapi/generated.json lib/openapi/generated-spec.ts # The push only touches lib/openapi/generated*, which are NOT in this # workflow's path filter, so it will not re-trigger the job (no loop). git commit -m "spec-sync: regenerate the OpenAPI spec from the Zod schemas" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0ecd5e8..68394f2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -63,16 +63,18 @@ Edit the content in `lib/skill-content.ts`, never the generated files. ## The OpenAPI spec -`/api/spec.yaml` is **code-first**: generated from the Zod schemas + paths -registered in `lib/{docs,auth}/{schemas,paths}.ts` (the single source of truth -for request/response validation AND the documented surface), so the spec can -never drift from the code. Same pattern as the skill above: +The OpenAPI 3.1 document is **code-first**: generated from the Zod schemas + +paths registered in `lib/{docs,auth}/{schemas,paths}.ts` (the single source of +truth for request/response validation AND the documented surface), so the spec +can never drift from the code. Same pattern as the skill above: - `npm run gen:spec` (runs `scripts/gen-spec.ts` via `tsx`) runs the - `OpenApiGeneratorV31` over the registry and writes two committed artifacts: - `lib/openapi/generated-spec.ts` (the `SPEC_YAML` the route serves) and - `lib/openapi/generated.yaml` (the same bytes as a file, for `@redocly/cli`). -- `app/api/spec.yaml/route.ts` serves `SPEC_YAML` verbatim. + `OpenApiGeneratorV31` over the registry and writes three committed artifacts: + `lib/openapi/generated-spec.ts` (the `SPEC_YAML` the YAML route serves), + `lib/openapi/generated.yaml` (the same bytes as a file, for `@redocly/cli`), + and `lib/openapi/generated.json` (the canonical discovery document). +- `app/openapi.json/route.ts` serves the canonical JSON document; + `app/api/spec.yaml/route.ts` keeps serving `SPEC_YAML` verbatim. - `npm run spec:check` re-runs the generator and asserts the committed artifacts match it byte-for-byte (drift guard), then cross-checks that the served spec's paths, the on-disk route handlers, and the `/llms.txt` body all agree. @@ -103,7 +105,11 @@ Agent / discovery (plain text or JSON, zero JS): - `GET /` — homepage / man-page docs (NAME … SEE ALSO + a copy-pasteable agent prompt). - `GET /auth.md` — prose auth protocol. - `GET /llms.txt` — terse agent usage: every endpoint with a curl example + limits. -- `GET /api/spec.yaml` — OpenAPI 3.1 (validated with `@redocly/cli`). +- `GET /openapi.json` — canonical OpenAPI 3.1 JSON document. +- `GET /api/spec.yaml` — alternate OpenAPI 3.1 YAML document (validated with `@redocly/cli`). +- `GET /.well-known/integrations.json` — integrations.sh v3 surface declaration. +- `GET /.well-known/agent-skills/index.json` — Agent Skills discovery index. +- `GET /.well-known/agent-skills/just-html/SKILL.md` — discoverable skill artifact. - `GET /.well-known/oauth-protected-resource`, `GET /.well-known/oauth-authorization-server`. Auth: diff --git a/README.md b/README.md index 54810ba..315d944 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ curl -s https://justhtml.sh/api/v1/docs \ # Share the private link: ?viewtoken= ``` -Full endpoint reference with a curl example for every call: [`/llms.txt`](https://justhtml.sh/llms.txt) · OpenAPI 3.1: [`/api/spec.yaml`](https://justhtml.sh/api/spec.yaml). +Full endpoint reference with a curl example for every call: [`/llms.txt`](https://justhtml.sh/llms.txt) · OpenAPI 3.1: [`/openapi.json`](https://justhtml.sh/openapi.json) ([YAML](https://justhtml.sh/api/spec.yaml)). ## How it works diff --git a/app/.well-known/agent-skills/index.json/route.ts b/app/.well-known/agent-skills/index.json/route.ts new file mode 100644 index 0000000..72ec4c0 --- /dev/null +++ b/app/.well-known/agent-skills/index.json/route.ts @@ -0,0 +1,33 @@ +import { createHash } from "node:crypto"; + +import { ORIGIN } from "@/lib/auth/config"; +import { + SKILL_DESCRIPTION, + SKILL_MARKDOWN, + SKILL_NAME, +} from "@/lib/skill-content"; + +export const dynamic = "force-static"; + +const digest = createHash("sha256").update(SKILL_MARKDOWN).digest("hex"); +const BODY = JSON.stringify({ + $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + skills: [ + { + name: SKILL_NAME, + type: "skill-md", + description: SKILL_DESCRIPTION, + url: `${ORIGIN}/.well-known/agent-skills/${SKILL_NAME}/SKILL.md`, + digest: `sha256:${digest}`, + }, + ], +}); + +export function GET(): Response { + return new Response(BODY, { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/app/.well-known/agent-skills/just-html/SKILL.md/route.ts b/app/.well-known/agent-skills/just-html/SKILL.md/route.ts new file mode 100644 index 0000000..0f23a90 --- /dev/null +++ b/app/.well-known/agent-skills/just-html/SKILL.md/route.ts @@ -0,0 +1,12 @@ +import { SKILL_MARKDOWN } from "@/lib/skill-content"; + +export const dynamic = "force-static"; + +export function GET(): Response { + return new Response(SKILL_MARKDOWN, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/app/.well-known/integrations.json/route.ts b/app/.well-known/integrations.json/route.ts new file mode 100644 index 0000000..95cc934 --- /dev/null +++ b/app/.well-known/integrations.json/route.ts @@ -0,0 +1,60 @@ +import { ORIGIN } from "@/lib/auth/config"; + +export const dynamic = "force-static"; + +const DISCOVERY_URL = `${ORIGIN}/.well-known/integrations.json`; +const basis = { via: "declared", source: DISCOVERY_URL } as const; + +const BODY = JSON.stringify({ + version: 3, + summary: + "Publish, share, edit, and discuss HTML documents through the justhtml.sh HTTP API.", + credentials: { + "justhtml-api-key": { + type: "api_key", + label: "justhtml.sh API key", + generateUrl: `${ORIGIN}/auth.md`, + setup: + "Follow the [agent authentication flow](https://justhtml.sh/auth.md): register the human's email, have them read back the emailed 6-digit code, complete the claim, then poll the token endpoint for the long-lived `jh_live_…` key. Store it securely (the examples use `JUSTHTML_API_KEY`) and send it as a Bearer token.", + }, + }, + surfaces: [ + { + type: "http", + slug: "justhtml-api", + name: "justhtml.sh HTTP API", + docs: `${ORIGIN}/llms.txt`, + spec: `${ORIGIN}/openapi.json`, + url: `${ORIGIN}/api/v1`, + basis, + auth: { + status: "required", + entries: [ + { + use: [ + { + id: "justhtml-api-key", + mechanics: { + source: "http", + in: "header", + headerName: "Authorization", + scheme: "Bearer", + }, + }, + ], + basis, + }, + ], + }, + }, + ], +}); + +export function GET(): Response { + return new Response(BODY, { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/app/openapi.json/route.ts b/app/openapi.json/route.ts new file mode 100644 index 0000000..fb72210 --- /dev/null +++ b/app/openapi.json/route.ts @@ -0,0 +1,14 @@ +import spec from "@/lib/openapi/generated.json"; + +export const dynamic = "force-static"; + +const BODY = JSON.stringify(spec); + +export function GET(): Response { + return new Response(BODY, { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/app/route.ts b/app/route.ts index 318f87a..a07794c 100644 --- a/app/route.ts +++ b/app/route.ts @@ -4,7 +4,7 @@ import { htmlResponse } from "@/lib/page"; // always light mode, single JUSTHTML.SH(1) header, left-aligned sections with // hanging-indent bodies, one quiet footer. The SYNOPSIS *is* the paste-to-your- // agent prompt — the single hero element and the growth loop. NO API docs live -// here; those are canonical at /llms.txt and /api/spec.yaml. +// here; those are canonical at /llms.txt and /openapi.json. // // Served as a dynamic route-handler response (new Response(html)) per the style // rules — NOT force-static (force-static turns handler output into a CDN static @@ -105,7 +105,7 @@ framework — the document you publish is the document people see.

SEE ALSO

/auth.md        how agents sign up + authenticate
 /llms.txt       agent-facing usage
-/api/spec.yaml  OpenAPI spec
+/openapi.json   OpenAPI spec
 /docs           your documents
 github          source
diff --git a/lib/discovery.test.ts b/lib/discovery.test.ts new file mode 100644 index 0000000..86222e6 --- /dev/null +++ b/lib/discovery.test.ts @@ -0,0 +1,153 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { GET as getSkillIndex } from "@/app/.well-known/agent-skills/index.json/route"; +import { GET as getSkill } from "@/app/.well-known/agent-skills/just-html/SKILL.md/route"; +import { GET as getIntegrations } from "@/app/.well-known/integrations.json/route"; +import { GET as getOpenApi } from "@/app/openapi.json/route"; + +const basis = z + .object({ + via: z.literal("declared"), + source: z.literal("https://justhtml.sh/.well-known/integrations.json"), + }) + .strict(); + +const integrationsV3 = z + .object({ + version: z.literal(3), + summary: z.string().optional(), + credentials: z + .record( + z.string(), + z + .object({ + type: z.string(), + label: z.string(), + generateUrl: z.string().url().optional(), + setup: z.string(), + }) + .strict() + ) + .optional(), + surfaces: z + .array( + z + .object({ + type: z.literal("http"), + slug: z.string(), + name: z.string(), + docs: z.string().url().optional(), + spec: z.string().url().optional(), + url: z.string().url().optional(), + basis, + auth: z + .object({ + status: z.literal("required"), + entries: z.array( + z + .object({ + use: z.array( + z + .object({ + id: z.string(), + mechanics: z + .object({ + source: z.literal("http"), + in: z.literal("header"), + headerName: z.string(), + scheme: z.string(), + }) + .strict(), + }) + .strict() + ), + basis, + }) + .strict() + ), + }) + .strict(), + }) + .strict() + ) + .optional(), + }) + .strict(); + +describe("integration discovery", () => { + it("serves a valid integrations.sh v3 declaration", async () => { + const response = getIntegrations(); + expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("public, max-age=3600"); + + const document = integrationsV3.parse(await response.json()); + expect(document.surfaces).toHaveLength(1); + expect(document.surfaces?.[0]).toMatchObject({ + type: "http", + url: "https://justhtml.sh/api/v1", + spec: "https://justhtml.sh/openapi.json", + auth: { + status: "required", + entries: [ + { + use: [ + { + id: "justhtml-api-key", + mechanics: { + source: "http", + in: "header", + headerName: "Authorization", + scheme: "Bearer", + }, + }, + ], + }, + ], + }, + }); + }); + + it("serves the generated OpenAPI document as JSON", async () => { + const response = getOpenApi(); + expect(response.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("public, max-age=3600"); + + const document = (await response.json()) as { + openapi: string; + servers: Array<{ url: string }>; + components: { securitySchemes: Record }; + }; + expect(document.openapi).toBe("3.1.0"); + expect(document.servers).toContainEqual( + expect.objectContaining({ url: "https://justhtml.sh" }) + ); + expect(document.components.securitySchemes).toHaveProperty("bearerApiKey"); + }); + + it("publishes an Agent Skills index with a matching artifact digest", async () => { + const indexResponse = getSkillIndex(); + const skillResponse = getSkill(); + expect(indexResponse.headers.get("content-type")).toBe("application/json; charset=utf-8"); + expect(skillResponse.headers.get("content-type")).toBe("text/markdown; charset=utf-8"); + + const index = (await indexResponse.json()) as { + $schema: string; + skills: Array<{ name: string; type: string; url: string; digest: string }>; + }; + const skill = await skillResponse.text(); + const digest = createHash("sha256").update(skill).digest("hex"); + + expect(index.$schema).toBe("https://schemas.agentskills.io/discovery/0.2.0/schema.json"); + expect(index.skills).toEqual([ + expect.objectContaining({ + name: "just-html", + type: "skill-md", + url: "https://justhtml.sh/.well-known/agent-skills/just-html/SKILL.md", + digest: `sha256:${digest}`, + }), + ]); + }); +}); diff --git a/lib/openapi/generated.json b/lib/openapi/generated.json new file mode 100644 index 0000000..b038f61 --- /dev/null +++ b/lib/openapi/generated.json @@ -0,0 +1,3449 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "justhtml.sh API", + "version": "1.0.0", + "description": "An agent-first minimal HTML document host. Agents self-onboard via the\nauth.md service_auth flow (see https://justhtml.sh/auth.md), receive a\nlong-lived API key, and publish HTML documents to stable URLs.\n\nTerse usage with curl examples: https://justhtml.sh/llms.txt\n", + "license": { + "name": "Proprietary", + "url": "https://justhtml.sh/" + } + }, + "servers": [ + { + "url": "https://justhtml.sh", + "description": "Production" + } + ], + "tags": [ + { + "name": "auth", + "description": "auth.md service_auth registration + OAuth token/revoke" + }, + { + "name": "discovery", + "description": "Machine-readable OAuth discovery metadata" + }, + { + "name": "docs", + "description": "Document CRUD, patch editing, versions" + }, + { + "name": "sharing", + "description": "Per-document grants (email or domain)" + }, + { + "name": "collaboration", + "description": "Comments (W3C text-quote anchors, 1-level threads) and reactions" + } + ], + "security": [ + { + "bearerApiKey": [] + } + ], + "components": { + "securitySchemes": { + "bearerApiKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "jh_live_...", + "description": "Long-lived API key obtained via the auth.md service_auth flow. Carries scopes docs.read docs.write. 401s include a WWW-Authenticate header pointing at the protected-resource metadata." + } + }, + "schemas": { + "CreateDocBody": { + "type": "object", + "properties": { + "html": { + "type": "string", + "description": "The document HTML.", + "example": "

Hello

" + }, + "title": { + "type": [ + "string", + "null" + ], + "maxLength": 300, + "description": "Optional document title.", + "example": "My doc" + }, + "public": { + "type": "boolean", + "default": false, + "description": "Whether the document is public." + } + }, + "required": [ + "html" + ], + "description": "Create a document. html is required; title and public are optional." + }, + "UpdateDocBody": { + "type": "object", + "properties": { + "html": { + "type": "string", + "description": "Replacement HTML (full rewrite, bumps version).", + "example": "

Hi

" + }, + "title": { + "type": [ + "string", + "null" + ], + "maxLength": 300, + "description": "New title, or null to clear it." + }, + "public": { + "type": "boolean", + "description": "New visibility flag (owner only)." + } + }, + "description": "Update html (full rewrite), title, or visibility. At least one field is required. Editors may rewrite html; only the owner may change title or public." + }, + "OwnerDoc": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "fierce-tiger-12345" + }, + "url": { + "type": "string", + "format": "uri", + "example": "https://justhtml.sh/d/fierce-tiger-12345" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "integer" + }, + "public": { + "type": "boolean" + }, + "view_token": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "html": { + "type": "string" + } + }, + "required": [ + "slug", + "url", + "title", + "version", + "public", + "view_token", + "created_at", + "updated_at" + ], + "description": "Document as seen by its owner (includes view_token)." + }, + "GranteeDoc": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "fierce-tiger-12345" + }, + "url": { + "type": "string", + "format": "uri", + "example": "https://justhtml.sh/d/fierce-tiger-12345" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "integer" + }, + "public": { + "type": "boolean" + }, + "role": { + "type": "string", + "enum": [ + "editor", + "commenter", + "viewer" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "html": { + "type": "string" + } + }, + "required": [ + "slug", + "url", + "title", + "version", + "public", + "role", + "created_at", + "updated_at" + ], + "description": "Document as seen by a non-owner grantee (role instead of view_token)." + }, + "DocWithHtml": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "fierce-tiger-12345" + }, + "url": { + "type": "string", + "format": "uri", + "example": "https://justhtml.sh/d/fierce-tiger-12345" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "integer" + }, + "public": { + "type": "boolean" + }, + "view_token": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "editor", + "commenter", + "viewer" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "html": { + "type": "string" + } + }, + "required": [ + "slug", + "url", + "title", + "version", + "public", + "created_at", + "updated_at" + ], + "description": "Owner sees view_token; a grantee sees role (editor/commenter/viewer) instead. html is included on single-doc fetches and after writes." + }, + "DocListItem": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "example": "fierce-tiger-12345" + }, + "url": { + "type": "string", + "format": "uri", + "example": "https://justhtml.sh/d/fierce-tiger-12345" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "access": { + "type": "string", + "enum": [ + "owner", + "editor", + "commenter", + "viewer" + ], + "description": "The caller's access to this doc. owner for docs you own; otherwise the resolved grant role (an explicit email grant beats a domain grant for the same email)." + }, + "version": { + "type": "integer" + }, + "public": { + "type": "boolean" + }, + "comment_count": { + "type": "integer", + "description": "Live (non-deleted) comments + replies on the doc. 0 when there are none. The /docs dashboard surfaces the same count." + }, + "view_token": { + "type": "string", + "description": "Present only when access=owner." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "slug", + "url", + "title", + "access", + "version", + "public", + "comment_count", + "created_at", + "updated_at" + ], + "description": "A document as returned by GET /api/v1/docs (any scope). Carries access (owner|editor|commenter|viewer). Owned items (access=owner) additionally carry view_token; shared items omit it." + }, + "DocListResponse": { + "type": "object", + "properties": { + "docs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocListItem" + } + } + }, + "required": [ + "docs" + ], + "description": "The matched documents." + }, + "DeleteDocResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "deleted": { + "type": "boolean" + } + }, + "required": [ + "slug", + "deleted" + ], + "description": "Soft-delete acknowledgement." + }, + "ApiError": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "additionalProperties": {}, + "description": "Structured API error: { error, message, ...extra }." + }, + "GrantBody": { + "type": "object", + "properties": { + "email": { + "type": [ + "string", + "null" + ], + "format": "email", + "description": "Grantee email (provide exactly one of email or domain)." + }, + "domain": { + "type": [ + "string", + "null" + ], + "example": "kernel.sh", + "description": "Grantee email-domain (provide exactly one of email or domain)." + }, + "role": { + "type": "string", + "enum": [ + "editor", + "commenter", + "viewer" + ], + "description": "Grant role." + }, + "notify": { + "type": "boolean", + "default": true, + "description": "Email-grants only. Send the grantee a share-notification email (default true). Ignored for domain grants." + } + }, + "required": [ + "role" + ], + "description": "Share with an email or a domain. Provide exactly one of email or domain. role is editor, commenter, or viewer. notify (email grants only) defaults to true." + }, + "Grant": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "grantee_type": { + "type": "string", + "enum": [ + "email", + "domain" + ] + }, + "grantee": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "editor", + "commenter", + "viewer" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "grantee_type", + "grantee", + "role", + "created_at" + ], + "description": "A single grant (email or domain) on a document." + }, + "GrantListResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "grants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Grant" + } + }, + "count": { + "type": "integer" + }, + "max": { + "type": "integer", + "example": 50 + } + }, + "required": [ + "slug", + "grants", + "count", + "max" + ], + "description": "Grants on the document (owner only)." + }, + "GrantCreatedResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "grant": { + "$ref": "#/components/schemas/Grant" + } + }, + "required": [ + "slug", + "grant" + ], + "description": "Grant created." + }, + "GrantUnchangedResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "grant": { + "$ref": "#/components/schemas/Grant" + }, + "unchanged": { + "type": "boolean" + } + }, + "required": [ + "slug", + "grant", + "unchanged" + ], + "description": "Idempotent re-grant (same target + role)." + }, + "GrantDeletedResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "grant_id": { + "type": "integer" + }, + "deleted": { + "type": "boolean" + } + }, + "required": [ + "slug", + "grant_id", + "deleted" + ], + "description": "Grant revoked." + }, + "VersionMeta": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "edit_kind": { + "type": "string", + "enum": [ + "create", + "patch", + "rewrite" + ] + }, + "author_user_id": { + "type": [ + "integer", + "null" + ], + "description": "User who authored this version (null for legacy/system writes)." + }, + "patch": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldText": { + "type": "string" + }, + "newText": { + "type": "string" + } + }, + "required": [ + "oldText", + "newText" + ] + }, + "description": "The edits payload as requested, present only when edit_kind=patch (the list of {oldText,newText} applied). Omitted otherwise." + }, + "bytes": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "version", + "edit_kind", + "author_user_id", + "bytes", + "created_at" + ], + "description": "Metadata for one retained version (no html)." + }, + "VersionListResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "current_version": { + "type": "integer" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VersionMeta" + } + } + }, + "required": [ + "slug", + "current_version", + "versions" + ], + "description": "Version metadata (no html), newest first." + }, + "VersionSnapshot": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "edit_kind": { + "type": "string", + "enum": [ + "create", + "patch", + "rewrite" + ] + }, + "author_user_id": { + "type": [ + "integer", + "null" + ] + }, + "patch": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldText": { + "type": "string" + }, + "newText": { + "type": "string" + } + }, + "required": [ + "oldText", + "newText" + ] + } + }, + "bytes": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "html": { + "type": "string" + } + }, + "required": [ + "slug", + "version", + "edit_kind", + "author_user_id", + "bytes", + "created_at", + "html" + ], + "description": "A version's metadata plus its full html snapshot." + }, + "EditsBody": { + "type": "object", + "properties": { + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldText": { + "type": "string" + }, + "newText": { + "type": "string" + } + }, + "required": [ + "oldText", + "newText" + ] + }, + "minItems": 1, + "maxItems": 200, + "description": "The patches to apply, in order. 1–200 edits." + }, + "base_version": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "description": "The version the edits were derived against; a mismatch returns 409." + } + }, + "required": [ + "edits" + ], + "description": "Apply deterministic patches. edits is a non-empty list of {oldText,newText}. Always send base_version; a mismatch returns 409." + }, + "TextAnchor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "exact": { + "type": "string", + "example": "deterministic compaction" + }, + "prefix": { + "type": "string", + "example": "record store with " + }, + "suffix": { + "type": "string", + "example": "." + }, + "start": { + "type": "integer" + }, + "end": { + "type": "integer" + } + }, + "required": [ + "exact" + ], + "description": "W3C text-quote selector (TextQuoteSelector + position hint). exact is the verbatim quoted passage; prefix/suffix (~32 chars) disambiguate repeated text and survive surrounding shifts; start/end are offsets into the document's text content (a fast-path hint, not authoritative)." + }, + "CreateCommentBody": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "Comment text (<= 10 KB).", + "example": "is this right?" + }, + "anchor": { + "description": "W3C text-quote selector; null/omitted = doc-level." + }, + "parent_id": { + "type": "integer", + "description": "Root comment id to reply to (1-level threads only)." + } + }, + "required": [ + "body" + ], + "description": "Comment on a span by QUOTING it (anchor), at the doc level (omit anchor), or reply to a root comment (parent_id)." + }, + "UpdateCommentBody": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "Author only. The new comment text (<= 10 KB)." + }, + "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." + }, + "CreateReactionBody": { + "type": "object", + "properties": { + "emoji": { + "type": "string", + "enum": [ + "👍", + "👎", + "🎉", + "🤔", + "❤️", + "🚀", + "👀", + "😄", + "🙏", + "🔥", + "✅", + "💯" + ], + "description": "One of the curated set: 👍 👎 🎉 🤔 ❤️ 🚀 👀 😄 🙏 🔥 ✅ 💯. Anything else → 400 invalid_request with an \"allowed\" array listing the full set.", + "example": "🚀" + }, + "comment_id": { + "type": "integer", + "description": "Target comment; omit/null = not a comment reaction. Mutually exclusive with anchor." + }, + "anchor": { + "description": "Target span (W3C text-quote selector). Mutually exclusive with comment_id; omit/null = react on the doc (or comment)." + } + }, + "required": [ + "emoji" + ], + "description": "Add an emoji reaction. The target is 3-way and mutually exclusive: comment_id (a comment), anchor (a span), or neither (the whole doc). Supplying both comment_id and anchor → 400." + }, + "ReactionGroup": { + "type": "object", + "properties": { + "emoji": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "authors": { + "type": "array", + "items": { + "type": "string", + "description": "Author email." + } + } + }, + "required": [ + "emoji", + "count", + "authors" + ], + "description": "Reactions collapsed by emoji, with the attributed authors." + }, + "AnchoredReactionGroup": { + "type": "object", + "properties": { + "sig": { + "type": "string", + "description": "Anchor signature (prefix|exact|suffix) — the grouping key." + }, + "anchor": { + "$ref": "#/components/schemas/TextAnchor" + }, + "anchored_version": { + "type": [ + "integer", + "null" + ] + }, + "reactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReactionGroup" + } + } + }, + "required": [ + "sig", + "anchor", + "anchored_version", + "reactions" + ], + "description": "All reactions on one text span, grouped by anchor signature, then collapsed per emoji. The viewer paints one highlight on the span and a chip per emoji at the span's end." + }, + "Comment": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "parent_id": { + "type": [ + "integer", + "null" + ] + }, + "author": { + "type": [ + "string", + "null" + ], + "description": "Author email." + }, + "author_avatar": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "Gravatar URL." + }, + "body": { + "type": "string" + }, + "anchor": { + "allOf": [ + { + "$ref": "#/components/schemas/TextAnchor" + }, + { + "type": [ + "object", + "null" + ] + } + ] + }, + "anchored_version": { + "type": [ + "integer", + "null" + ] + }, + "orphaned": { + "type": "boolean", + "description": "Anchor no longer resolves; kept, shown unanchored." + }, + "resolved": { + "type": "boolean" + }, + "resolved_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "edited_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "reactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReactionGroup" + } + } + }, + "required": [ + "id", + "parent_id", + "author", + "author_avatar", + "body", + "anchor", + "anchored_version", + "orphaned", + "resolved", + "resolved_at", + "created_at", + "edited_at", + "reactions" + ], + "description": "A single comment (with its aggregated reactions)." + }, + "CommentThread": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "parent_id": { + "type": [ + "integer", + "null" + ] + }, + "author": { + "type": [ + "string", + "null" + ] + }, + "author_avatar": { + "type": [ + "string", + "null" + ] + }, + "body": { + "type": "string" + }, + "anchor": { + "allOf": [ + { + "$ref": "#/components/schemas/TextAnchor" + }, + { + "type": [ + "object", + "null" + ] + } + ] + }, + "anchored_version": { + "type": [ + "integer", + "null" + ] + }, + "orphaned": { + "type": "boolean" + }, + "resolved": { + "type": "boolean" + }, + "resolved_at": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "edited_at": { + "type": [ + "string", + "null" + ] + }, + "reactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReactionGroup" + } + }, + "group": { + "type": "string", + "enum": [ + "anchored", + "doc", + "orphaned" + ], + "description": "Which group this thread sorts into in the all-threads view." + }, + "replies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Comment" + } + } + }, + "required": [ + "id", + "parent_id", + "author", + "author_avatar", + "body", + "anchor", + "anchored_version", + "orphaned", + "resolved", + "resolved_at", + "created_at", + "edited_at", + "reactions", + "group", + "replies" + ], + "description": "A root comment with its group tag and 1-level replies." + }, + "CommentsListResponse": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "total": { + "type": "integer", + "description": "Live comment + reply count." + }, + "can_comment": { + "type": "boolean" + }, + "can_react": { + "type": "boolean" + }, + "threads": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommentThread" + } + }, + "doc_reactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReactionGroup" + }, + "description": "Doc-level reactions (present only when any exist). Includes orphaned anchored reactions degraded to doc-level." + }, + "anchored_reactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AnchoredReactionGroup" + }, + "description": "Span reactions grouped by anchor signature, in document order, so clients stack/count without re-grouping (present only when any exist)." + } + }, + "required": [ + "slug", + "version", + "total", + "can_comment", + "can_react", + "threads" + ], + "description": "The complete all-threads view." + }, + "CommentCreatedResponse": { + "type": "object", + "properties": { + "comment": { + "$ref": "#/components/schemas/Comment" + } + }, + "required": [ + "comment" + ], + "description": "Comment created." + }, + "CommentUpdatedResponse": { + "type": "object", + "properties": { + "comment": { + "$ref": "#/components/schemas/Comment" + } + }, + "required": [ + "comment" + ], + "description": "Comment updated." + }, + "CommentDeletedResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "deleted": { + "type": "boolean" + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Comment soft-deleted." + }, + "ReactionCreatedResponse": { + "type": "object", + "properties": { + "reaction": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "comment_id": { + "type": [ + "integer", + "null" + ] + }, + "anchor": { + "allOf": [ + { + "$ref": "#/components/schemas/TextAnchor" + }, + { + "type": [ + "object", + "null" + ] + } + ] + }, + "anchored_version": { + "type": [ + "integer", + "null" + ] + }, + "orphaned": { + "type": "boolean" + }, + "emoji": { + "type": "string" + }, + "author": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "comment_id", + "anchor", + "anchored_version", + "orphaned", + "emoji", + "author", + "created_at" + ] + } + }, + "required": [ + "reaction" + ], + "description": "Reaction added." + }, + "ReactionToggledResponse": { + "type": "object", + "properties": { + "toggled": { + "type": "boolean" + }, + "removed": { + "type": "boolean" + } + }, + "required": [ + "toggled", + "removed" + ], + "description": "Reaction toggled off (the same reaction already existed)." + }, + "ReactionDeletedResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "deleted": { + "type": "boolean" + } + }, + "required": [ + "id", + "deleted" + ], + "description": "Reaction removed." + }, + "ClaimBlock": { + "type": "object", + "properties": { + "complete_url": { + "type": "string", + "format": "uri", + "description": "POST {claim_token, user_code} here to complete the claim." + }, + "expires_in": { + "type": "integer", + "example": 600 + }, + "interval": { + "type": "integer", + "example": 5 + } + }, + "required": [ + "complete_url", + "expires_in", + "interval" + ], + "description": "The claim block. The user_code is intentionally omitted — it is emailed to the human (the only place it appears). The human reads it back to the agent, which POSTs {claim_token, user_code} to complete_url (/agent/identity/claim/complete)." + }, + "AgentError": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "description": "Agent ceremony error: { error, message }." + }, + "OAuthError": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "error_description": { + "type": "string" + } + }, + "required": [ + "error" + ], + "description": "OAuth error envelope (RFC 6749): { error, error_description? }." + }, + "StartRegistrationBody": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "service_auth" + ], + "description": "The registration type." + }, + "login_hint": { + "type": "string", + "format": "email", + "example": "you@example.com", + "description": "The human's email address." + } + }, + "required": [ + "type", + "login_hint" + ], + "description": "Start a service_auth registration; the 6-digit code is emailed to login_hint." + }, + "RemintClaimBody": { + "type": "object", + "properties": { + "claim_token": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email", + "description": "Corrected email; updates the registration's login_hint." + } + }, + "required": [ + "claim_token", + "email" + ], + "description": "Re-mint an expired code; a fresh code is emailed to the human." + }, + "CompleteClaimBody": { + "type": "object", + "properties": { + "claim_token": { + "type": "string" + }, + "user_code": { + "type": "string", + "pattern": "^[0-9]{6}$", + "example": "428117" + } + }, + "required": [ + "claim_token", + "user_code" + ], + "description": "Complete a claim by reading the emailed 6-digit code back to the agent." + }, + "TokenForm": { + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "enum": [ + "urn:workos:agent-auth:grant-type:claim" + ], + "description": "The claim grant type." + }, + "claim_token": { + "type": "string" + } + }, + "required": [ + "grant_type", + "claim_token" + ], + "description": "Claim-grant token request (form-encoded)." + }, + "RevokeForm": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "token_type_hint": { + "type": "string", + "enum": [ + "access_token" + ] + } + }, + "required": [ + "token" + ], + "description": "RFC 7009 revocation request (form-encoded)." + }, + "StartRegistrationResponse": { + "type": "object", + "properties": { + "registration_id": { + "type": "string" + }, + "registration_type": { + "type": "string", + "enum": [ + "service_auth" + ] + }, + "claim_url": { + "type": "string", + "format": "uri" + }, + "claim_token": { + "type": "string", + "description": "Secret; returned once. Hold in memory only." + }, + "claim_token_expires": { + "type": "string", + "format": "date-time" + }, + "post_claim_scopes": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "docs.read", + "docs.write" + ] + }, + "claim": { + "$ref": "#/components/schemas/ClaimBlock" + } + }, + "required": [ + "registration_id", + "registration_type", + "claim_url", + "claim_token", + "claim_token_expires", + "post_claim_scopes", + "claim" + ], + "description": "Pending registration created; code emailed to the human." + }, + "RemintClaimResponse": { + "type": "object", + "properties": { + "registration_id": { + "type": "string" + }, + "claim_attempt_id": { + "type": "string" + }, + "status": { + "type": "string", + "example": "initiated" + }, + "claim_attempt": { + "$ref": "#/components/schemas/ClaimBlock" + } + }, + "required": [ + "registration_id", + "claim_attempt_id", + "status", + "claim_attempt" + ], + "description": "Fresh code emailed." + }, + "CompleteClaimResponse": { + "type": "object", + "properties": { + "registration_id": { + "type": "string" + }, + "status": { + "type": "string", + "example": "claimed" + }, + "message": { + "type": "string" + } + }, + "required": [ + "registration_id", + "status", + "message" + ], + "description": "Claim confirmed; poll /oauth2/token for the key." + }, + "TokenResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "jh_live_..." + }, + "token_type": { + "type": "string", + "enum": [ + "Bearer" + ] + }, + "scope": { + "type": "string", + "example": "docs.read docs.write" + }, + "credential_type": { + "type": "string", + "enum": [ + "api_key" + ] + }, + "registration_id": { + "type": "string" + } + }, + "required": [ + "access_token", + "token_type", + "scope", + "credential_type", + "registration_id" + ], + "description": "Credential issued (once)." + }, + "ProtectedResourceMetadata": { + "type": "object", + "properties": {}, + "additionalProperties": {}, + "description": "RFC 9728 protected-resource metadata." + }, + "AuthServerMetadata": { + "type": "object", + "properties": {}, + "additionalProperties": {}, + "description": "RFC 8414 authorization-server metadata (with agent_auth block)." + }, + "Slug": { + "type": "string", + "example": "fierce-tiger-12345" + }, + "VersionNum": { + "type": "integer", + "minimum": 1, + "example": 3 + }, + "GrantId": { + "type": "integer", + "minimum": 1, + "example": 1 + }, + "CommentId": { + "type": "integer", + "minimum": 1, + "example": 42 + }, + "ReactionId": { + "type": "integer", + "minimum": 1, + "example": 7 + } + }, + "parameters": { + "Slug": { + "schema": { + "$ref": "#/components/schemas/Slug" + }, + "required": true, + "name": "slug", + "in": "path" + }, + "VersionNum": { + "schema": { + "$ref": "#/components/schemas/VersionNum" + }, + "required": true, + "name": "n", + "in": "path" + }, + "GrantId": { + "schema": { + "$ref": "#/components/schemas/GrantId" + }, + "required": true, + "name": "id", + "in": "path" + }, + "CommentId": { + "schema": { + "$ref": "#/components/schemas/CommentId" + }, + "required": true, + "name": "id", + "in": "path" + }, + "ReactionId": { + "schema": { + "$ref": "#/components/schemas/ReactionId" + }, + "required": true, + "name": "id", + "in": "path" + } + } + }, + "paths": { + "/api/v1/docs": { + "post": { + "tags": [ + "docs" + ], + "summary": "Create a document", + "operationId": "createDoc", + "security": [ + { + "bearerApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocBody" + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerDoc" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "A resource quota was exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "413": { + "description": "HTML exceeds the 2 MB per-document size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "get": { + "tags": [ + "docs" + ], + "summary": "List documents (owned, shared, or both)", + "description": "Lists documents by scope. Every item carries an access role (owner|editor|commenter|viewer). For a doc matched by both an email grant and a domain grant, the email grant wins (precedence ladder). Owned items additionally carry view_token; shared items do not (the view token is an owner-only capability). The web equivalent for a signed-in human is https://justhtml.sh/docs.", + "operationId": "listDocs", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "owned", + "shared", + "all" + ], + "default": "owned", + "description": "owned (default): docs the caller owns. shared: docs granted to the caller's email or email-domain, excluding docs the caller owns. all: owned then shared." + }, + "required": false, + "description": "owned (default): docs the caller owns. shared: docs granted to the caller's email or email-domain, excluding docs the caller owns. all: owned then shared.", + "name": "scope", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 100 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The matched documents", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocListResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}": { + "get": { + "tags": [ + "docs" + ], + "summary": "Fetch a document (metadata + html)", + "operationId": "getDoc", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "description": "Owner sees view_token; a grantee sees role instead of view_token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocWithHtml" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "docs" + ], + "summary": "Update html (full rewrite), title, or visibility", + "description": "Owner or editor grant may rewrite html. Only the owner may change title or public (visibility).", + "operationId": "updateDoc", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDocBody" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocWithHtml" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Editor tried to change title/visibility", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "413": { + "description": "HTML exceeds the 2 MB per-document size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "docs" + ], + "summary": "Soft-delete a document (owner only)", + "operationId": "deleteDoc", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteDocResponse" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/edits": { + "post": { + "tags": [ + "docs" + ], + "summary": "Apply deterministic patches", + "description": "exact-match-then-fuzzy edit application. Owner or editor grant. Always send base_version; a mismatch returns 409. Ambiguous, no-match, or overlapping edits return 422 naming the failing edit index.", + "operationId": "editDoc", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Patched", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocWithHtml" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "base_version conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "413": { + "description": "HTML exceeds the 2 MB per-document size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "422": { + "description": "An edit could not be applied deterministically", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/rotate-token": { + "post": { + "tags": [ + "docs" + ], + "summary": "Rotate the view token (un-share; owner only)", + "operationId": "rotateViewToken", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "description": "New view token issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerDoc" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/versions": { + "get": { + "tags": [ + "docs" + ], + "summary": "List retained version history (newest first)", + "operationId": "listVersions", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "description": "Version metadata (no html)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionListResponse" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/versions/{n}": { + "get": { + "tags": [ + "docs" + ], + "summary": "Fetch a specific version's full html", + "operationId": "getVersion", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "$ref": "#/components/parameters/VersionNum" + } + ], + "responses": { + "200": { + "description": "Version snapshot with html", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionSnapshot" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/grants": { + "get": { + "tags": [ + "sharing" + ], + "summary": "List grants (owner only)", + "operationId": "listGrants", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "description": "Grants on the document", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantListResponse" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "tags": [ + "sharing" + ], + "summary": "Share with an email or a domain (owner only)", + "description": "Provide exactly one of email or domain. role is editor, commenter, or viewer. Consumer email providers (gmail.com, ...) are rejected with 422. Re-granting the same target+role is idempotent (200 with unchanged:true). Email grants send the grantee a share-notification email containing ONE single-use, 7-day login link with next=/d/:slug; set notify:false to suppress it. DOMAIN grants NEVER notify (notify is ignored for them).", + "operationId": "createGrant", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantBody" + } + } + } + }, + "responses": { + "200": { + "description": "Idempotent re-grant (same target + role)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantUnchangedResponse" + } + } + } + }, + "201": { + "description": "Grant created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantCreatedResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "A resource quota was exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "422": { + "description": "Consumer email domain rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/grants/{id}": { + "delete": { + "tags": [ + "sharing" + ], + "summary": "Revoke a grant (owner only)", + "operationId": "deleteGrant", + "security": [ + { + "bearerApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "$ref": "#/components/parameters/GrantId" + } + ], + "responses": { + "200": { + "description": "Grant revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GrantDeletedResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/comments": { + "get": { + "tags": [ + "collaboration" + ], + "summary": "List all comment threads (the complete all-threads view)", + "description": "Returns every live thread the caller can see, exactly as the viewer shell shows humans: anchored threads in document order, then doc-level threads, then orphaned threads, each carrying resolved/orphaned flags, 1-level replies, and reactions. Read access required (owner/grant via identity, a valid view token, or a public doc).", + "operationId": "listComments", + "security": [ + { + "bearerApiKey": [] + }, + {} + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "schema": { + "type": "string", + "description": "Present a doc's view token to comment/read as a token-holder (with identity). Not needed for owner/grantee sessions or API keys." + }, + "required": false, + "description": "Present a doc's view token to comment/read as a token-holder (with identity). Not needed for owner/grantee sessions or API keys.", + "name": "viewtoken", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All threads", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentsListResponse" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "tags": [ + "collaboration" + ], + "summary": "Post a comment (anchored to a quote, doc-level, or a reply)", + "description": "Comment on a span by QUOTING it (anchor), at the doc level (omit anchor), or reply to a root comment (parent_id). Identity required: API key OR signed-in session — anonymous never writes. Permission to comment: owner, editor or commenter grant, view-token holder with identity, or any identity on a public doc.", + "operationId": "createComment", + "security": [ + { + "bearerApiKey": [] + }, + {} + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "schema": { + "type": "string", + "description": "Present a doc's view token to comment/read as a token-holder (with identity). Not needed for owner/grantee sessions or API keys." + }, + "required": false, + "description": "Present a doc's view token to comment/read as a token-holder (with identity). Not needed for owner/grantee sessions or API keys.", + "name": "viewtoken", + "in": "query" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCommentBody" + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentCreatedResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Can view but not comment (e.g. a viewer-only grant)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "413": { + "description": "Comment body exceeds 10 KB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/comments/{id}": { + "patch": { + "tags": [ + "collaboration" + ], + "summary": "Edit body (author) and/or resolve/unresolve (anyone who can comment)", + "operationId": "updateComment", + "security": [ + { + "bearerApiKey": [] + }, + {} + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "$ref": "#/components/parameters/CommentId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCommentBody" + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentUpdatedResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Editing another author's body, or resolving without comment rights", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "collaboration" + ], + "summary": "Soft-delete a comment (author own, owner any)", + "operationId": "deleteComment", + "security": [ + { + "bearerApiKey": [] + }, + {} + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "$ref": "#/components/parameters/CommentId" + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentDeletedResponse" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not the author and not the owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/reactions": { + "post": { + "tags": [ + "collaboration" + ], + "summary": "React to a doc, a comment, or a quoted span (attributed; re-post toggles off)", + "description": "Add an emoji reaction. The target is 3-way and MUTUALLY EXCLUSIVE: comment_id set → react on that comment; anchor set → react on a text span (W3C text-quote, same shape + validation as a comment anchor; an agent \"highlights\" by quoting); neither set → react on the whole document. Supplying BOTH comment_id and anchor → 400. Attributed-only (identity required); unique per (target, author, emoji) — for span reactions the \"target\" is the anchor signature, so the same emoji on two different spans are two distinct reactions. Re-posting the same reaction removes it (toggle). Anchored reactions re-anchor on every doc edit exactly like comments (move, or orphan + later un-orphan); an orphaned anchored reaction degrades to doc-level display. React permission: anyone who can view, with identity.", + "operationId": "addReaction", + "security": [ + { + "bearerApiKey": [] + }, + {} + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReactionBody" + } + } + } + }, + "responses": { + "200": { + "description": "Reaction toggled off (the same reaction already existed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReactionToggledResponse" + } + } + } + }, + "201": { + "description": "Reaction added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReactionCreatedResponse" + } + } + } + }, + "400": { + "description": "Invalid request body or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "422": { + "description": "comment_id does not reference a live comment on this document", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/docs/{slug}/reactions/{id}": { + "delete": { + "tags": [ + "collaboration" + ], + "summary": "Remove your own reaction", + "operationId": "deleteReaction", + "security": [ + { + "bearerApiKey": [] + }, + {} + ], + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + }, + { + "$ref": "#/components/parameters/ReactionId" + } + ], + "responses": { + "200": { + "description": "Removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReactionDeletedResponse" + } + } + } + }, + "401": { + "description": "Missing/invalid credential", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such document (also returned for inaccessible docs; no existence oracle)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/agent/identity": { + "post": { + "tags": [ + "auth" + ], + "summary": "Start a service_auth registration", + "description": "Creates a pending registration (no user account is created yet), emails the human a 6-digit code, and returns a claim_token plus a claim block. There is exactly one flow: justhtml.sh emails the login_hint the code (the code and nothing else — no links). The user_code is NEVER returned in the response (the email is the binding proof). The human reads the code back to the agent, which submits it to POST /agent/identity/claim/complete; the agent then polls /oauth2/token for the key. There is no claim_delivery parameter, no approve link, and no hosted claim form.", + "operationId": "startRegistration", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartRegistrationBody" + } + } + } + }, + "responses": { + "200": { + "description": "Pending registration created; code emailed to the human", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartRegistrationResponse" + } + } + } + }, + "400": { + "description": "Bad body, bad login_hint, unsupported type, or a now-removed parameter (claim_delivery is rejected with invalid_request).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "503": { + "description": "email_send_failed — the code email could not be sent; the registration is voided. Retry registration.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + } + } + } + }, + "/agent/identity/claim": { + "post": { + "tags": [ + "auth" + ], + "summary": "Re-mint an expired code", + "description": "Invalidates the prior code and emails a fresh 6-digit code (the 24h registration window must still be open). A corrected email updates the registration's login_hint. The new code is NOT returned in the response — it goes to the human's inbox.", + "operationId": "remintClaim", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemintClaimBody" + } + } + } + }, + "responses": { + "200": { + "description": "Fresh code emailed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemintClaimResponse" + } + } + } + }, + "400": { + "description": "Bad body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "401": { + "description": "Unknown claim_token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "409": { + "description": "Already claimed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "410": { + "description": "Registration window closed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + } + } + } + }, + "/agent/identity/claim/complete": { + "post": { + "tags": [ + "auth" + ], + "summary": "Complete a claim by reading the emailed code back", + "description": "The human reads the 6-digit code from the emailed message back to the agent, which submits it here to confirm the claim WITHOUT a browser session (the binding proof is that the code only reached the human via their inbox). Constant-time compare; 5 wrong attempts kill the code (410 code_dead), then re-mint via POST /agent/identity/claim. On success the agent's /oauth2/token poll returns the key.", + "operationId": "completeClaim", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteClaimBody" + } + } + } + }, + "responses": { + "200": { + "description": "Claim confirmed; poll /oauth2/token for the key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteClaimResponse" + } + } + } + }, + "400": { + "description": "Bad body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "401": { + "description": "invalid_claim_token (unknown token) or invalid_user_code (wrong code; message names attempts remaining).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "409": { + "description": "claimed_or_in_flight (already claimed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "410": { + "description": "claim_expired (registration window closed), code_dead (5 wrong attempts), or expired_token (user_code window closed). Re-mint.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentError" + } + } + } + } + } + } + }, + "/oauth2/token": { + "post": { + "tags": [ + "auth" + ], + "summary": "Poll the claim grant for the API key", + "description": "RFC 8628-style polling. While the human has not finished, returns 400 authorization_pending (or slow_down if polled under 5s apart). On confirm, returns the long-lived API key exactly once.", + "operationId": "claimGrantToken", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/TokenForm" + } + } + } + }, + "responses": { + "200": { + "description": "Credential issued (once)", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + }, + "description": "no-store" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "400": { + "description": "OAuth error envelope. error one of: authorization_pending, slow_down, expired_token, invalid_grant, invalid_request, unsupported_grant_type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + } + } + } + }, + "/oauth2/revoke": { + "post": { + "tags": [ + "auth" + ], + "summary": "Revoke an API key (RFC 7009)", + "description": "Idempotent. Returns 200 with an empty body whether or not the token existed.", + "operationId": "revokeToken", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/RevokeForm" + } + } + } + }, + "responses": { + "200": { + "description": "Revoked (or no-op); empty body" + }, + "400": { + "description": "Malformed body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + } + } + } + }, + "/.well-known/oauth-protected-resource": { + "get": { + "tags": [ + "discovery" + ], + "summary": "RFC 9728 protected-resource metadata", + "operationId": "protectedResourceMetadata", + "security": [], + "responses": { + "200": { + "description": "Resource metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtectedResourceMetadata" + } + } + } + } + } + } + }, + "/.well-known/oauth-authorization-server": { + "get": { + "tags": [ + "discovery" + ], + "summary": "RFC 8414 authorization-server metadata (with agent_auth block)", + "operationId": "authServerMetadata", + "security": [], + "responses": { + "200": { + "description": "Authorization-server metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthServerMetadata" + } + } + } + } + } + } + } + }, + "webhooks": {} +} diff --git a/lib/skill-content.ts b/lib/skill-content.ts index 413119a..85e49b8 100644 --- a/lib/skill-content.ts +++ b/lib/skill-content.ts @@ -73,7 +73,8 @@ Scopes: docs.read docs.write (every key carries both). All requests: Authorization: Bearer jh_live_... Errors are JSON: { "error": "...", "message": "..." } with the documented -status. OpenAPI 3.1: https://justhtml.sh/api/spec.yaml +status. OpenAPI 3.1: https://justhtml.sh/openapi.json (JSON) or +https://justhtml.sh/api/spec.yaml (YAML) Create a doc -> POST /docs { html, title?, public? } curl -s https://justhtml.sh/api/v1/docs -H "Authorization: Bearer $JUSTHTML_API_KEY" \\ @@ -244,3 +245,10 @@ Auth-flow limits (per IP / per email) protect registration, code attempts (max 5 wrong attempts per code), and email sends (login links + claim codes: 5/h + 20/day per recipient, 30/h per IP). See /auth.md. `; + +export const SKILL_MARKDOWN = `--- +name: ${SKILL_NAME} +description: ${SKILL_DESCRIPTION} +--- + +${LLMS_BODY}`; diff --git a/scripts/gen-skill.ts b/scripts/gen-skill.ts index 147351f..c0b2cd1 100644 --- a/scripts/gen-skill.ts +++ b/scripts/gen-skill.ts @@ -10,20 +10,11 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { SKILL_NAME, SKILL_DESCRIPTION, LLMS_BODY } from "../lib/skill-content"; +import { SKILL_MARKDOWN } from "../lib/skill-content"; const here = dirname(fileURLToPath(import.meta.url)); const outPath = join(here, "..", "skills", "just-html", "SKILL.md"); -// YAML frontmatter: keep the description on one folded line (no colons/newlines -// to escape — it's prose). The body is the verbatim llms.txt content. -const frontmatter = `--- -name: ${SKILL_NAME} -description: ${SKILL_DESCRIPTION} ---- - -`; - mkdirSync(dirname(outPath), { recursive: true }); -writeFileSync(outPath, frontmatter + LLMS_BODY); -console.log(`wrote ${outPath} (${frontmatter.length + LLMS_BODY.length} bytes)`); +writeFileSync(outPath, SKILL_MARKDOWN); +console.log(`wrote ${outPath} (${SKILL_MARKDOWN.length} bytes)`); diff --git a/scripts/gen-spec.ts b/scripts/gen-spec.ts index 805db00..671e6b6 100644 --- a/scripts/gen-spec.ts +++ b/scripts/gen-spec.ts @@ -2,17 +2,18 @@ // OpenApiGeneratorV31 over the shared registry (populated by importing each // resource's schemas + paths modules) and writes the SERVED spec artifact. // -// Z5 (cutover): the generated spec IS now what GET /api/spec.yaml serves. The -// hand-written literal is gone. `npm run gen:spec` writes two committed -// artifacts from the SAME document: +// Z5 (cutover): the generated spec IS now what GET /api/spec.yaml and +// GET /openapi.json serve. The hand-written literal is gone. `npm run gen:spec` +// writes three committed artifacts from the SAME document: // - lib/openapi/generated-spec.ts — `export const SPEC_YAML = ...`, imported -// by the route (mirrors how gen-skill commits SKILL.md). This is the bytes -// served in production. +// by the YAML route (mirrors how gen-skill commits SKILL.md). // - lib/openapi/generated.yaml — the same YAML as a plain file, for // out-of-band validation (@redocly/cli) and human diffing. +// - lib/openapi/generated.json — the canonical JSON document served from +// /openapi.json for standards-based API discovery. // scripts/spec-check.ts re-runs this generator and asserts the committed -// artifacts match it byte-for-byte, so the served spec can never drift from the -// Zod schemas (the spec-sync GitHub Action regenerates + commits on change). +// artifacts match it byte-for-byte, so the served specs can never drift from +// the Zod schemas (the spec-sync GitHub Action regenerates + commits on change). import { writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -31,6 +32,7 @@ import "../lib/auth/paths"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); const YAML_OUT = join(ROOT, "lib/openapi/generated.yaml"); +const JSON_OUT = join(ROOT, "lib/openapi/generated.json"); const TS_OUT = join(ROOT, "lib/openapi/generated-spec.ts"); // Document-level metadata the generator does not derive from schemas/paths. These @@ -72,8 +74,13 @@ export function generateSpec(): Record { } /** The exact YAML bytes served by GET /api/spec.yaml and validated by redocly. */ -export function generateSpecYaml(): string { - return yaml.dump(generateSpec(), { lineWidth: 100, noRefs: true }); +export function generateSpecYaml(spec = generateSpec()): string { + return yaml.dump(spec, { lineWidth: 100, noRefs: true }); +} + +/** The canonical JSON bytes served by GET /openapi.json. */ +export function generateSpecJson(spec = generateSpec()): string { + return JSON.stringify(spec, null, 2) + "\n"; } /** The committed .ts artifact the route imports (mirrors gen-skill's SKILL.md). */ @@ -94,14 +101,15 @@ export function generatedSpecModule(specYaml: string): string { } function main() { - const specYaml = generateSpecYaml(); + const spec = generateSpec(); + const specYaml = generateSpecYaml(spec); + const specJson = generateSpecJson(spec); writeFileSync(YAML_OUT, specYaml); + writeFileSync(JSON_OUT, specJson); writeFileSync(TS_OUT, generatedSpecModule(specYaml)); - const paths = Object.keys( - (yaml.load(specYaml) as { paths?: Record }).paths ?? {} - ); + const paths = Object.keys(spec.paths as Record); console.log( - `wrote ${YAML_OUT} + ${TS_OUT} (${specYaml.length} bytes, ${paths.length} paths)` + `wrote ${YAML_OUT} + ${JSON_OUT} + ${TS_OUT} (${paths.length} paths)` ); } diff --git a/scripts/spec-check.ts b/scripts/spec-check.ts index 2eb6ac6..13510e5 100644 --- a/scripts/spec-check.ts +++ b/scripts/spec-check.ts @@ -4,9 +4,10 @@ // two jobs: // // A. DRIFT GUARD (the committed artifact is in sync). Re-run the generator and -// assert the committed artifacts the route + tooling read — -// lib/openapi/generated-spec.ts (served by GET /api/spec.yaml) and -// lib/openapi/generated.yaml (validated by @redocly/cli) — match it +// assert the committed artifacts the routes + tooling read — +// lib/openapi/generated-spec.ts (served by GET /api/spec.yaml), +// lib/openapi/generated.yaml (validated by @redocly/cli), and +// lib/openapi/generated.json (served by GET /openapi.json) — match it // byte-for-byte. This is the same drift guard gen-skill's SKILL.md uses: if // the schemas changed but `npm run gen:spec` wasn't re-run, this fails. The // spec-sync GitHub Action regenerates + commits so it can't stay drifted. @@ -26,11 +27,17 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import yaml from "js-yaml"; -import { generateSpecYaml, generatedSpecModule } from "./gen-spec"; +import { + generateSpec, + generateSpecJson, + generateSpecYaml, + generatedSpecModule, +} from "./gen-spec"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); const APP_DIR = join(ROOT, "app"); const YAML_ARTIFACT = join(ROOT, "lib/openapi/generated.yaml"); +const JSON_ARTIFACT = join(ROOT, "lib/openapi/generated.json"); const TS_ARTIFACT = join(ROOT, "lib/openapi/generated-spec.ts"); const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; @@ -55,11 +62,11 @@ function fmtSet(s: Iterable): string { // --- A. drift guard ------------------------------------------------------ // // Re-generate from the registry and compare to the committed artifacts. The -// served bytes (generated-spec.ts) and the redocly-validated file (generated.yaml) -// must both equal a fresh generation, so the served spec can never drift from the +// served YAML module, redocly-validated YAML file, and served JSON file must +// equal a fresh generation, so neither spec representation can drift from the // Zod schemas without spec:check (and CI) catching it. -function checkArtifactsInSync(freshYaml: string): string[] { +function checkArtifactsInSync(freshYaml: string, freshJson: string): string[] { const problems: string[] = []; let committedYaml: string; try { @@ -75,6 +82,21 @@ function checkArtifactsInSync(freshYaml: string): string[] { "Run `npm run gen:spec` and commit the result." ); } + let committedJson: string; + try { + committedJson = readFileSync(JSON_ARTIFACT, "utf8"); + } catch { + return [ + ...problems, + `${relative(ROOT, JSON_ARTIFACT)} is missing — run \`npm run gen:spec\`.`, + ]; + } + if (committedJson !== freshJson) { + problems.push( + `${relative(ROOT, JSON_ARTIFACT)} is stale (committed bytes != fresh generation). ` + + "Run `npm run gen:spec` and commit the result." + ); + } let committedTs: string; try { committedTs = readFileSync(TS_ARTIFACT, "utf8"); @@ -206,14 +228,17 @@ function main() { // A. The committed artifacts must match a fresh generation from the registry. let freshYaml: string; + let freshJson: string; try { - freshYaml = generateSpecYaml(); + const freshSpec = generateSpec(); + freshYaml = generateSpecYaml(freshSpec); + freshJson = generateSpecJson(freshSpec); } catch (e) { console.error("spec:check FAILED — could not generate the spec from the registry:\n"); console.error((e as Error).stack ?? (e as Error).message); process.exit(1); } - problems.push(...checkArtifactsInSync(freshYaml)); + problems.push(...checkArtifactsInSync(freshYaml, freshJson)); // B. Cross-surface coverage. Drive this off the FRESH spec (the source of // truth); if A passed, the committed/served bytes are identical to it anyway. diff --git a/skills/just-html/SKILL.md b/skills/just-html/SKILL.md index c33986b..9f8ade2 100644 --- a/skills/just-html/SKILL.md +++ b/skills/just-html/SKILL.md @@ -65,7 +65,8 @@ Scopes: docs.read docs.write (every key carries both). All requests: Authorization: Bearer jh_live_... Errors are JSON: { "error": "...", "message": "..." } with the documented -status. OpenAPI 3.1: https://justhtml.sh/api/spec.yaml +status. OpenAPI 3.1: https://justhtml.sh/openapi.json (JSON) or +https://justhtml.sh/api/spec.yaml (YAML) Create a doc -> POST /docs { html, title?, public? } curl -s https://justhtml.sh/api/v1/docs -H "Authorization: Bearer $JUSTHTML_API_KEY" \