@escalay/kit 0.0.0-stage → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -2
  3. package/cli.mjs +438 -0
  4. package/docs/STANDARD.md +342 -0
  5. package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
  6. package/docs/decisions/0002-node-and-workers.md +45 -0
  7. package/docs/releasing.md +97 -0
  8. package/package.json +59 -3
  9. package/registry.json +548 -0
  10. package/src/a2a/README.md +25 -0
  11. package/src/a2a/index.ts +262 -0
  12. package/src/boards/README.md +35 -0
  13. package/src/boards/handlers.ts +87 -0
  14. package/src/boards/index.ts +64 -0
  15. package/src/boards/refs.ts +23 -0
  16. package/src/boards/repo.ts +50 -0
  17. package/src/boards/routes.ts +83 -0
  18. package/src/boards/schema.ts +28 -0
  19. package/src/boards/tools.ts +76 -0
  20. package/src/browser/README.md +31 -0
  21. package/src/browser/boards-client.ts +74 -0
  22. package/src/browser/boards-react.ts +73 -0
  23. package/src/browser/search-client.ts +258 -0
  24. package/src/browser/search-react.ts +116 -0
  25. package/src/browser/vector-search-block.tsx +94 -0
  26. package/src/core/README.md +37 -0
  27. package/src/core/api.ts +239 -0
  28. package/src/core/auth.ts +147 -0
  29. package/src/core/cache.ts +53 -0
  30. package/src/core/discovery.ts +315 -0
  31. package/src/core/guards.ts +109 -0
  32. package/src/core/hal.ts +63 -0
  33. package/src/core/kv.ts +29 -0
  34. package/src/core/problem.ts +124 -0
  35. package/src/db/README.md +39 -0
  36. package/src/db/guards.ts +61 -0
  37. package/src/db/index.ts +122 -0
  38. package/src/guide/README.md +38 -0
  39. package/src/guide/guide.ts +152 -0
  40. package/src/guide/reference.ts +204 -0
  41. package/src/index.ts +15 -0
  42. package/src/mcp/README.md +34 -0
  43. package/src/mcp/index.ts +251 -0
  44. package/src/oauth/README.md +29 -0
  45. package/src/oauth/index.ts +439 -0
  46. package/src/oauth/schema.ts +7 -0
  47. package/src/oauth/store.ts +34 -0
  48. package/src/pagination/README.md +31 -0
  49. package/src/pagination/index.ts +65 -0
  50. package/src/testing/README.md +31 -0
  51. package/src/testing/index.ts +162 -0
  52. package/src/vector-search/README.md +27 -0
  53. package/src/vector-search/index.ts +367 -0
  54. package/src/vectors/README.md +30 -0
  55. package/src/vectors/index.ts +326 -0
  56. package/src/web/README.md +21 -0
  57. package/src/web/index.ts +100 -0
  58. package/templates/app/AGENTS.md +41 -0
  59. package/templates/app/CLAUDE.md +3 -0
  60. package/templates/app/Dockerfile +12 -0
  61. package/templates/app/container/entrypoint.sh +7 -0
  62. package/templates/app/gitignore +4 -0
  63. package/templates/app/guide/llms.txt +11 -0
  64. package/templates/app/pnpm-workspace.yaml +5 -0
  65. package/templates/app/scripts/guide.ts +8 -0
  66. package/templates/app/src/app.ts +39 -0
  67. package/templates/app/src/features/index.ts +30 -0
  68. package/templates/app/src/server.ts +14 -0
  69. package/templates/app/test/api.test.ts +34 -0
  70. package/templates/app/tsconfig.json +16 -0
  71. package/templates/feature/feature.test.ts +19 -0
  72. package/templates/feature/handlers.ts +43 -0
  73. package/templates/feature/index.ts +34 -0
  74. package/templates/feature/repo.ts +24 -0
  75. package/templates/feature/routes.ts +43 -0
  76. package/templates/feature/schema.ts +14 -0
  77. package/templates/feature/tools.ts +46 -0
  78. package/templates/npm-release/release-please-config.json +60 -0
  79. package/templates/npm-release/release-please-manifest.json +3 -0
  80. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  81. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  82. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  83. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  84. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  85. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  86. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  87. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  88. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  89. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A suggested search UI over the `search-react` hooks, as a shadcn block: a search field that also takes a pasted (⌘V),
