From 74dd00849a68d6650b65ef4e9e14de259624c379 Mon Sep 17 00:00:00 2001 From: Nikola Balic Date: Thu, 30 Jul 2026 13:42:14 +0200 Subject: [PATCH] feat(api): publish an RFC 9727 API catalog Agents had no machine-readable way to find the Steel API from the docs. Add the catalog at /.well-known/api-catalog, listing the API and linking its OpenAPI description and human documentation. The catalog points at https://api.steel.dev/sdk-openapi.json rather than a copy served from docs. The API already sends the spec with open CORS, so a mirror would add nothing an agent cannot already fetch while introducing a second copy that goes stale between docs deploys. /openapi.json redirects to the same canonical URL for anyone who wants the short form. Two details the endpoint needed: RFC 9727 requires the linkset media type, which a header rule supplies because the file has no extension, and the path is excluded from markdown negotiation, which would otherwise treat an extensionless path as a docs page and rewrite it into a 404. --- lib/markdown-negotiation.ts | 1 + next.config.mjs | 19 +++++++++ public/.well-known/api-catalog | 27 ++++++++++++ tests/api-catalog.test.ts | 76 +++++++++++++++++++++++++++++++++ tests/e2e/llm-endpoints.test.ts | 28 +++++++++++- 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 public/.well-known/api-catalog create mode 100644 tests/api-catalog.test.ts diff --git a/lib/markdown-negotiation.ts b/lib/markdown-negotiation.ts index b8bc1f78..75bc8bd0 100644 --- a/lib/markdown-negotiation.ts +++ b/lib/markdown-negotiation.ts @@ -31,6 +31,7 @@ export const MARKDOWN_USER_AGENT_SUBSTRINGS = [ const MARKDOWN_VARY_HEADERS = ['Accept', 'User-Agent']; const EXCLUDED_EXACT_PATHS = new Set([ + '/.well-known/api-catalog', '/.well-known/llms.txt', '/AGENTS', '/AGENTS.md', diff --git a/next.config.mjs b/next.config.mjs index 47d0341f..36cbc579 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -59,6 +59,14 @@ const config = { destination: "https://steel.apidocumentation.com/api-reference", permanent: true, }, + { + // A memorable docs URL for the spec, redirecting rather than mirroring: + // the API serves the spec with open CORS, so a local copy would only + // add one that goes stale between docs deploys. + source: "/openapi.json", + destination: "https://api.steel.dev/sdk-openapi.json", + permanent: true, + }, // { // source: "/overview/steel-cli", // destination: "https://github.com/steel-dev/cli", @@ -201,6 +209,17 @@ const config = { }, async headers() { return [ + { + // RFC 9727 requires the catalog to be served as a linkset. The file has + // no extension, so static serving would otherwise guess a type for it. + source: "/.well-known/api-catalog", + headers: [ + { + key: "Content-Type", + value: 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"', + }, + ], + }, { source: "/(.*)", headers: [ diff --git a/public/.well-known/api-catalog b/public/.well-known/api-catalog new file mode 100644 index 00000000..d10dbe29 --- /dev/null +++ b/public/.well-known/api-catalog @@ -0,0 +1,27 @@ +{ + "linkset": [ + { + "anchor": "https://docs.steel.dev/.well-known/api-catalog", + "item": [ + { + "href": "https://api.steel.dev" + } + ] + }, + { + "anchor": "https://api.steel.dev", + "service-desc": [ + { + "href": "https://api.steel.dev/sdk-openapi.json", + "type": "application/openapi+json" + } + ], + "service-doc": [ + { + "href": "https://docs.steel.dev/overview/intro-to-steel", + "type": "text/html" + } + ] + } + ] +} diff --git a/tests/api-catalog.test.ts b/tests/api-catalog.test.ts new file mode 100644 index 00000000..eb9f3662 --- /dev/null +++ b/tests/api-catalog.test.ts @@ -0,0 +1,76 @@ +// ABOUTME: Tests for the RFC 9727 API catalog served at /.well-known/api-catalog, +// ABOUTME: covering the linkset shape and that markdown negotiation leaves it alone. +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { isNegotiableDocsPath } from '../lib/markdown-negotiation'; + +const CATALOG_PATH = '/.well-known/api-catalog'; + +type Link = { href: string; type?: string }; +type LinksetEntry = { + anchor: string; + item?: Link[]; + 'service-desc'?: Link[]; + 'service-doc'?: Link[]; +}; + +const catalog = JSON.parse( + readFileSync( + fileURLToPath(new URL('../public/.well-known/api-catalog', import.meta.url)), + 'utf8', + ), +) as { linkset: LinksetEntry[] }; + +function entryFor(anchor: string): LinksetEntry | undefined { + return catalog.linkset.find((entry) => entry.anchor === anchor); +} + +describe('api catalog document', () => { + test('is a linkset anchored at its own well-known URI', () => { + expect(Array.isArray(catalog.linkset)).toBe(true); + expect(entryFor(`https://docs.steel.dev${CATALOG_PATH}`)).toBeDefined(); + }); + + test('lists the Steel API as a catalog item', () => { + const items = entryFor(`https://docs.steel.dev${CATALOG_PATH}`)?.item ?? []; + expect(items.map((item) => item.href)).toContain('https://api.steel.dev'); + }); + + test('describes the API with the canonical OpenAPI spec, not a docs copy', () => { + const [desc] = entryFor('https://api.steel.dev')?.['service-desc'] ?? []; + + // Pointing at the spec the API itself serves keeps one canonical copy, so + // the catalog cannot advertise a spec that has drifted from the live API. + expect(desc?.href).toBe('https://api.steel.dev/sdk-openapi.json'); + expect(desc?.type).toBe('application/openapi+json'); + }); + + test('links human documentation for the API', () => { + const [doc] = entryFor('https://api.steel.dev')?.['service-doc'] ?? []; + expect(doc?.href).toStartWith('https://'); + expect(doc?.type).toBe('text/html'); + }); + + test('every link is an absolute https URL', () => { + for (const entry of catalog.linkset) { + const links = [ + ...(entry.item ?? []), + ...(entry['service-desc'] ?? []), + ...(entry['service-doc'] ?? []), + ]; + for (const link of links) { + expect(link.href).toStartWith('https://'); + } + } + }); +}); + +describe('api catalog and markdown negotiation', () => { + test('is excluded from markdown negotiation', () => { + // The path has no file extension, so without an explicit exclusion the + // middleware would treat it as a docs page and rewrite it into a 404. + expect(isNegotiableDocsPath(CATALOG_PATH)).toBe(false); + }); +}); diff --git a/tests/e2e/llm-endpoints.test.ts b/tests/e2e/llm-endpoints.test.ts index fca93528..f2d737f6 100644 --- a/tests/e2e/llm-endpoints.test.ts +++ b/tests/e2e/llm-endpoints.test.ts @@ -1,5 +1,5 @@ -// ABOUTME: End-to-end tests that boot the Next.js dev server and verify the -// ABOUTME: LLM-facing endpoints: .md-suffixed URLs, index pointer, llms-full.txt. +// ABOUTME: End-to-end tests that boot the Next.js dev server and verify the agent-facing +// ABOUTME: endpoints: .md URLs, index pointer, llms-full.txt, api-catalog, openapi redirect. import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test'; import { fileURLToPath } from 'node:url'; @@ -111,6 +111,30 @@ describe('.md suffix end-to-end', () => { expect(await response.text()).toStartWith('> Full docs index: https://docs.steel.dev/llms.txt'); }); + test('serves the API catalog as a linkset', async () => { + const response = await fetch(`${BASE_URL}/.well-known/api-catalog`, { + headers: { accept: 'application/linkset+json' }, + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toStartWith('application/linkset+json'); + const catalog = (await response.json()) as { linkset: Array<{ anchor: string }> }; + expect(catalog.linkset.length).toBeGreaterThan(0); + }); + + test('serves the API catalog to markdown clients too, without rewriting it', async () => { + const response = await fetch(`${BASE_URL}/.well-known/api-catalog`, { + headers: { accept: 'text/markdown', 'user-agent': 'claude-code/1.0' }, + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toStartWith('application/linkset+json'); + }); + + test('redirects /openapi.json to the canonical spec', async () => { + const response = await fetch(`${BASE_URL}/openapi.json`, { redirect: 'manual' }); + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe('https://api.steel.dev/sdk-openapi.json'); + }); + test('returns 404 for a .md URL with no matching page', async () => { const response = await fetch(`${BASE_URL}/nonexistent-page.md`, { headers: BROWSER_HEADERS,