diff --git a/packages/mcp-core/src/mcp-guards/rest-dry-run-parity.spec.ts b/packages/mcp-core/src/mcp-guards/rest-dry-run-parity.spec.ts new file mode 100644 index 000000000..73f854b81 --- /dev/null +++ b/packages/mcp-core/src/mcp-guards/rest-dry-run-parity.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { generateSpec } from '@lorekit/schemas/openapi/spec'; + +/** + * The OpenAPI spec advertises `X-LoreKit-Dry-Run` on the operations that change + * data; the edge routers decide that with each route's `requires`. A write route + * without the header hides the rehearsal from `/api-docs` and `/api-docs.md`; a + * read route with it promises a rehearsal its handler never performs. The Deno + * routers and the Node spec cannot import each other, so they are held together + * here, the way `tool-catalog-parity.spec.ts` holds the catalog to the gate. + */ + +const repoRoot = join(import.meta.dirname, '../../../..'); + +const ROUTERS = [ + { fn: 'memories', prefix: '/memories' }, + { fn: 'orgs', prefix: '/orgs' }, +] as const; + +const ROUTE_ENTRY = /\{\s*method:\s*'(GET|POST|PUT|PATCH|DELETE)',\s*path:\s*'([^']+)',[^}]*?requires:\s*'(read|write)'/g; + +interface RouterRoute { + readonly method: string; + readonly path: string; + readonly requires: 'read' | 'write'; +} + +/** Every `{ method, path, requires }` entry of the routers, in the spec's path spelling. */ +function routerRoutes(): RouterRoute[] { + return ROUTERS.flatMap(({ fn, prefix }) => { + const source = readFileSync(join(repoRoot, 'supabase/functions', fn, 'index.ts'), 'utf8'); + return [...source.matchAll(ROUTE_ENTRY)].map(([, method, path, requires]) => ({ + method: (method as string).toLowerCase(), + path: prefix + (path === '/' ? '' : (path as string).replace(/:(\w+)/g, '{$1}')), + requires: requires as 'read' | 'write', + })); + }); +} + +type SpecOperation = { parameters?: Array<{ name: string }> }; +const spec = generateSpec('https://example.test/functions/v1') as { + paths: Record>; +}; + +/** The Bearer-authed operations the spec documents — the public `/blog` surface takes no dry-run. */ +const documented = Object.entries(spec.paths) + .filter(([path]) => !path.startsWith('/blog')) + .flatMap(([path, operations]) => + Object.entries(operations).map(([method, operation]) => ({ method, path, operation })), + ); + +const routes = routerRoutes(); + +describe('REST dry-run header ↔ router permission', () => { + it('reads both route tables', () => { + expect(routes.length).toBeGreaterThan(20); + }); + + it.each(documented.map((op) => [op.method.toUpperCase(), op.path, op] as const))( + '%s %s is served by a router route', + (_, __, { method, path }) => { + expect(routes.some((r) => r.method === method && r.path === path)).toBe(true); + }, + ); + + it.each(documented.map((op) => [op.method.toUpperCase(), op.path, op] as const))( + '%s %s advertises X-LoreKit-Dry-Run exactly when it requires write', + (_, __, { method, path, operation }) => { + const route = routes.find((r) => r.method === method && r.path === path); + const advertised = (operation.parameters ?? []).some((p) => p.name === 'X-LoreKit-Dry-Run'); + expect(advertised).toBe(route?.requires === 'write'); + }, + ); +}); diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 3ea29187c..88ec6416b 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -21,6 +21,7 @@ "./usage": "./src/domain/usage.ts", "./relevant": "./src/shared/relevant.ts", "./openapi/spec": "./src/openapi/spec.ts", + "./openapi/markdown": "./src/openapi/markdown.ts", "./tool-catalog": "./src/shared/tool-catalog.ts", "./llms/render": "./src/llms/render.ts" }, diff --git a/packages/schemas/src/llms/template.md b/packages/schemas/src/llms/template.md index de35d81d1..34d93a48a 100644 --- a/packages/schemas/src/llms/template.md +++ b/packages/schemas/src/llms/template.md @@ -98,6 +98,21 @@ https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp Auth: `Authorization: Bearer ` — token prefix encodes permissions (see Tokens below). +## REST API + +Memories, organizations, members and invites are also plain HTTPS + JSON under +`https://pqokxlhvnosogizsjztg.supabase.co/functions/v1`, with the same Bearer token. + +- **Reference as Markdown (read this one):** https://lorekit.io/api-docs.md — every route, + parameter, body and response, with a `curl` example each. `https://lorekit.io/api-docs` + returns the same file to a request sent with `Accept: text/markdown`. +- OpenAPI 3.1 JSON: https://lorekit.io/api-docs/spec +- Interactive console (browser): https://lorekit.io/api-docs + +A REST write is REAL unless it carries `X-LoreKit-Dry-Run: true`, which validates and authorizes +the request without changing anything. The browser console pre-fills that header; curl and SDKs +do not. + ## Tokens ``` @@ -442,6 +457,7 @@ id: a `401` makes streamable-HTTP clients retry a session handshake and hang. ## Key documentation +- REST API reference (Markdown): https://lorekit.io/api-docs.md - MCP tools reference: https://github.com/mthines/lorekit/blob/main/docs/mcp-tools.md - Scope format: https://github.com/mthines/lorekit/blob/main/docs/scope-format.md - API tokens: https://github.com/mthines/lorekit/blob/main/docs/api-tokens.md diff --git a/packages/schemas/src/openapi/markdown.spec.ts b/packages/schemas/src/openapi/markdown.spec.ts new file mode 100644 index 000000000..b464fba6e --- /dev/null +++ b/packages/schemas/src/openapi/markdown.spec.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { generateSpec } from './spec.ts'; +import { headingAnchor, renderOpenApiMarkdown, type OpenApiDocument } from './markdown.ts'; + +const LINKS = { + htmlUrl: 'https://lorekit.test/api-docs', + specUrl: 'https://lorekit.test/api-docs/spec', + llmsTxtUrl: 'https://lorekit.test/llms.txt', +}; + +const BASE_URL = 'https://example.test/functions/v1'; + +function section(markdown: string, heading: string): string { + const start = markdown.indexOf(`\n### ${heading}\n`); + if (start === -1) throw new Error(`no section ${heading}`); + const next = markdown.indexOf('\n### ', start + 1); + return markdown.slice(start, next === -1 ? undefined : next); +} + +// Runs the REAL document, so a route registered in spec.ts without being +// renderable here fails the build rather than vanishing from /api-docs.md. +describe('renderOpenApiMarkdown — the generated REST spec', () => { + const spec = generateSpec(BASE_URL) as unknown as OpenApiDocument; + const markdown = renderOpenApiMarkdown(spec, LINKS); + + it('documents every operation in the spec under its own heading', () => { + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of Object.keys(item)) { + expect(markdown, `${method.toUpperCase()} ${path}`).toContain(`\n### ${method.toUpperCase()} ${path}\n`); + } + } + }); + + it('has no dangling in-page links', () => { + const anchors = new Set( + [...markdown.matchAll(/^#{1,6} (.+)$/gm)].map((m) => headingAnchor(m[1] as string)), + ); + const targets = [...markdown.matchAll(/\]\(#([^)]+)\)/g)].map((m) => m[1] as string); + expect(targets.length).toBeGreaterThan(0); + for (const target of targets) expect(anchors, `#${target}`).toContain(target); + }); + + it('points at the base URL and the sibling formats', () => { + expect(markdown).toContain(`\`${BASE_URL}\``); + for (const url of Object.values(LINKS)) expect(markdown).toContain(url); + }); + + // The trap this guards: the spec's `default: true` is the console's pre-fill. + // The server executes a write that omits the header, so an agent must never + // read "default true" here. + it('states the dry-run header defaults to a REAL write', () => { + const row = markdown.split('\n').find((line) => line.startsWith('| `X-LoreKit-Dry-Run`')); + expect(row).toBeDefined(); + expect(row).toContain('Default `false`'); + expect(row).not.toContain('Default `true`'); + expect(markdown).toContain('Writes are real by default'); + }); + + it('writes shared headers once instead of in every operation table', () => { + const rows = markdown.split('\n').filter((line) => line.startsWith('| `X-LoreKit-Client`')); + expect(rows).toHaveLength(1); + expect(section(markdown, 'POST /memories')).toContain('`X-LoreKit-Dry-Run`'); + expect(section(markdown, 'GET /memories')).not.toContain('`X-LoreKit-Dry-Run`'); + // A POST that only reads ignores the header, so it must not be offered one. + expect(section(markdown, 'POST /memories/search')).not.toContain('`X-LoreKit-Dry-Run`'); + }); + + it('links repeated shapes to one Schemas entry rather than inlining them', () => { + expect(markdown.match(/^### Error$/gm)).toHaveLength(1); + expect(markdown.match(/^### Memory$/gm)).toHaveLength(1); + expect(section(markdown, 'GET /memories')).toContain('[MemoryPage](#memorypage)'); + // Smaller than even the minified JSON, despite adding prose and examples. + expect(markdown.length).toBeLessThan(JSON.stringify(spec).length); + }); + + it('sends a bearer token in every example except the public routes', () => { + expect(section(markdown, 'POST /memories')).toContain('-H "Authorization: Bearer $LOREKIT_TOKEN"'); + expect(section(markdown, 'GET /blog/likes')).not.toContain('Authorization'); + expect(section(markdown, 'GET /blog/likes')).toContain('None — public endpoint.'); + }); + + it('builds a minimal body from the required fields', () => { + expect(section(markdown, 'POST /memories')).toContain(`-d '{"scope":"","key":"","value":""}'`); + }); + + // curl globs `{id}` into `id`, so an unedited copy would hit `/memories/id` + // instead of failing loudly; only the curl URL changes, headings keep `{id}`. + it('writes path parameters in curl URLs as placeholders, never curl globs', () => { + const curlLines = markdown.split('\n').filter((line) => line.startsWith('curl')); + expect(curlLines.length).toBeGreaterThan(0); + for (const line of curlLines) expect(line, line).not.toContain('{'); + expect(section(markdown, 'GET /memories/{id}')).toContain(`curl "${BASE_URL}/memories/`); + }); + + it('is deterministic', () => { + expect(renderOpenApiMarkdown(spec, LINKS)).toBe(markdown); + }); +}); + +describe('renderOpenApiMarkdown — rendering rules', () => { + const doc: OpenApiDocument = { + info: { title: 'Tiny API', version: '2.0.0', description: 'A tiny API.\n\nMore text.' }, + servers: [{ url: 'https://tiny.test' }], + components: { + schemas: { + Thing: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + paths: { + '/things/{id}': { + put: { + summary: 'Replace a thing', + tags: ['Things'], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + { name: 'mode', in: 'query', required: true, schema: { type: 'string', enum: ['fast', 'safe'] } }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['note', 'meta'], + properties: { + note: { type: 'string', example: "it's here" }, + count: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'null' }] }, + meta: { + type: 'object', + required: ['owner'], + properties: { owner: { type: 'string', maxLength: 64 } }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Replaced', + content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } }, + }, + '204': { description: 'Nothing to do' }, + }, + }, + }, + }, + }; + const markdown = renderOpenApiMarkdown(doc, LINKS); + + it('labels a nullable union and keeps the bound of its non-null branch', () => { + expect(markdown).toContain('| `count` | no | integer or null | ≥ 0. |'); + }); + + it('flattens a nested object into dotted rows', () => { + expect(markdown).toContain('| `meta.owner` | yes | string | Length ≤ 64. |'); + }); + + it('links an inline copy of a named component', () => { + expect(markdown).toContain('`200` — Replaced → [Thing](#thing)'); + expect(markdown).toContain('### Thing'); + }); + + it('describes a bodiless response', () => { + expect(markdown).toContain('`204` — Nothing to do. No body.'); + }); + + it('puts required query parameters in the example URL and shell-quotes the body', () => { + expect(markdown).toContain('curl -X PUT "https://tiny.test/things/?mode="'); + expect(markdown).toContain('### PUT /things/{id}'); + expect(markdown).toContain(`-d '{"note":"it'\\''s here","meta":{"owner":""}}'`); + }); + + it('uses the first line of the API description as the summary', () => { + expect(markdown).toContain('> A tiny API. This is the Markdown edition'); + expect(markdown).not.toContain('More text.'); + }); +}); + +describe('headingAnchor', () => { + it.each([ + ['GET /memories/{id}', 'get-memoriesid'], + ['POST /memories/purge-expired', 'post-memoriespurge-expired'], + ['Common headers', 'common-headers'], + ['MemoryPage', 'memorypage'], + ])('%s → #%s', (heading, anchor) => { + expect(headingAnchor(heading)).toBe(anchor); + }); +}); diff --git a/packages/schemas/src/openapi/markdown.ts b/packages/schemas/src/openapi/markdown.ts new file mode 100644 index 000000000..f81e47895 --- /dev/null +++ b/packages/schemas/src/openapi/markdown.ts @@ -0,0 +1,647 @@ +/** + * Render the REST API's OpenAPI document as ONE Markdown file for AI agents. + * + * `/api-docs` is a Scalar page: an empty `
` that a CDN script + * draws in the browser. An agent that fetches it — `curl`, a web-fetch tool, + * an MCP client reading docs into context — gets a title and nothing else, and + * the raw OpenAPI JSON beside it is ~170 KB minified because zod-to-openapi + * inlines every schema (the error body alone is repeated over a hundred times). This + * renderer is the readable middle: every operation with its parameters, body, + * responses and a copy-paste `curl`, in one pass, with shared shapes written + * once and linked. + * + * It derives everything from the same document Scalar renders, so the two + * cannot disagree. The only editorial text is the short "Before you call" + * preamble, which states facts the document holds but buries — and it reads + * those facts (the auth scheme, the shared headers) out of the document too. + * + * Pure and dependency-free: the web app owns the I/O (`/api-docs.md`), and this + * file is unit tested against the real `generateSpec()` output. + */ + +/** The subset of JSON Schema (OpenAPI 3.1 dialect) the generated document uses. */ +export interface JsonSchema { + readonly $ref?: string; + readonly type?: string | readonly string[]; + readonly enum?: readonly unknown[]; + readonly const?: unknown; + readonly format?: string; + readonly default?: unknown; + readonly example?: unknown; + readonly description?: string; + readonly items?: JsonSchema; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly additionalProperties?: boolean | JsonSchema; + readonly anyOf?: readonly JsonSchema[]; + readonly oneOf?: readonly JsonSchema[]; + readonly allOf?: readonly JsonSchema[]; + readonly minimum?: number; + readonly maximum?: number; + readonly exclusiveMinimum?: number; + readonly exclusiveMaximum?: number; + readonly minLength?: number; + readonly maxLength?: number; + readonly minItems?: number; + readonly maxItems?: number; + readonly pattern?: string; +} + +export interface OpenApiParameter { + readonly name: string; + readonly in: 'path' | 'query' | 'header' | 'cookie' | string; + readonly required?: boolean; + readonly description?: string; + readonly schema?: JsonSchema; + /** What the server does when the parameter is absent, where that differs from the console pre-fill in `schema.default` (see `X-LoreKit-Dry-Run` in `spec.ts`). */ + readonly 'x-server-default'?: unknown; +} + +interface MediaType { + readonly schema?: JsonSchema; +} + +export interface OpenApiOperation { + readonly summary?: string; + readonly description?: string; + readonly tags?: readonly string[]; + readonly security?: ReadonlyArray>>; + readonly parameters?: readonly OpenApiParameter[]; + readonly requestBody?: { readonly content?: Readonly> }; + readonly responses?: Readonly< + Record> }> + >; +} + +export interface OpenApiDocument { + readonly info: { readonly title: string; readonly version?: string; readonly description?: string }; + readonly servers?: ReadonlyArray<{ readonly url: string }>; + readonly paths: Readonly>>>; + readonly components?: { + readonly schemas?: Readonly>; + readonly securitySchemes?: Readonly>; + }; +} + +/** Absolute URLs of the sibling formats, so the file can point at them. */ +export interface MarkdownLinks { + /** The interactive (browser) reference, e.g. `https://lorekit.io/api-docs`. */ + readonly htmlUrl: string; + /** The OpenAPI JSON, e.g. `https://lorekit.io/api-docs/spec`. */ + readonly specUrl: string; + /** The product-wide agent guide, e.g. `https://lorekit.io/llms.txt`. */ + readonly llmsTxtUrl: string; +} + +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const; +const JSON_MEDIA = 'application/json'; + +/** Deepest nesting a field table flattens into dotted rows before it says `object`. */ +const MAX_FLATTEN_DEPTH = 4; + +// ── Small text helpers ────────────────────────────────────────────────────── + +/** One table cell: no line breaks, and a literal `|` cannot end the cell. */ +function cell(text: string): string { + return text.replace(/\s*\n+\s*/g, ' ').replace(/\|/g, '\\|').trim(); +} + +function code(value: unknown): string { + return `\`${typeof value === 'string' ? value : JSON.stringify(value)}\``; +} + +/** GitHub-style heading anchor (lowercase, punctuation dropped, spaces to `-`), so index links resolve wherever the file is rendered. */ +export function headingAnchor(heading: string): string { + return heading + .toLowerCase() + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .trim() + .replace(/\s/g, '-'); +} + +/** Key-order-independent JSON, so two inlined copies of one shape compare equal. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function operationHeading(method: string, path: string): string { + return `${method.toUpperCase()} ${path}`; +} + +// ── Schema description ────────────────────────────────────────────────────── + +/** + * Per-render state: which named component each inlined shape is, and which of + * them the output has linked to (only those get a "Schemas" entry). + */ +class SchemaContext { + private readonly nameByShape = new Map(); + readonly linked = new Set(); + + constructor(readonly components: Readonly>) { + for (const [name, schema] of Object.entries(components)) { + const shape = canonicalJson(schema); + // First registration wins, so a later alias never renames a shape. + if (!this.nameByShape.has(shape)) this.nameByShape.set(shape, name); + } + } + + /** The component a schema IS — by `$ref`, or by being an identical inline copy. */ + componentName(schema: JsonSchema): string | undefined { + if (schema.$ref) return refName(schema.$ref); + // Only objects are worth naming: a bare `string` matching some + // single-field component would be a confusing link, not a helpful one. + if (!schema.properties) return undefined; + return this.nameByShape.get(canonicalJson(schema)); + } + + link(name: string): string { + this.linked.add(name); + return `[${name}](#${headingAnchor(name)})`; + } + + resolve(schema: JsonSchema): JsonSchema { + if (!schema.$ref) return schema; + return this.components[refName(schema.$ref)] ?? {}; + } +} + +/** `#/components/schemas/Memory` → `Memory`. */ +function refName(ref: string): string { + return ref.split('/').pop() ?? ''; +} + +function isNullSchema(schema: JsonSchema): boolean { + return schema.type === 'null'; +} + +/** The variants of a union, nulls removed, plus whether `null` was one of them. */ +function unionVariants(schema: JsonSchema): { variants: readonly JsonSchema[]; nullable: boolean } | null { + const union = schema.anyOf ?? schema.oneOf; + if (!union) return null; + return { variants: union.filter((v) => !isNullSchema(v)), nullable: union.some(isNullSchema) }; +} + +/** `T | null` read as `T`; any other schema unchanged. */ +function nonNullVariant(schema: JsonSchema): JsonSchema { + const union = unionVariants(schema); + return union && union.variants.length === 1 ? (union.variants[0] as JsonSchema) : schema; +} + +/** An empty-array default (`tags: []`) says nothing worth printing. */ +function hasMeaningfulDefault(schema: JsonSchema): boolean { + return schema.default !== undefined && !(Array.isArray(schema.default) && schema.default.length === 0); +} + +/** A short type label: `string`, `integer or null`, `array of string`, a component link. */ +function typeLabel(schema: JsonSchema, ctx: SchemaContext): string { + const component = ctx.componentName(schema); + if (component) return ctx.link(component); + + const union = unionVariants(schema); + if (union) { + const label = union.variants.map((v) => typeLabel(v, ctx)).join(' or '); + return union.nullable ? `${label} or null` : label; + } + + if (schema.allOf?.length === 1) return typeLabel(schema.allOf[0] as JsonSchema, ctx); + + const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []; + const nonNull = types.filter((t) => t !== 'null'); + const nullable = nonNull.length < types.length; + let base = nonNull.join(' or ') || (schema.properties ? 'object' : schema.enum ? 'string' : 'any'); + + if (base === 'array' && schema.items) base = `array of ${typeLabel(schema.items, ctx)}`; + if (base === 'object' && !schema.properties && typeof schema.additionalProperties === 'object') { + const valueType = typeLabel(schema.additionalProperties, ctx); + base = valueType === 'any' ? 'object' : `object (map of ${valueType})`; + } + return nullable ? `${base} or null` : base; +} + +/** `Range 1–100.`, `≥ 0.`, `Length ≤ 512.` — `label` is omitted for an open-ended number range. */ +function rangeNote(min: number | undefined, max: number | undefined, label: string): string | null { + const prefix = label ? `${label} ` : ''; + if (min !== undefined && max !== undefined) return `${label || 'Range'} ${min}–${max}.`; + if (min !== undefined) return `${prefix}≥ ${min}.`; + if (max !== undefined) return `${prefix}≤ ${max}.`; + return null; +} + +/** Constraint sentences for one schema: allowed values, bounds, format, default. */ +function constraintNotes(schema: JsonSchema, serverDefault: { readonly value: unknown } | null): string[] { + const notes: string[] = []; + const scalar = nonNullVariant(schema); + + if (scalar.enum) notes.push(`One of ${scalar.enum.map(code).join(', ')}.`); + if (scalar.const !== undefined) notes.push(`Always ${code(scalar.const)}.`); + if (scalar.format) notes.push(`Format: ${scalar.format}.`); + + const numberRange = rangeNote(scalar.minimum, scalar.maximum, ''); + if (numberRange) notes.push(numberRange); + if (scalar.exclusiveMinimum !== undefined) notes.push(`> ${scalar.exclusiveMinimum}.`); + if (scalar.exclusiveMaximum !== undefined) notes.push(`< ${scalar.exclusiveMaximum}.`); + + // `minLength: 1` alone is zod's `.min(1)` — "must not be empty" says it plainly. + const nonEmptyOnly = scalar.minLength === 1 && scalar.maxLength === undefined; + const lengthRange = nonEmptyOnly ? 'Non-empty.' : rangeNote(scalar.minLength, scalar.maxLength, 'Length'); + if (lengthRange) notes.push(lengthRange); + if (scalar.pattern) notes.push(`Pattern: ${code(scalar.pattern)}.`); + + if (scalar.minItems !== undefined || scalar.maxItems !== undefined) { + const items = rangeNote(scalar.minItems, scalar.maxItems, 'Items'); + if (items) notes.push(items); + } + if (scalar.items) { + const itemNotes = constraintNotes(scalar.items, null).filter((n) => !n.startsWith('Default')); + if (itemNotes.length) notes.push(`Each item: ${itemNotes.join(' ')}`); + } + + if (serverDefault) { + notes.push(`Default ${code(serverDefault.value)}.`); + } else if (hasMeaningfulDefault(schema)) { + notes.push(`Default ${code(schema.default)}.`); + } + return notes; +} + +function notesCell(description: string | undefined, notes: readonly string[]): string { + const prose = description?.trim() ?? ''; + // A description that already says "One of `a`, `b`" needs no second copy. + const fresh = notes.filter((note) => !prose.includes(note.replace(/\.$/, ''))); + return cell([prose, ...fresh].filter(Boolean).join(' ')) || '—'; +} + +interface FieldRow { + readonly name: string; + readonly required: boolean; + readonly type: string; + readonly notes: string; +} + +/** Flatten an object schema into table rows: dotted rows for nested shapes (`org.slug`, `entries[].key`), a link for a named component. */ +function fieldRows(schema: JsonSchema, ctx: SchemaContext, prefix = '', depth = 0): FieldRow[] { + const resolved = ctx.resolve(schema); + const required = new Set(resolved.required ?? []); + const rows: FieldRow[] = []; + + for (const [name, raw] of Object.entries(resolved.properties ?? {})) { + const field = ctx.resolve(raw); + const path = `${prefix}${name}`; + rows.push({ + name: path, + required: required.has(name), + type: typeLabel(raw, ctx), + notes: notesCell(field.description, constraintNotes(field, null)), + }); + + if (depth + 1 >= MAX_FLATTEN_DEPTH || ctx.componentName(raw)) continue; + const nested = nestedObject(field); + if (nested && !ctx.componentName(nested.schema)) { + rows.push(...fieldRows(nested.schema, ctx, `${path}${nested.suffix}.`, depth + 1)); + } + } + return rows; +} + +/** The object a field contains, directly or as the element of an array. */ +function nestedObject(field: JsonSchema): { schema: JsonSchema; suffix: string } | null { + const single = nonNullVariant(field); + if (single.properties) return { schema: single, suffix: '' }; + if (single.items?.properties) return { schema: single.items, suffix: '[]' }; + return null; +} + +function fieldTable(rows: readonly FieldRow[]): string { + if (rows.length === 0) return '_No fields._'; + return [ + '| Field | Required | Type | Notes |', + '|---|---|---|---|', + ...rows.map((r) => `| \`${r.name}\` | ${r.required ? 'yes' : 'no'} | ${cell(r.type)} | ${r.notes} |`), + ].join('\n'); +} + +// ── Examples ──────────────────────────────────────────────────────────────── + +/** A minimal example value — required fields only, real enum/default values, `` placeholders otherwise — so a copied call sends only what it needs. */ +function exampleValue(schema: JsonSchema, ctx: SchemaContext, name: string, depth = 0): unknown { + const resolved = ctx.resolve(schema); + if (resolved.example !== undefined) return resolved.example; + if (resolved.const !== undefined) return resolved.const; + if (resolved.enum?.length) return resolved.enum[0]; + if (hasMeaningfulDefault(resolved)) return resolved.default; + + const union = unionVariants(resolved); + if (union?.variants[0]) return exampleValue(union.variants[0], ctx, name, depth); + + const type = Array.isArray(resolved.type) ? resolved.type.find((t) => t !== 'null') : resolved.type; + if (resolved.properties || type === 'object') { + if (depth >= MAX_FLATTEN_DEPTH) return {}; + const out: Record = {}; + for (const key of resolved.required ?? []) { + const property = resolved.properties?.[key]; + if (property) out[key] = exampleValue(property, ctx, key, depth + 1); + } + return out; + } + switch (type) { + case 'array': + return resolved.items ? [exampleValue(resolved.items, ctx, name, depth + 1)] : []; + case 'integer': + case 'number': + return resolved.minimum ?? (resolved.exclusiveMinimum !== undefined ? resolved.exclusiveMinimum + 1 : 1); + case 'boolean': + return true; + default: + return `<${name}>`; + } +} + +/** Single-quote a string for a POSIX shell. */ +function shellQuote(text: string): string { + return `'${text.replace(/'/g, `'\\''`)}'`; +} + +function curlExample(method: string, path: string, op: OpenApiOperation, baseUrl: string, ctx: SchemaContext): string { + const query = (op.parameters ?? []) + .filter((p) => p.in === 'query' && p.required) + .map((p) => `${encodeURIComponent(p.name)}=<${p.name}>`); + // curl reads `{id}` as a URL glob and silently requests `/id`; `` stays a visible placeholder. + const pathWithPlaceholders = path.replace(/\{(\w+)\}/g, '<$1>'); + const url = `${baseUrl}${pathWithPlaceholders}${query.length ? `?${query.join('&')}` : ''}`; + + const lines = [`curl${method === 'get' ? '' : ` -X ${method.toUpperCase()}`} "${url}"`]; + if (!isPublic(op)) lines.push(` -H "Authorization: Bearer $LOREKIT_TOKEN"`); + + const body = op.requestBody?.content?.[JSON_MEDIA]?.schema; + if (body) { + lines.push(` -H "Content-Type: application/json"`); + lines.push(` -d ${shellQuote(JSON.stringify(exampleValue(body, ctx, 'body')))}`); + } + return ['```bash', lines.join(' \\\n'), '```'].join('\n'); +} + +// ── Operations ────────────────────────────────────────────────────────────── + +/** An explicit empty `security` array is how OpenAPI marks an open endpoint. */ +function isPublic(op: OpenApiOperation): boolean { + return Array.isArray(op.security) && op.security.length === 0; +} + +interface OperationRef { + readonly method: string; + readonly path: string; + readonly op: OpenApiOperation; +} + +function listOperations(doc: OpenApiDocument): OperationRef[] { + const out: OperationRef[] = []; + for (const [path, item] of Object.entries(doc.paths)) { + for (const method of HTTP_METHODS) { + const op = item[method]; + if (op) out.push({ method, path, op }); + } + } + return out; +} + +const UNTAGGED = 'Other'; + +function primaryTag(op: OpenApiOperation): string { + return op.tags?.[0] ?? UNTAGGED; +} + +/** Tags in order of first appearance, so the file follows the spec's own grouping. */ +function tagOrder(ops: readonly OperationRef[]): string[] { + const tags: string[] = []; + for (const { op } of ops) { + const tag = primaryTag(op); + if (!tags.includes(tag)) tags.push(tag); + } + return tags; +} + +/** Header parameters attached identically to more than one operation, documented once under "Common headers" instead of in every table. */ +function commonHeaders(ops: readonly OperationRef[]): Map { + const byShape = new Map(); + for (const ref of ops) { + for (const param of ref.op.parameters ?? []) { + if (param.in !== 'header') continue; + const shape = canonicalJson(param); + const entry = byShape.get(shape) ?? { param, ops: [] }; + entry.ops.push(ref); + byShape.set(shape, entry); + } + } + const common = new Map(); + for (const entry of byShape.values()) { + if (entry.ops.length > 1) common.set(entry.param.name, entry); + } + return common; +} + +function serverDefaultOf(param: OpenApiParameter): { readonly value: unknown } | null { + return 'x-server-default' in param ? { value: param['x-server-default'] } : null; +} + +function parameterTable(params: readonly OpenApiParameter[], ctx: SchemaContext): string { + return [ + '| Name | In | Required | Type | Notes |', + '|---|---|---|---|---|', + ...params.map((p) => { + const schema = p.schema ?? {}; + const notes = notesCell(p.description ?? schema.description, constraintNotes(schema, serverDefaultOf(p))); + return `| \`${p.name}\` | ${p.in} | ${p.required ? 'yes' : 'no'} | ${cell(typeLabel(schema, ctx))} | ${notes} |`; + }), + ].join('\n'); +} + +/** Indent a block so it renders inside the preceding list item. */ +function indent(block: string): string { + return block.replace(/^/gm, ' '); +} + +function responseLines(op: OpenApiOperation, ctx: SchemaContext): string[] { + const groups = new Map(); + + for (const [status, response] of Object.entries(op.responses ?? {}).sort(([a], [b]) => a.localeCompare(b))) { + const schema = response.content?.[JSON_MEDIA]?.schema; + const description = response.description?.trim() ?? ''; + const component = schema ? ctx.componentName(schema) : undefined; + let summary: string; + let table: string | null = null; + + if (!schema) { + summary = `${description}. No body.`.replace(/^\. /, ''); + } else if (component) { + summary = `${description} → ${ctx.link(component)}`.trim(); + } else { + summary = `${description} → ${typeLabel(schema, ctx)}`.trim(); + if (ctx.resolve(schema).properties) table = fieldTable(fieldRows(schema, ctx)); + } + + // Statuses that render identically (most often 400/401/403 → Error) share a line. + const key = `${summary}\u0000${table ?? ''}`; + const group = groups.get(key) ?? { statuses: [], summary, table }; + group.statuses.push(status); + groups.set(key, group); + } + + const lines: string[] = []; + for (const { statuses, summary, table } of groups.values()) { + lines.push(`- ${statuses.map((s) => `\`${s}\``).join(', ')} — ${summary}`); + if (table) lines.push('', indent(table), ''); + } + return lines; +} + +function renderOperation( + { method, path, op }: OperationRef, + ctx: SchemaContext, + baseUrl: string, + shared: ReadonlySet, +): string { + const out: string[] = [`### ${operationHeading(method, path)}`, '']; + if (op.summary) out.push(`**${op.summary.replace(/\.?$/, '.')}**`, ''); + if (op.description) out.push(op.description.trim(), ''); + + const sharedHere = (op.parameters ?? []).filter((p) => p.in === 'header' && shared.has(p.name)).map((p) => p.name); + const auth = isPublic(op) ? 'None — public endpoint.' : 'Bearer token (`Authorization: Bearer `).'; + out.push(`- **Auth:** ${auth}`); + if (sharedHere.length) { + out.push(`- **Common headers:** ${sharedHere.map((h) => `\`${h}\``).join(', ')} — see [Common headers](#common-headers).`); + } + out.push(''); + + const params = (op.parameters ?? []).filter((p) => !(p.in === 'header' && shared.has(p.name))); + if (params.length) out.push('**Parameters**', '', parameterTable(params, ctx), ''); + + const body = op.requestBody?.content?.[JSON_MEDIA]?.schema; + if (body) { + const resolved = ctx.resolve(body); + out.push('**Request body** (`application/json`)', ''); + out.push(resolved.properties ? fieldTable(fieldRows(body, ctx)) : `\`${typeLabel(body, ctx)}\``, ''); + } + + out.push('**Responses**', '', ...responseLines(op, ctx), ''); + out.push('**Example**', '', curlExample(method, path, op, baseUrl, ctx), ''); + return out.join('\n'); +} + +// ── Document ──────────────────────────────────────────────────────────────── + +function renderCommonHeaders( + common: ReturnType, + total: number, + ctx: SchemaContext, +): string { + const rows = [...common.values()].map(({ param, ops }) => { + const methods = [...new Set(ops.map((o) => o.method.toUpperCase()))]; + const sentOn = ops.length === total ? 'every operation' : `${ops.length} operations (${methods.join(', ')})`; + const notes = notesCell(param.description, constraintNotes(param.schema ?? {}, serverDefaultOf(param))); + return `| \`${param.name}\` | ${sentOn} | ${cell(typeLabel(param.schema ?? {}, ctx))} | ${notes} |`; + }); + return ['| Header | Accepted on | Type | Notes |', '|---|---|---|---|', ...rows].join('\n'); +} + +function renderIndex(ops: readonly OperationRef[], tags: readonly string[]): string { + const out: string[] = []; + for (const tag of tags) { + out.push(`### ${tag}`, '', '| Method | Path | Summary |', '|---|---|---|'); + for (const ref of ops.filter((o) => primaryTag(o.op) === tag)) { + const anchor = headingAnchor(operationHeading(ref.method, ref.path)); + out.push(`| ${ref.method.toUpperCase()} | [\`${ref.path}\`](#${anchor}) | ${cell(ref.op.summary ?? '')} |`); + } + out.push(''); + } + return out.join('\n'); +} + +function renderSchemas(ctx: SchemaContext): string { + const out: string[] = []; + const written = new Set(); + // Rendering one schema can link another (MemoryPage → Memory), so keep going + // until a pass adds nothing new. + for (let pending = [...ctx.linked]; pending.length; pending = [...ctx.linked].filter((n) => !written.has(n))) { + for (const name of pending) { + written.add(name); + const schema = ctx.components[name]; + if (!schema) continue; + out.push(`### ${name}`, ''); + if (schema.description) out.push(schema.description.trim(), ''); + out.push(fieldTable(fieldRows(schema, ctx)), ''); + } + } + return out.join('\n'); +} + +/** Render the whole document. Deterministic: the same spec gives the same bytes. */ +export function renderOpenApiMarkdown(doc: OpenApiDocument, links: MarkdownLinks): string { + const ctx = new SchemaContext(doc.components?.schemas ?? {}); + const baseUrl = doc.servers?.[0]?.url ?? ''; + const ops = listOperations(doc); + const tags = tagOrder(ops); + const common = commonHeaders(ops); + const shared = new Set(common.keys()); + + const authDescription = Object.values(doc.components?.securitySchemes ?? {})[0]?.description; + const intro = (doc.info.description ?? '').split('\n')[0]?.trim(); + + // Operations first: rendering them is what decides which schemas get linked. + const sections = tags.map((tag) => { + const body = ops + .filter((o) => primaryTag(o.op) === tag) + .map((ref) => renderOperation(ref, ctx, baseUrl, shared)); + return [`## ${tag}`, '', ...body].join('\n'); + }); + const errorLink = ctx.components['Error'] ? ctx.link('Error') : 'a JSON error object'; + const commonTable = renderCommonHeaders(common, ops.length, ctx); + const schemas = renderSchemas(ctx); + + return [ + `# ${doc.info.title}`, + '', + `> ${intro ? `${intro} ` : ''}This is the Markdown edition of the REST reference, written for AI agents and scripts. ` + + 'It is generated from the same OpenAPI document as the interactive reference, so the two always agree.', + '', + `- **Base URL:** \`${baseUrl}\` — every path below is relative to it.`, + `- **OpenAPI 3.1 JSON:** ${links.specUrl}`, + `- **Interactive reference (browser):** ${links.htmlUrl}`, + `- **MCP tools, scopes, filtering and limits:** ${links.llmsTxtUrl}`, + `- **Version:** ${doc.info.version ?? 'unversioned'} · ${ops.length} operations`, + '', + '## Before you call', + '', + `1. **Authenticate.** Send \`Authorization: Bearer \` on every request except the ones marked public. ${authDescription ?? ''}`.trim(), + '2. **Writes are real by default.** An operation that lists `X-LoreKit-Dry-Run` under its common headers changes data ' + + 'unless the request carries `X-LoreKit-Dry-Run: true`, which validates and authorizes it without changing anything. ' + + 'Operations that do not list it (including the `POST` reads) ignore it. See [Common headers](#common-headers).', + `3. **Errors.** A non-2xx response carries ${errorLink}. \`429\` means rate-limited; wait for the \`Retry-After\` header before retrying.`, + '4. **JSON in, JSON out.** Send request bodies with `Content-Type: application/json`.', + '', + '## Common headers', + '', + commonTable, + '', + '## Endpoints', + '', + renderIndex(ops, tags), + ...sections, + '## Schemas', + '', + 'Named shapes the endpoints above link to, written out once.', + '', + schemas, + ] + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + .concat('\n'); +} diff --git a/packages/schemas/src/openapi/spec.spec.ts b/packages/schemas/src/openapi/spec.spec.ts index 00beb8812..3da828bbb 100644 --- a/packages/schemas/src/openapi/spec.spec.ts +++ b/packages/schemas/src/openapi/spec.spec.ts @@ -104,6 +104,17 @@ describe('generateSpec', () => { const get = spec.paths['/memories']!.get as { parameters?: Array<{ name: string }> }; expect((get.parameters ?? []).some((p) => p.name === 'X-LoreKit-Dry-Run')).toBe(false); + + // A POST that only reads ignores the header; offering it promises a rehearsal. + const search = spec.paths['/memories/search']!.post as { parameters?: Array<{ name: string }> }; + expect((search.parameters ?? []).some((p) => p.name === 'X-LoreKit-Dry-Run')).toBe(false); + }); + + // The console pre-fills `true`; the server executes a request without the header. + it('states the dry-run header defaults to a real write on the server', () => { + const post = spec.paths['/memories']!.post as { parameters?: Array> }; + const dryRun = post.parameters?.find((p) => p['name'] === 'X-LoreKit-Dry-Run'); + expect(dryRun?.['x-server-default']).toBe(false); }); // The hard-delete flag is a query param on BOTH delete forms — a caller that diff --git a/packages/schemas/src/openapi/spec.ts b/packages/schemas/src/openapi/spec.ts index 4fb8f1766..31a591dfb 100644 --- a/packages/schemas/src/openapi/spec.ts +++ b/packages/schemas/src/openapi/spec.ts @@ -698,9 +698,12 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co 'Generate a token from [Settings → API keys](/settings/api-keys) and paste it into the', '**Authorize** box above — one token works on every endpoint, orgs included.', '', - '**Safe by default** — destructive calls run in *dry-run* mode: the `X-LoreKit-Dry-Run`', - 'header defaults to `true`, so create / update / delete requests are validated and', - 'authorized but make **no changes**. Clear that header on an operation to execute for real.', + '**Safe by default here** — this console pre-fills `X-LoreKit-Dry-Run: true`, so create /', + 'update / delete requests sent from it are validated and authorized but make **no changes**.', + 'Clear that header on an operation to execute for real. A request sent WITHOUT the header', + '(curl, an SDK, an agent) executes for real.', + '', + '**For agents and scripts** — the same reference as Markdown: [/api-docs.md](/api-docs.md).', ].join('\n'); const gen = new OpenApiGeneratorV31(registry.definitions); @@ -727,14 +730,30 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co // docs are safe by default; the caller clears it to execute for real. The // backend contract lives in `_shared/limits/dry-run.ts` (isDryRunHeader). const MUTATING_METHODS = new Set(['post', 'patch', 'delete', 'put']); + // POSTs that only READ (body-transport filters, search, batch read). Their + // handlers never check the header, so advertising it promises a rehearsal + // that does not happen. `rest-dry-run-parity.spec.ts` holds this to the routers. + const READ_ONLY_POSTS = new Set([ + '/memories/list', + '/memories/search', + '/memories/facets', + '/memories/pivot', + '/memories/activity', + '/memories/read', + ]); + // `schema.default: true` is the console pre-fill, NOT the server's default: an + // absent header executes. `x-server-default` states that in data, so readers + // that skip the prose (the Markdown renderer, agents) cannot miss it. const dryRunParam = { name: 'X-LoreKit-Dry-Run', in: 'header', required: false, description: - 'Safe-explore mode. When `true` (the default here), the request is validated and ' + - 'authorized but makes NO changes. Set it to `false` to execute for real.', + 'Safe-explore mode. `true` validates and authorizes the request but makes NO changes. ' + + 'Absent or `false` executes it for real — the server default is `false`; only this ' + + 'reference\'s try-it console pre-fills `true`.', schema: { type: 'boolean', default: true }, + 'x-server-default': false, }; // Attach the client-attribution header to EVERY operation (not just mutating // ones — it exists mainly to label reads). Optional and fail-safe: an @@ -765,11 +784,12 @@ export function generateSpec(baseUrl = 'https://pqokxlhvnosogizsjztg.supabase.co for (const [path, operations] of Object.entries(paths)) { // The public blog surface implements neither usage attribution nor dry-run, // so it must not advertise their headers. Every other (Bearer-authed) route - // gets the client header, and every mutating one the dry-run header. + // gets the client header, and every one that changes data the dry-run header. if (path.startsWith('/blog')) continue; for (const [method, operation] of Object.entries(operations)) { operation.parameters = [...(operation.parameters ?? []), clientParam]; if (!MUTATING_METHODS.has(method)) continue; + if (method === 'post' && READ_ONLY_POSTS.has(path)) continue; operation.parameters = [...(operation.parameters ?? []), dryRunParam]; } } diff --git a/packages/web-e2e/src/journeys/api-docs.e2e.ts b/packages/web-e2e/src/journeys/api-docs.e2e.ts index 9ce2b49b0..b747537df 100644 --- a/packages/web-e2e/src/journeys/api-docs.e2e.ts +++ b/packages/web-e2e/src/journeys/api-docs.e2e.ts @@ -3,7 +3,8 @@ import { expect, test } from '../support/fixtures'; /** * /api-docs is a route handler, not a page: it returns a Scalar HTML shell that * draws itself from a CDN script, so the browser view depends on the internet. - * Assert what the SERVER returns instead — the shell and the OpenAPI spec. + * Assert what the SERVER returns instead — the shell, the OpenAPI spec, and the + * Markdown edition agents read (the shell itself holds no content for them). */ test('the API reference shell is served', async ({ request }) => { @@ -13,6 +14,14 @@ test('the API reference shell is served', async ({ request }) => { expect(await res.text()).toContain('LoreKit REST API'); }); +test('the API reference shell points agents at the Markdown edition', async ({ request }) => { + const res = await request.get('/api-docs', { headers: { Accept: 'text/html' } }); + const html = await res.text(); + expect(html).toContain('[^<]*\/api-docs\.md[^<]*<\/noscript>/); + expect(res.headers()['link']).toContain('; rel="alternate"; type="text/markdown"'); +}); + test('the OpenAPI spec is served as JSON', async ({ request }) => { const res = await request.get('/api-docs/spec'); expect(res.status()).toBe(200); @@ -20,3 +29,24 @@ test('the OpenAPI spec is served as JSON', async ({ request }) => { expect(spec.info?.title).toBe('LoreKit REST API'); expect(Object.keys(spec.paths ?? {}).length).toBeGreaterThan(0); }); + +test('the API reference is served as Markdown at /api-docs.md', async ({ request }) => { + const res = await request.get('/api-docs.md'); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('text/markdown'); + const markdown = await res.text(); + expect(markdown).toMatch(/^# LoreKit REST API\n/); + expect(markdown).toContain('\n### POST /memories\n'); + expect(markdown).toContain('Writes are real by default'); +}); + +test('/api-docs returns the Markdown to a client that asks for it', async ({ request }) => { + const res = await request.get('/api-docs', { headers: { Accept: 'text/markdown, text/html;q=0.9' } }); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('text/markdown'); + expect(res.headers()['vary']).toMatch(/accept/i); + // Never cacheable by a CDN: one that ignored `Vary` would serve it to browsers. + expect(res.headers()['cache-control']).toContain('private'); + expect(res.headers()['cache-control']).not.toContain('s-maxage'); + expect(await res.text()).toMatch(/^# LoreKit REST API\n/); +}); diff --git a/packages/web/CLAUDE.md b/packages/web/CLAUDE.md index 8b8ffec0b..8d98191b6 100644 --- a/packages/web/CLAUDE.md +++ b/packages/web/CLAUDE.md @@ -144,3 +144,10 @@ until the whole queue drains. what the generator produces. The repo-wide obligation to update it lives in the root [CLAUDE.md](../../CLAUDE.md#user-facing-docs-mandatory-on-every-change). Keep the template terse: it is read by agents under a token budget, not browsed. +- **`/api-docs.md` is the REST reference for agents, rendered on request.** The + Scalar page at `/api-docs` is an empty JavaScript shell to a fetch, so the same + OpenAPI document is also served as Markdown by `app/api-docs.md/route.ts` + (renderer: `@lorekit/schemas/openapi/markdown`), and `/api-docs` returns it to + `Accept: text/markdown`. Nothing is committed and nothing can drift: improve the + spec in `packages/schemas/src/openapi/spec.ts` and both forms change. The + negotiation and the HTML signposts live in `src/lib/api-docs/agent-docs.ts`. diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index 965c8b653..2d7035ab7 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -109,6 +109,21 @@ https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/mcp Auth: `Authorization: Bearer ` — token prefix encodes permissions (see Tokens below). +## REST API + +Memories, organizations, members and invites are also plain HTTPS + JSON under +`https://pqokxlhvnosogizsjztg.supabase.co/functions/v1`, with the same Bearer token. + +- **Reference as Markdown (read this one):** https://lorekit.io/api-docs.md — every route, + parameter, body and response, with a `curl` example each. `https://lorekit.io/api-docs` + returns the same file to a request sent with `Accept: text/markdown`. +- OpenAPI 3.1 JSON: https://lorekit.io/api-docs/spec +- Interactive console (browser): https://lorekit.io/api-docs + +A REST write is REAL unless it carries `X-LoreKit-Dry-Run: true`, which validates and authorizes +the request without changing anything. The browser console pre-fills that header; curl and SDKs +do not. + ## Tokens ``` @@ -997,6 +1012,7 @@ id: a `401` makes streamable-HTTP clients retry a session handshake and hang. ## Key documentation +- REST API reference (Markdown): https://lorekit.io/api-docs.md - MCP tools reference: https://github.com/mthines/lorekit/blob/main/docs/mcp-tools.md - Scope format: https://github.com/mthines/lorekit/blob/main/docs/scope-format.md - API tokens: https://github.com/mthines/lorekit/blob/main/docs/api-tokens.md diff --git a/packages/web/src/app/api-docs.md/route.ts b/packages/web/src/app/api-docs.md/route.ts new file mode 100644 index 000000000..b3c7eb4f1 --- /dev/null +++ b/packages/web/src/app/api-docs.md/route.ts @@ -0,0 +1,9 @@ +import { apiDocsMarkdownResponse } from '@/lib/api-docs/rest-api'; + +/** + * The REST reference as Markdown for agents — the Scalar page at `/api-docs` is empty without JavaScript, and `.md` follows the llms.txt "Markdown twin" convention. + * @see src/lib/api-docs/agent-docs.ts + */ +export function GET(request: Request) { + return apiDocsMarkdownResponse(request.url); +} diff --git a/packages/web/src/app/api-docs/route.ts b/packages/web/src/app/api-docs/route.ts index f5d9e12b1..7e0b37b32 100644 --- a/packages/web/src/app/api-docs/route.ts +++ b/packages/web/src/app/api-docs/route.ts @@ -1,5 +1,14 @@ import { ApiReference } from '@scalar/nextjs-api-reference'; +import { + API_DOCS_MARKDOWN_PATH, + API_DOCS_SPEC_PATH, + agentDocsLinkHeader, + prefersMarkdown, + withAgentHints, +} from '@/lib/api-docs/agent-docs'; +import { apiDocsMarkdownResponse } from '@/lib/api-docs/rest-api'; + import { LOREKIT_SCALAR_CSS } from './theme'; /** @@ -15,9 +24,14 @@ import { LOREKIT_SCALAR_CSS } from './theme'; * just consumes it through the same-origin `/api-docs/spec` proxy (see the * sibling route) so the fetch works on localhost, preview, and production * regardless of the Edge Function's CORS allow-origin. + * + * Scalar draws the page with JavaScript, so the HTML an agent fetches is empty. + * A request asking for `Accept: text/markdown` therefore gets the Markdown + * edition (`/api-docs.md`) instead, and the HTML carries signposts to it — see + * `src/lib/api-docs/agent-docs.ts`. */ const config = { - url: '/api-docs/spec', + url: API_DOCS_SPEC_PATH, pageTitle: 'LoreKit REST API', // Route "Send"/try-it-out through our same-origin forwarder so live testing // isn't blocked by the Edge Function's CORS allow-list (see ./proxy/route.ts). @@ -35,8 +49,28 @@ const config = { persistAuth: true, metaData: { title: 'LoreKit REST API', - description: 'REST API reference for LoreKit — shared, persistent agent memory.', + description: `REST API reference for LoreKit — shared, persistent agent memory. Markdown for agents: ${API_DOCS_MARKDOWN_PATH}`, }, }; -export const GET = ApiReference(config); +const renderScalarPage = ApiReference(config); + +// The body depends on `Accept`, so every cache between here and the caller +// must key on it — otherwise a cached Markdown body could be served to a browser. +const NEGOTIATED = { Vary: 'Accept', Link: agentDocsLinkHeader() }; + +// Kept out of shared caches too: `Vary` only protects browsers if the CDN honours +// it. The cacheable copy is `/api-docs.md`, which has one body per URL. +const NEGOTIATED_MARKDOWN = { ...NEGOTIATED, 'Cache-Control': 'private, no-cache' }; + +export async function GET(request: Request) { + if (prefersMarkdown(request.headers.get('accept'))) { + return apiDocsMarkdownResponse(request.url, NEGOTIATED_MARKDOWN); + } + + const page = renderScalarPage(); + const html = withAgentHints(await page.text(), new URL(request.url).origin); + const headers = new Headers(page.headers); + for (const [name, value] of Object.entries(NEGOTIATED)) headers.set(name, value); + return new Response(html, { status: page.status, headers }); +} diff --git a/packages/web/src/app/api-docs/spec/route.ts b/packages/web/src/app/api-docs/spec/route.ts index fcaa36423..244391b45 100644 --- a/packages/web/src/app/api-docs/spec/route.ts +++ b/packages/web/src/app/api-docs/spec/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; -import { generateSpec } from '@lorekit/schemas/openapi/spec'; + +import { API_DOCS_CACHE_CONTROL, restApiSpec } from '@/lib/api-docs/rest-api'; /** * Serves the OpenAPI spec for the /api-docs Scalar page. @@ -11,15 +12,11 @@ import { generateSpec } from '@lorekit/schemas/openapi/spec'; * * `servers[0].url` is pinned to this environment's Edge Function base so Scalar's * "Send" targets the right API; those requests still go through the same-origin - * `/api-docs/proxy` forwarder (see ../proxy/route.ts) to avoid CORS. + * `/api-docs/proxy` forwarder (see ../proxy/route.ts) to avoid CORS. The same + * document, rendered as Markdown for agents, is served at `/api-docs.md`. */ -const SUPABASE_URL = - process.env['NEXT_PUBLIC_SUPABASE_URL'] ?? 'https://pqokxlhvnosogizsjztg.supabase.co'; -const BASE_URL = `${SUPABASE_URL}/functions/v1`; - export function GET() { - const spec = generateSpec(BASE_URL); - return NextResponse.json(spec, { - headers: { 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=60' }, + return NextResponse.json(restApiSpec(), { + headers: { 'Cache-Control': API_DOCS_CACHE_CONTROL }, }); } diff --git a/packages/web/src/content/docs/deep-links.mdx b/packages/web/src/content/docs/deep-links.mdx index 806ba31ee..3426a9e8d 100644 --- a/packages/web/src/content/docs/deep-links.mdx +++ b/packages/web/src/content/docs/deep-links.mdx @@ -170,3 +170,4 @@ These dashboard pages are plain routes — no encoding needed: | Integrations | `/settings/integrations` | | Documentation | `/docs` | | REST API reference | `/api-docs` | +| REST API reference as Markdown (for agents and scripts) | `/api-docs.md` | diff --git a/packages/web/src/lib/api-docs/agent-docs.spec.ts b/packages/web/src/lib/api-docs/agent-docs.spec.ts new file mode 100644 index 000000000..6340c19ef --- /dev/null +++ b/packages/web/src/lib/api-docs/agent-docs.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { agentDocsLinkHeader, prefersMarkdown, withAgentHints } from './agent-docs'; + +describe('prefersMarkdown', () => { + it.each([ + // Agents that ask for Markdown get it. + ['text/markdown', true], + ['text/x-markdown', true], + ['text/markdown, text/html, */*', true], + ['text/html;q=0.9, text/markdown', true], + ['text/markdown;q=0.8, text/plain', true], + // Browsers and generic clients keep the page. + [null, false], + ['', false], + ['*/*', false], + ['text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', false], + ['text/html, text/markdown;q=0.5', false], + ['text/markdown;q=0', false], + // Parameter names are case-insensitive, so an uppercase `Q` still refuses. + ['text/markdown;Q=0', false], + ['text/html, text/markdown;Q=0.5', false], + ['application/json', false], + ])('%j → %s', (accept, expected) => { + expect(prefersMarkdown(accept)).toBe(expected); + }); +}); + +describe('agentDocsLinkHeader', () => { + it('names the Markdown twin and the OpenAPI description', () => { + const header = agentDocsLinkHeader(); + expect(header).toContain('; rel="alternate"; type="text/markdown"'); + expect(header).toContain('; rel="service-desc"'); + }); +}); + +describe('withAgentHints', () => { + const shell = [ + '', + '', + ' ', + ' LoreKit REST API', + ' ', + ' ', + '
', + ' ', + '', + ].join('\n'); + const html = withAgentHints(shell, 'https://lorekit.test'); + + it('adds alternate links to the head', () => { + const head = html.slice(0, html.indexOf('')); + expect(head).toContain(' { + const noscript = /