Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/spec-sync.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down
24 changes: 15 additions & 9 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ curl -s https://justhtml.sh/api/v1/docs \
# Share the private link: <url>?viewtoken=<view_token>
```

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

Expand Down
33 changes: 33 additions & 0 deletions app/.well-known/agent-skills/index.json/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
12 changes: 12 additions & 0 deletions app/.well-known/agent-skills/just-html/SKILL.md/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
60 changes: 60 additions & 0 deletions app/.well-known/integrations.json/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
14 changes: 14 additions & 0 deletions app/openapi.json/route.ts
Original file line number Diff line number Diff line change
@@ -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, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OpenAPI route bytes mismatch

Medium Severity

The GET /openapi.json endpoint re-serializes the imported generated.json using JSON.stringify(). This produces a compact response that differs byte-for-byte from the pretty-printed, committed lib/openapi/generated.json file. This means the served content doesn't match the canonical artifact validated by spec:check, unlike /api/spec.yaml.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 805e270. Configure here.

headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}
4 changes: 2 additions & 2 deletions app/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,7 +105,7 @@ framework — the document you publish is the document people see.</pre></div>
<h2>SEE ALSO</h2>
<div class="body"><pre><a href="/auth.md">/auth.md</a> how agents sign up + authenticate
<a href="/llms.txt">/llms.txt</a> agent-facing usage
<a href="/api/spec.yaml">/api/spec.yaml</a> OpenAPI spec
<a href="/openapi.json">/openapi.json</a> OpenAPI spec
<a href="/docs">/docs</a> your documents
<a href="https://github.com/kernel/just-html">github</a> source</pre></div>

Expand Down
153 changes: 153 additions & 0 deletions lib/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
};
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}`,
}),
]);
});
});
Loading
Loading