3
+ * dropped or picked picture, a look/meaning/both switch, a live status line and a results grid. The app renders each
4
+ * result (`renderResult`), so cards stay its own. Copy it, restyle it or skip it: the hooks carry all the logic.
5
+ *
6
+ * Install: `npx shadcn add escalay/api-kit/vector-search-ui`, or `kit add
7
+ * vector-search-ui --dir src/components/vector-search`. Needs the shadcn `button` and `input` (Radix or Base UI alike).
8
+ *
9
+ * @example
10
+ * const client = useMemo(() => createSearchClient<RecipeCard>({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar' }), [])
11
+ * <VectorSearch client={client} getKey={(recipe) => recipe.slug} renderResult={({ item }) => <RecipeCard recipe={item} />} />
12
+ */
13
+ import { useState, type ReactNode } from 'react'
14
+ import { Button } from '@/components/ui/button'
15
+ import { Input } from '@/components/ui/input'
16
+ import { cn } from '@/lib/utils'
17
+ import type { SearchBy, SearchClient, SearchResult } from './search-client'
18
+ import { usePictureInput, useVectorSearch } from './search-react'
19
+
20
+ const BY: { value: SearchBy; label: string }[] = [
21
+ { value: 'both', label: 'Both' },
22
+ { value: 'look', label: 'Look' },
23
+ { value: 'meaning', label: 'Meaning' },
24
+ ]
25
+
26
+ export function VectorSearch<Item>({ client, renderResult, getKey, placeholder = 'Describe it, or paste a picture', limit, className }: { client: SearchClient<Item>; renderResult: (result: SearchResult<Item>) => ReactNode; getKey: (item: Item) => string; placeholder?: string; limit?: number; className?: string }) {
27
+ const search = useVectorSearch(client, { limit })
28
+ const [text, setText] = useState('')
29
+ const picture = usePictureInput({
30
+ onPicture: (file) => {
31
+ setText('')
32
+ search.setPicture(file)
33
+ },
34
+ })
35
+ const status =
36
+ search.status === 'loading' ? 'Searching…'
37
+ : search.status === 'error' ? search.error?.detail
38
+ : search.status === 'done' ? (search.results.length ? `${search.results.length} ${search.results.length === 1 ? 'match' : 'matches'}, best first` : 'Nothing stands out. Try other words, or compare by both.')
39
+ : 'Type what you are looking for, or paste (⌘V), drop or choose a picture.'
40
+
41
+ return (
42
+ <div {...picture.dropProps} data-dragging={picture.dragging || undefined} className={cn('flex flex-col gap-4 rounded-lg', picture.dragging && 'ring-2 ring-ring ring-offset-2', className)}>
43
+ <div className="flex flex-wrap items-center gap-2">
44
+ <div className="relative flex min-w-0 flex-1 items-center">
45
+ {search.input.kind === 'picture' ? (
46
+ <span className="absolute left-1.5 flex items-center">
47
+ <img src={search.input.preview} alt="Your picture" className="size-7 rounded object-cover" />
48
+ <button type="button" aria-label="Remove the picture" onClick={search.clear} className="-ml-2 -mt-6 grid size-4 place-items-center rounded-full bg-foreground text-[10px] leading-none text-background">
49
+ ×
50
+ </button>
51
+ </span>
52
+ ) : null}
53
+ <Input
54
+ type="search"
55
+ value={text}
56
+ placeholder={search.input.kind === 'picture' ? 'Searching by your picture' : placeholder}
57
+ aria-label="Search"
58
+ className={cn(search.input.kind === 'picture' && 'pl-12')}
59
+ onChange={(event) => {
60
+ setText(event.target.value)
61
+ search.setWords(event.target.value)
62
+ }}
63
+ />
64
+ </div>
65
+ <Button type="button" variant="outline" onClick={picture.openPicker}>
66
+ Picture
67
+ </Button>
68
+ <input {...picture.inputProps} />
69
+ <div role="group" aria-label="Compare by" className="flex gap-1">
70
+ {BY.map(({ value, label }) => (
71
+ <Button key={value} type="button" size="sm" variant={search.by === value ? 'secondary' : 'ghost'} aria-pressed={search.by === value} onClick={() => search.setBy(value)}>
72
+ {label}
73
+ </Button>
74
+ ))}
75
+ </div>
76
+ </div>
77
+ <p role="status" aria-live="polite" className="flex items-center gap-2 text-sm text-muted-foreground">
78
+ {status}
79
+ {search.status === 'error' ? (
80
+ <Button type="button" variant="link" size="sm" onClick={search.retry}>
81
+ Retry
82
+ </Button>
83
+ ) : null}
84
+ </p>
85
+ {search.results.length ? (
86
+ <ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
87
+ {search.results.map((result) => (
88
+ <li key={getKey(result.item)}>{renderResult(result)}</li>
89
+ ))}
90
+ </ul>
91
+ ) : null}
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,37 @@
1
+ # core
2
+
3
+ The API itself: `createAgentApi` builds an OpenAPIHono app that already follows the standard (problem+json errors,
4
+ HAL, auth, caching, discovery), so an app only adds its routes. Every other server module builds on it.
5
+
6
+ Needs: `hono`, `@hono/zod-openapi`, `zod`, `jose`, `@scalar/hono-api-reference`.
7
+
8
+ ```ts
9
+ import { createAgentApi, parseApiKeys, problemResponses, HAL_JSON, linksOf, originOf, ProblemError } from './kit/index.js'
10
+
11
+ const app = createAgentApi({
12
+ title: 'Notes', version: '1.0.0', description: 'What it is and where to start (Markdown).',
13
+ tags: [{ name: 'Notes', description: 'The notes.' }], // the workflow tag first: the generated guide follows tag order
14
+ auth: { realm: 'notes', keys: parseApiKeys(env.API_KEYS), access },
15
+ oauth, // optional: createOAuth(…) from the oauth module
16
+ private: true, // optional: nothing readable without credentials, noindex
17
+ })
18
+ ```
19
+
20
+ | File | What |
21
+ |---|---|
22
+ | `api.ts` | `createAgentApi`: request ids, secure headers, CORS, auth, strict query parameters (unknown ones answer 400), `Idempotency-Key` replay, `/openapi.json`, `/docs`, `/problems/{code}`, `/api/health`, the discovery it can serve alone |
23
+ | `problem.ts` | RFC 9457: `ProblemError` (throw it), `PROBLEMS`, `problemResponses(400, 404…)` for a route's documented errors, `problemResponse` |
24
+ | `hal.ts` | `linksOf(origin, { self, next… })`, `originOf(c)` (the public origin behind proxies), `pathWith(path, query)`, `LinksSchema`, `HAL_JSON` |
25
+ | `auth.ts` | API keys (only hashes kept: `hashApiKey`, `mintApiKey`, `parseApiKeys`), Cloudflare Access, `authenticate`; `SCOPES`, `OAUTH_OPEN_PATHS` and the `OAuthServer` shape the oauth module fills |
26
+ | `cache.ts` | `snapshotEtag` (on Workers pass `boot: env.CF_VERSION_METADATA.id`), `CACHE` presets, `cacheControl` |
27
+ | `guards.ts` | `idempotency` (wired for you) and `rateLimit({ policy, limit, windowSeconds, store? })` for anything that spends money; both keep state in a `GuardStore`: `memoryGuardStore()` (default, one Node process) or `dbGuardStore(db)` from `db` (every instance shares it: required on Workers), passed as `createAgentApi({ guards: { store } })` and to each `rateLimit` |
28
+ | `discovery.ts` | api-catalog, Link headers, robots.txt with Content Signals, Markdown for agents, skills index, Server Card, `serveAiCatalog` |
29
+ | `kv.ts` | `KeyValueStore` (values with expiry) and `memoryStore`, what OAuth and A2A keep |
30
+
31
+ ## Rules
32
+
33
+ - Every route is `createRoute` + `app.openapi`: camelCase `operationId`, a summary, a description whose first sentence
34
+ says what it is for, described parameters with real examples, a named HAL response, `...problemResponses(…)`.
35
+ - Errors are thrown as `ProblemError('not_found', { detail })`; the detail says what to do next.
36
+ - A route that spends money answers 202 with a run when slow and carries a `rateLimit`.
37
+ - The standard behind all of this: `docs/STANDARD.md` §1–9, §12, §14.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * One call gives an app the whole standard: an `OpenAPIHono` whose every error is problem+json, every request has an
3
+ * id, the OpenAPI 3.1 document at `/openapi.json` describes auth, tags and the agent guide, Scalar renders it at
4
+ * `/docs`, `/problems/{code}` explains each error, and `/api/health` answers without a key. The app then adds its own
5
+ * routes with `createRoute` and `app.openapi`.
6
+ *
7
+ * @example
8
+ * const app = createAgentApi({
9
+ * title: 'Cookbook', version: '1.0.0', description: 'What it is and where to start.',
10
+ * tags: [{ name: 'Catalog', description: 'Cooks, recipes, menus' }],
11
+ * auth: { realm: 'cookbook', keys: parseApiKeys(process.env.API_KEYS), access: { team: 'example', audience: process.env.ACCESS_AUD ?? '' } },
12
+ * health: () => ({ snapshot: store.snapshot }),
13
+ * })
14
+ * app.openapi(getAppRoute, (c) => c.json(appOf(c.req.valid('param').id), 200))
15
+ */
16
+ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
17
+ import { Scalar } from '@scalar/hono-api-reference'
18
+ import type { MiddlewareHandler } from 'hono'
19
+ import { cors } from 'hono/cors'
20
+ import { requestId } from 'hono/request-id'
21
+ import { secureHeaders } from 'hono/secure-headers'
22
+ import { authenticate, OAUTH_OPEN_PATHS, SCOPES, type OAuthServer } from './auth.js'
23
+ import { cacheControl, CACHE } from './cache.js'
24
+ import { idempotency, type GuardStore } from './guards.js'
25
+ import { API_CATALOG_TYPE, apiCatalog, LINK_HEADER, markdownTokens, MARKDOWN_TYPE, poppyJson, robotsTxt, sendPublic, sitemapXml, WELL_KNOWN, wantsMarkdown, type DiscoveryConfig } from './discovery.js'
26
+ import { HAL_JSON, originOf } from './hal.js'
27
+ import { errorHandler, notFoundHandler, PROBLEMS, problemResponse, problemResponses, validationHook, type ProblemCode } from './problem.js'
28
+
29
+ export type AgentApiConfig = {
30
+ title: string
31
+ version: string
32
+ /** Markdown for the top of the OpenAPI document and the docs page: what this is, where to start. */
33
+ description: string
34
+ tags: { name: string; description: string }[]
35
+ /** Scalar's sidebar groups (`x-tagGroups`). */
36
+ tagGroups?: { name: string; tags: string[] }[]
37
+ auth: Parameters<typeof authenticate>[0]
38
+ /** Extra fields for `/api/health`, e.g. the snapshot the API serves. */
39
+ health?: () => Record<string, unknown>
40
+ /** Origins allowed to call from a browser; the API's own pages are same-origin and need none. */
41
+ corsOrigins?: string[]
42
+ /** robots.txt: Content Signals and crawling (defaults: private, no, no, no). See `discovery.ts`. */
43
+ discovery?: DiscoveryConfig
44
+ /** `docs` (default): `/` sends people to `/docs` and agents asking for Markdown to the guide. `false`: the app serves `/`. */
45
+ home?: 'docs' | false
46
+ /** The service as its own OAuth server (`createOAuth`): mounted here, its tokens accepted beside keys. */
47
+ oauth?: OAuthServer
48
+ /** Where Idempotency-Key answers are kept (default: this process's memory). On Workers: `dbGuardStore(db)`. */
49
+ guards?: { store?: GuardStore }
50
+ /**
51
+ * Private: nothing about the service is readable without credentials. Only `robots.txt`, `/api/health` (status only),
52
+ * the error pages and what an OAuth client needs to ask for access stay open; the guide, docs, spec, discovery files
53
+ * and agent cards need a key, a token or the Access cookie. Every response says `noindex`, and no page points at
54
+ * the guide. Agents still connect: MCP clients through OAuth (the 401 names the resource metadata), others with a key.
55
+ */
56
+ private?: boolean
57
+ }
58
+
59
+ /** What an OAuth client needs before it holds a token, and nothing more (private mode). */
60
+ const PRIVATE_OPEN = ['/robots.txt', '/api/health', '/problems/*', '/.well-known/oauth-protected-resource*', '/.well-known/oauth-authorization-server*', '/.well-known/jwks.json']
61
+ const PRIVATE_OAUTH_OPEN = ['/oauth/*', '/agent/identity', '/agent/identity/claim']
62
+ const NOINDEX = 'noindex, nofollow, noarchive, nosnippet, noimageindex'
63
+
64
+ export const SECURITY_SCHEME = 'apiKey'
65
+
66
+ type Parameter = { name?: string; in?: string; required?: boolean; description?: string; example?: unknown; schema?: { description?: string; example?: unknown; type?: string; maxLength?: number } }
67
+ type OpenApiDocument = { paths?: Record<string, Record<string, { parameters?: Parameter[] }>> } & Record<string, unknown>
68
+
69
+ const IDEMPOTENCY_HEADER = {
70
+ name: 'Idempotency-Key',
71
+ in: 'header',
72
+ required: false,
73
+ description: 'Any unique string (a UUID): a retry with the same key and body gets the first answer instead of doing the work twice; the same key with another body is a 422. Kept 24 hours.',
74
+ schema: { type: 'string', maxLength: 255 },
75
+ }
76
+
77
+ /**
78
+ * Copies each parameter's schema description and example up to the parameter, where OpenAPI readers look first, and
79
+ * documents `Idempotency-Key` on every POST and PATCH (the kit honours it on all of them).
80
+ */
81
+ export const hoistParameterDocs = (doc: OpenApiDocument) => {
82
+ for (const item of Object.values(doc.paths ?? {}))
83
+ for (const [method, operation] of Object.entries(item)) {
84
+ if (method === 'post' || method === 'patch') operation.parameters = [...(operation.parameters ?? []), IDEMPOTENCY_HEADER as Parameter]
85
+ for (const parameter of operation.parameters ?? []) {
86
+ parameter.description ??= parameter.schema?.description
87
+ if (parameter.example === undefined && parameter.schema?.example !== undefined) parameter.example = parameter.schema.example
88
+ }
89
+ }
90
+ return doc
91
+ }
92
+
93
+ /**
94
+ * A parameter the operation doesn't declare is an error, not a no-op: `?since=` on a route without it would otherwise
95
+ * answer 200 as if it had filtered. The allowed names come from each route's own query schema, read once.
96
+ */
97
+ const rejectUnknownQuery = (app: OpenAPIHono): MiddlewareHandler => {
98
+ let routes: { method: string; pattern: RegExp; allowed: Set<string> }[] | null = null
99
+ return async (c, next) => {
100
+ routes ??= app.openAPIRegistry.definitions.flatMap((definition) => {
101
+ if (definition.type !== 'route') return []
102
+ const { method, path, request } = definition.route as { method: string; path: string; request?: { query?: { shape?: Record<string, unknown> } } }
103
+ return [{ method: method.toUpperCase(), pattern: new RegExp(`^${path.replace(/\{[^}]+\}/g, '[^/]+')}$`), allowed: new Set(Object.keys(request?.query?.shape ?? {})) }]
104
+ })
105
+ const route = routes.find((candidate) => candidate.method === c.req.method && candidate.pattern.test(c.req.path))
106
+ const unknown = route ? Object.keys(c.req.queries()).filter((name) => !route.allowed.has(name)) : []
107
+ if (!unknown.length) return next()
108
+ const allowed = route!.allowed.size ? `Allowed here: ${[...route!.allowed].join(', ')}.` : 'This operation takes no query parameters.'
109
+ return problemResponse(c, 'bad_request', { detail: `Unknown query parameter${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')}. ${allowed}`, errors: unknown.map((name) => ({ in: 'query' as const, path: name, message: 'not a parameter of this operation' })) })
110
+ }
111
+ }
112
+
113
+ /** The pages an agent lands on first; each carries the RFC 8288 pointers, as does every 401. */
114
+ const LINKED = new Set(['/', '/api', WELL_KNOWN.health, WELL_KNOWN.docs, WELL_KNOWN.guide])
115
+ const linkHeaders: MiddlewareHandler = async (c, next) => {
116
+ await next()
117
+ if (LINKED.has(c.req.path) || c.res.status === 401) c.res.headers.append('link', LINK_HEADER)
118
+ }
119
+
120
+ /** `Accept: text/markdown` on `/` or `/docs` gets the agent guide (llms.txt) instead of HTML; browsers still get HTML. */
121
+ const markdownForAgents = (app: { request: (input: string, init?: RequestInit) => Response | Promise<Response> }): MiddlewareHandler => async (c, next) => {
122
+ if (c.req.method !== 'GET' || (c.req.path !== '/' && c.req.path !== WELL_KNOWN.docs) || !wantsMarkdown(c.req.header('accept'))) {
123
+ await next()
124
+ if (c.req.path === '/' || c.req.path === WELL_KNOWN.docs) c.res.headers.append('vary', 'Accept')
125
+ return
126
+ }
127
+ const guide = await app.request(WELL_KNOWN.guide, { headers: { 'x-forwarded-proto': new URL(originOf(c)).protocol.slice(0, -1), 'x-forwarded-host': new URL(originOf(c)).host } })
128
+ if (!guide.ok) return next()
129
+ const text = await guide.text()
130
+ return c.body(text, 200, { 'content-type': MARKDOWN_TYPE, 'x-markdown-tokens': String(markdownTokens(text)), vary: 'Accept', 'cache-control': CACHE.docs })
131
+ }
132
+
133
+ export const createAgentApi = (config: AgentApiConfig) => {
134
+ const app = new OpenAPIHono({ defaultHook: validationHook as never })
135
+ const home = config.home ?? 'docs'
136
+ // Discovery is public: what exists and how to reach it, never the data
137
+ const discovery = ['/robots.txt', '/sitemap.xml', '/.well-known/*', '*/server-card', ...(home === 'docs' ? ['/'] : [])]
138
+ const open = config.private
139
+ ? [...PRIVATE_OPEN, ...(config.oauth ? PRIVATE_OAUTH_OPEN : []), ...(config.auth.open ?? [])]
140
+ : ['/api/health', '/openapi.json', '/docs', '/llms.txt', '/llms-full.txt', '/agents/*', '/skills/*', '/problems/*', ...discovery, ...(config.oauth ? OAUTH_OPEN_PATHS : []), ...(config.auth.open ?? [])]
141
+
142
+ app.use('*', requestId({ headerName: 'x-request-id' }))
143
+ app.use('*', secureHeaders({ crossOriginResourcePolicy: false, contentSecurityPolicy: undefined }))
144
+ // Same-site unless a route says otherwise (a sandboxed page's own files must load from its opaque origin)
145
+ app.use('*', async (c, next) => {
146
+ await next()
147
+ if (!c.res.headers.has('cross-origin-resource-policy')) c.res.headers.set('cross-origin-resource-policy', 'same-site')
148
+ })
149
+ if (config.corsOrigins?.length) app.use('*', cors({ origin: config.corsOrigins, exposeHeaders: ['etag', 'link', 'retry-after', 'x-request-id'] }))
150
+ // Before auth, so a 401 also tells an agent where the catalog and docs are
151
+ if (config.private)
152
+ app.use('*', async (c, next) => {
153
+ await next()
154
+ c.res.headers.set('x-robots-tag', NOINDEX)
155
+ // Anything readable only with credentials must never sit in a shared cache (Cloudflare keeps .txt and .js by extension)
156
+ const cache = c.res.headers.get('cache-control')
157
+ if (cache?.includes('public') && c.req.path !== '/robots.txt') c.res.headers.set('cache-control', cache.replace('public', 'private'))
158
+ })
159
+ else {
160
+ app.use('*', linkHeaders)
161
+ app.use('*', markdownForAgents(app))
162
+ }
163
+ app.use('*', authenticate({ relay: ['/mcp', '/a2a'], ...config.auth, open, ...(config.oauth ? { tokens: config.oauth.verify, resourceMetadata: config.oauth.resourceMetadataFor } : {}) }) as MiddlewareHandler)
164
+ app.use('*', cacheControl({ '/openapi.json': CACHE.docs, '/problems/': CACHE.docs, '/api/': CACHE.api }))
165
+ // Resources under /api are HAL; Hono writes plain JSON, so name the media type once here
166
+ app.use('/api/*', async (c, next) => {
167
+ await next()
168
+ if (c.res.status < 300 && c.req.path !== '/api/health' && c.res.headers.get('content-type')?.startsWith('application/json')) c.res.headers.set('content-type', HAL_JSON)
169
+ })
170
+ app.use('*', rejectUnknownQuery(app))
171
+ // After auth: a retried POST is recognised per caller
172
+ app.use('*', idempotency({ store: config.guards?.store }))
173
+ app.onError(errorHandler)
174
+ app.notFound(notFoundHandler)
175
+
176
+ app.openAPIRegistry.registerComponent('securitySchemes', SECURITY_SCHEME, {
177
+ type: 'http',
178
+ scheme: 'bearer',
179
+ description: 'An API key per client: `Authorization: Bearer <key>`. People reach the same routes through the single sign-on in front of the site.',
180
+ })
181
+ if (config.oauth) {
182
+ app.openAPIRegistry.registerComponent('securitySchemes', 'oauth', {
183
+ type: 'oauth2',
184
+ description: 'An OAuth token the owner approved: authorization code with PKCE (clients register at `/oauth/register`), or agent registration as `/auth.md` describes.',
185
+ flows: { authorizationCode: { authorizationUrl: '/oauth/authorize', tokenUrl: '/oauth/token', refreshUrl: '/oauth/token', scopes: Object.fromEntries(SCOPES.map((scope) => [scope, scope === 'read' ? 'Read everything' : 'Make changes (boards)'])) } },
186
+ })
187
+ config.oauth.mount(app)
188
+ }
189
+
190
+ app.openapi(
191
+ createRoute({
192
+ method: 'get',
193
+ path: '/api/health',
194
+ operationId: 'getHealth',
195
+ summary: 'Health check',
196
+ description: 'Answers without a key: `ok` while the service can serve, with the version it runs.',
197
+ tags: ['Meta'],
198
+ security: [],
199
+ responses: {
200
+ 200: { description: 'The service is up.', content: { 'application/json': { schema: z.object({ status: z.literal('ok'), version: z.string() }).catchall(z.unknown()).openapi('Health', { description: 'Up, with the version it runs and what it serves.' }) } } },
201
+ ...problemResponses(503),
202
+ },
203
+ }),
204
+ (c) => c.json({ status: 'ok' as const, version: config.version, ...(config.private ? {} : config.health?.()) }, 200),
205
+ )
206
+
207
+ app.get('/problems/:code', (c) => {
208
+ const code = c.req.param('code') as ProblemCode
209
+ if (!(code in PROBLEMS)) return problemResponse(c, 'not_found', { detail: `No problem type "${code}". All of them: ${Object.keys(PROBLEMS).join(', ')}.` })
210
+ const problem = PROBLEMS[code]
211
+ return c.json({ code, status: problem.status, title: problem.title, description: problem.description })
212
+ })
213
+
214
+ // The generator leaves a parameter's description and example inside its schema; tools and agents read them on the
215
+ // parameter, so lift them there
216
+ app.get('/openapi.json', (c) => {
217
+ const doc = app.getOpenAPI31Document({
218
+ openapi: '3.1.0',
219
+ info: { title: config.title, version: config.version, description: config.description },
220
+ servers: [{ url: originOf(c), description: 'This server' }],
221
+ security: [{ [SECURITY_SCHEME]: [] }, ...(config.oauth ? [{ oauth: [...SCOPES] }] : [])],
222
+ tags: [...config.tags, { name: 'Meta', description: 'Health and errors.' }],
223
+ externalDocs: { url: `${originOf(c)}/llms.txt`, description: 'Agent guide: concepts, every endpoint with examples, trust and limits, and task playbooks.' },
224
+ }) as unknown as OpenApiDocument
225
+ return c.json({ ...hoistParameterDocs(doc), ...(config.tagGroups ? { 'x-tagGroups': [...config.tagGroups, { name: 'Meta', tags: ['Meta'] }] } : {}) })
226
+ })
227
+ app.get('/docs', Scalar({ url: '/openapi.json', pageTitle: `${config.title} — API`, hideClientButton: false, defaultOpenAllTags: false }))
228
+
229
+ app.get(WELL_KNOWN.apiCatalog, (c) => sendPublic(c, JSON.stringify(apiCatalog(originOf(c)), null, 2), API_CATALOG_TYPE))
230
+ app.get('/robots.txt', (c) => sendPublic(c, robotsTxt(config.private ? { private: true } : (config.discovery ?? {}), originOf(c)), 'text/plain; charset=utf-8'))
231
+ const { sitemap, poppy } = config.private ? {} : (config.discovery ?? {})
232
+ if (sitemap?.length) app.get('/sitemap.xml', (c) => sendPublic(c, sitemapXml(originOf(c), sitemap), 'application/xml; charset=utf-8'))
233
+ if (poppy) app.get('/.well-known/poppy.json', (c) => sendPublic(c, JSON.stringify(poppyJson(originOf(c), poppy, Boolean(config.oauth)), null, 2), 'application/json'))
234
+ if (home === 'docs') app.get('/', (c) => c.redirect('/docs', 302))
235
+
236
+ return app
237
+ }
238
+
239
+ export type AgentApi = ReturnType<typeof createAgentApi>
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Who may call: agents and scripts with an API key (`Authorization: Bearer <key>`), people through Cloudflare Access
3
+ * (its signed `Cf-Access-Jwt-Assertion` header, checked against the team's keys so a misrouted request without Access
4
+ * in front is still refused). A browser app on the same host also sends Access's `CF_Authorization` cookie, the same
5
+ * signed token, on paths that bypass the gate; it is accepted too, and writes made with it must come from the same
6
+ * site (`Sec-Fetch-Site` / `Origin`), so another site can't post through someone's login. The service only ever holds key *hashes* (`API_KEYS=name:sha256,…`); the keys themselves
7
+ * live in your secret store, one per client, so revoking one is removing its line.
8
+ *
9
+ * @example
10
+ * const { key, hash } = mintApiKey('cli') // once per client: key → the secret store, `laptop:<hash>` → API_KEYS
11
+ * app.use('*', authenticate({ realm: 'cookbook', keys: parseApiKeys(process.env.API_KEYS), access: { team: 'example', audience: process.env.ACCESS_AUD }, open: ['/llms.txt', '/api/health'] }))
12
+ * c.get('caller') // { kind: 'key', name: 'laptop' } or { kind: 'person', email: '…' }
13
+ */
14
+ import { createHash, randomBytes } from 'node:crypto'
15
+ import type { Context, Hono, MiddlewareHandler } from 'hono'
16
+ import { getCookie } from 'hono/cookie'
17
+ import { createRemoteJWKSet, jwtVerify } from 'jose'
18
+ import { problemResponse } from './problem.js'
19
+
20
+ /** What an OAuth token can do: `read` for safe methods, `write` for the rest. */
21
+ export const SCOPES = ['read', 'write'] as const
22
+
23
+ /** The paths an app with an OAuth server must leave open to anonymous callers: discovery, registration, the token endpoints, auth.md. */
24
+ export const OAUTH_OPEN_PATHS = ['/.well-known/*', '/auth.md', '/oauth/*', '/agent/identity', '/agent/identity/claim']
25
+
26
+ /** What `createAgentApi` needs from an OAuth server (`createOAuth`, the `oauth` module): mount it, check its tokens, name its metadata. */
27
+ export type OAuthServer = {
28
+ mount: (app: Hono<any>) => void
29
+ verify: NonNullable<AuthOptions['tokens']>
30
+ resourceMetadataFor: (c: Context) => string
31
+ }
32
+
33
+ export type Caller = { kind: 'key'; name: string } | { kind: 'person'; email: string } | { kind: 'agent'; name: string; scopes: string[]; owner: string }
34
+
35
+ export const hashApiKey = (key: string) => createHash('sha256').update(key).digest('hex')
36
+
37
+ /** A new key, `<prefix>_` + 192 random bits in base64url, and the hash the service stores. */
38
+ export const mintApiKey = (prefix: string) => {
39
+ const key = `${prefix}_${randomBytes(24).toString('base64url')}`
40
+ return { key, hash: hashApiKey(key) }
41
+ }
42
+
43
+ /** `laptop:<sha256>,ci:<sha256>` → hash → client name. Malformed entries are refused loudly at boot. */
44
+ export const parseApiKeys = (value: string | undefined) => {
45
+ const keys = new Map<string, string>()
46
+ for (const entry of (value ?? '').split(',').map((part) => part.trim()).filter(Boolean)) {
47
+ const [name, hash] = entry.split(':')
48
+ if (!name || !hash || !/^[0-9a-f]{64}$/.test(hash)) throw new Error(`API_KEYS entry "${name ?? entry}" is not name:sha256`)
49
+ keys.set(hash, name)
50
+ }
51
+ return keys
52
+ }
53
+
54
+ const SAFE = new Set(['GET', 'HEAD', 'OPTIONS'])
55
+
56
+ /** A write sent with a cookie must come from this host: browsers say so in `Sec-Fetch-Site`, older ones in `Origin`. */
57
+ const sameSite = (c: Context) => {
58
+ const site = c.req.header('sec-fetch-site')
59
+ if (site) return site === 'same-origin'
60
+ const origin = c.req.header('origin')
61
+ return Boolean(origin) && new URL(origin!).host === new URL(c.req.url).host
62
+ }
63
+
64
+ const bearerOf = (c: Context) => {
65
+ const header = c.req.header('authorization') ?? ''
66
+ return /^bearer\s+/i.test(header) ? header.replace(/^bearer\s+/i, '').trim() : undefined
67
+ }
68
+
69
+ /** Checks a Cloudflare Access token against the team's published keys (cached by jose). */
70
+ export const accessVerifier = ({ team, audience }: { team: string; audience: string }) => {
71
+ const issuer = `https://${team}.cloudflareaccess.com`
72
+ const keys = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`))
73
+ return async (token: string) => {
74
+ const { payload } = await jwtVerify(token, keys, { issuer, audience })
75
+ return typeof payload.email === 'string' ? payload.email : null
76
+ }
77
+ }
78
+
79
+ /** The signed-in person on a page behind Access (header or cookie), for OAuth's approval pages: their email, or null. */
80
+ export const accessOwner = (access: AccessOptions) => {
81
+ const verify = access.verify ?? accessVerifier(access)
82
+ return async (c: Context) => {
83
+ const assertion = c.req.header('cf-access-jwt-assertion') ?? getCookie(c, 'CF_Authorization')
84
+ return assertion ? verify(assertion).catch(() => null) : null
85
+ }
86
+ }
87
+
88
+ // `/llms.txt` matches itself, `/skills/x.md` matches `/skills/*`, and a leading star matches a suffix (`/mcp/server-card`)
89
+ const matches = (path: string, pattern: string) =>
90
+ pattern.endsWith('*') ? path.startsWith(pattern.slice(0, -1)) : pattern.startsWith('*') ? path.endsWith(pattern.slice(1)) : path === pattern
91
+
92
+ export type AccessOptions = { team: string; audience: string; /** Replaces the JWKS check, for tests. */ verify?: (token: string) => Promise<string | null> }
93
+
94
+ export type AuthOptions = {
95
+ realm: string
96
+ keys: Map<string, string>
97
+ access?: AccessOptions
98
+ open?: string[]
99
+ /** OAuth access tokens (`oauth.ts`): a JWT → its caller, or null. Keys still work beside them. */
100
+ tokens?: (jwt: string, c: Context) => Promise<{ kind: 'agent'; name: string; scopes: string[]; owner: string } | null>
101
+ /** Where a 401 points (`WWW-Authenticate: … resource_metadata=`): a URL, or one per request. */
102
+ resourceMetadata?: string | ((c: Context) => string)
103
+ /** Endpoints that POST but only relay to routes in-process (MCP, A2A): a token needs just `read` there, each route checks its own scope. */
104
+ relay?: string[]
105
+ }
106
+
107
+ const isJwt = (value: string) => /^[\w-]+\.[\w-]+\.[\w-]+$/.test(value)
108
+
109
+ export const authenticate = ({ realm, keys, access, open = [], tokens, resourceMetadata, relay = [] }: AuthOptions): MiddlewareHandler => {
110
+ const verifyAccess = access?.verify ?? (access?.audience ? accessVerifier(access) : null)
111
+ return async (c, next) => {
112
+ if (c.req.method === 'OPTIONS' || open.some((pattern) => matches(c.req.path, pattern))) return next()
113
+ const key = bearerOf(c)
114
+ const name = key ? keys.get(hashApiKey(key)) : undefined
115
+ if (name) {
116
+ c.set('caller' as never, { kind: 'key', name } satisfies Caller as never)
117
+ return next()
118
+ }
119
+ const agent = key && tokens && isJwt(key) ? await tokens(key, c) : null
120
+ if (agent) {
121
+ // read covers what an agent can look at; anything that changes state needs write
122
+ const needed = SAFE.has(c.req.method) || relay.includes(c.req.path) ? 'read' : 'write'
123
+ if (!agent.scopes.includes(needed)) return problemResponse(c, 'forbidden', { detail: `This token lacks the ${needed} scope: authorize again asking for it.`, headers: { 'www-authenticate': `Bearer realm="${realm}", error="insufficient_scope", scope="${needed}"` } })
124
+ c.set('caller' as never, agent satisfies Caller as never)
125
+ return next()
126
+ }
127
+ const header = c.req.header('cf-access-jwt-assertion')
128
+ const cookie = header ? undefined : getCookie(c, 'CF_Authorization')
129
+ const assertion = header ?? cookie
130
+ if (!key && assertion && verifyAccess) {
131
+ const email = await verifyAccess(assertion).catch(() => null)
132
+ if (email && cookie && !SAFE.has(c.req.method) && !sameSite(c))
133
+ return problemResponse(c, 'forbidden', { detail: 'A write signed in with the Access cookie must come from this site.' })
134
+ if (email) {
135
+ c.set('caller' as never, { kind: 'person', email } satisfies Caller as never)
136
+ return next()
137
+ }
138
+ }
139
+ const metadata = typeof resourceMetadata === 'function' ? resourceMetadata(c) : resourceMetadata
140
+ const challenge = [`Bearer realm="${realm}"`, ...(key ? ['error="invalid_token"'] : []), ...(metadata ? [`resource_metadata="${metadata}"`] : [])].join(', ')
141
+ const howToGetOne = tokens ? ' Or get a token yourself: OAuth from the resource metadata in WWW-Authenticate, or /auth.md.' : ''
142
+ return problemResponse(c, 'unauthorized', {
143
+ detail: key ? `This token or API key is not valid (expired, revoked, mistyped, or for another service).${howToGetOne}` : `Send an API key: \`Authorization: Bearer <key>\`. Keys are issued per client; ask the owner for one.${howToGetOne}`,
144
+ headers: { 'www-authenticate': challenge },
145
+ })
146
+ }
147
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Caching for read-mostly data. An API over a snapshot changes only when the snapshot or the code serving it does, so
3
+ * a GET's ETag is the snapshot version, this process's start and the URL: a client that sends it back in
4
+ * `If-None-Match` gets `304 Not Modified` without the route running, and a deploy never leaves it on an old answer. `Cache-Control` says how long each kind of response may be kept; errors are never stored.
5
+ *
6
+ * @example
7
+ * app.use('/api/*', snapshotEtag(() => store.version, { skip: (path) => path.startsWith('/api/v1/boards') }))
8
+ * app.use('*', cacheControl({ '/media/': CACHE.media, '/api/': CACHE.api }))
9
+ */
10
+ import { createHash, randomUUID } from 'node:crypto'
11
+ import type { MiddlewareHandler } from 'hono'
12
+
13
+ export const CACHE = {
14
+ /** Revalidate every time (cheap with ETags), keep out of shared caches. */
15
+ api: 'private, no-cache',
16
+ /** Images and videos never change under the same URL. */
17
+ media: 'private, max-age=86400, immutable',
18
+ /** Guides and the OpenAPI document. */
19
+ docs: 'private, max-age=300',
20
+ error: 'no-store',
21
+ } as const
22
+
23
+ /**
24
+ * Changes on every start, so answers a code change altered are never confirmed from before it. Made on first use:
25
+ * Workers refuse random values at global scope.
26
+ */
27
+ let started: string | undefined
28
+ const startId = () => (started ??= randomUUID())
29
+
30
+ /**
31
+ * `boot` names the deploy instead of the process: on Workers pass the version id (`env.CF_VERSION_METADATA.id`), so
32
+ * every isolate of one deploy agrees on the ETag and a new deploy still changes it.
33
+ */
34
+ export const snapshotEtag = (versionOf: () => string | undefined, { skip = () => false, boot }: { skip?: (path: string) => boolean; boot?: string } = {}): MiddlewareHandler =>
35
+ async (c, next) => {
36
+ const version = versionOf()
37
+ if ((c.req.method !== 'GET' && c.req.method !== 'HEAD') || !version || skip(c.req.path)) return next()
38
+ const tag = `W/"${createHash('sha256').update(`${version}|${boot ?? startId()}|${c.req.url}|${c.req.header('accept') ?? ''}`).digest('base64url').slice(0, 27)}"`
39
+ const sent = c.req.header('if-none-match')?.split(',').map((value) => value.trim())
40
+ if (sent?.includes(tag) || sent?.includes('*')) return c.body(null, 304, { etag: tag })
41
+ await next()
42
+ if (c.res.status === 200 && !c.res.headers.has('etag')) c.res.headers.set('etag', tag)
43
+ }
44
+
45
+ /** The first matching path prefix sets `Cache-Control` on success; any error is `no-store`. */
46
+ export const cacheControl = (byPrefix: Record<string, string>): MiddlewareHandler => async (c, next) => {
47
+ await next()
48
+ if (c.res.status >= 400) c.res.headers.set('cache-control', CACHE.error)
49
+ else if (!c.res.headers.has('cache-control')) {
50
+ const match = Object.entries(byPrefix).find(([prefix]) => c.req.path.startsWith(prefix))
51
+ if (match) c.res.headers.set('cache-control', match[1])
52
+ }
53
+ }