@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.
- package/LICENSE +21 -0
- package/README.md +142 -2
- package/cli.mjs +438 -0
- package/docs/STANDARD.md +342 -0
- package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
- package/docs/decisions/0002-node-and-workers.md +45 -0
- package/docs/releasing.md +97 -0
- package/package.json +59 -3
- package/registry.json +548 -0
- package/src/a2a/README.md +25 -0
- package/src/a2a/index.ts +262 -0
- package/src/boards/README.md +35 -0
- package/src/boards/handlers.ts +87 -0
- package/src/boards/index.ts +64 -0
- package/src/boards/refs.ts +23 -0
- package/src/boards/repo.ts +50 -0
- package/src/boards/routes.ts +83 -0
- package/src/boards/schema.ts +28 -0
- package/src/boards/tools.ts +76 -0
- package/src/browser/README.md +31 -0
- package/src/browser/boards-client.ts +74 -0
- package/src/browser/boards-react.ts +73 -0
- package/src/browser/search-client.ts +258 -0
- package/src/browser/search-react.ts +116 -0
- package/src/browser/vector-search-block.tsx +94 -0
- package/src/core/README.md +37 -0
- package/src/core/api.ts +239 -0
- package/src/core/auth.ts +147 -0
- package/src/core/cache.ts +53 -0
- package/src/core/discovery.ts +315 -0
- package/src/core/guards.ts +109 -0
- package/src/core/hal.ts +63 -0
- package/src/core/kv.ts +29 -0
- package/src/core/problem.ts +124 -0
- package/src/db/README.md +39 -0
- package/src/db/guards.ts +61 -0
- package/src/db/index.ts +122 -0
- package/src/guide/README.md +38 -0
- package/src/guide/guide.ts +152 -0
- package/src/guide/reference.ts +204 -0
- package/src/index.ts +15 -0
- package/src/mcp/README.md +34 -0
- package/src/mcp/index.ts +251 -0
- package/src/oauth/README.md +29 -0
- package/src/oauth/index.ts +439 -0
- package/src/oauth/schema.ts +7 -0
- package/src/oauth/store.ts +34 -0
- package/src/pagination/README.md +31 -0
- package/src/pagination/index.ts +65 -0
- package/src/testing/README.md +31 -0
- package/src/testing/index.ts +162 -0
- package/src/vector-search/README.md +27 -0
- package/src/vector-search/index.ts +367 -0
- package/src/vectors/README.md +30 -0
- package/src/vectors/index.ts +326 -0
- package/src/web/README.md +21 -0
- package/src/web/index.ts +100 -0
- package/templates/app/AGENTS.md +41 -0
- package/templates/app/CLAUDE.md +3 -0
- package/templates/app/Dockerfile +12 -0
- package/templates/app/container/entrypoint.sh +7 -0
- package/templates/app/gitignore +4 -0
- package/templates/app/guide/llms.txt +11 -0
- package/templates/app/pnpm-workspace.yaml +5 -0
- package/templates/app/scripts/guide.ts +8 -0
- package/templates/app/src/app.ts +39 -0
- package/templates/app/src/features/index.ts +30 -0
- package/templates/app/src/server.ts +14 -0
- package/templates/app/test/api.test.ts +34 -0
- package/templates/app/tsconfig.json +16 -0
- package/templates/feature/feature.test.ts +19 -0
- package/templates/feature/handlers.ts +43 -0
- package/templates/feature/index.ts +34 -0
- package/templates/feature/repo.ts +24 -0
- package/templates/feature/routes.ts +43 -0
- package/templates/feature/schema.ts +14 -0
- package/templates/feature/tools.ts +46 -0
- package/templates/npm-release/release-please-config.json +60 -0
- package/templates/npm-release/release-please-manifest.json +3 -0
- package/templates/runtime-cloudflare/dev.vars.example +3 -0
- package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
- package/templates/runtime-cloudflare/src/worker.ts +19 -0
- package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
- package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
- package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
- package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
- package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
- package/templates/runtime-cloudflare/vitest.config.ts +23 -0
- package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How agents find an API before they hold a key: public, read-only metadata the kit serves from what the app already
|
|
3
|
+
* has, so none of it can drift from the real routes.
|
|
4
|
+
* /.well-known/api-catalog RFC 9727 linkset: the OpenAPI document, docs, guide and health
|
|
5
|
+
* Link headers RFC 8288 on `/`, `/api`, health, the docs and every 401: the same pointers
|
|
6
|
+
* /robots.txt Content Signals (ai-train, search, ai-input) and whether to crawl at all
|
|
7
|
+
* Accept: text/markdown `/` and `/docs` answer with the agent guide (llms.txt) instead of HTML
|
|
8
|
+
* /.well-known/agent-skills/… the skills index (Agent Skills Discovery v0.2.0) and one archive per skill
|
|
9
|
+
* <mcp>/server-card the MCP Server Card (SEP-2127), also at /.well-known/mcp/server-card.json
|
|
10
|
+
* /.well-known/ai-catalog.json an AI Catalog listing all of the above (ARD)
|
|
11
|
+
* /sitemap.xml the public pages (`discovery.sitemap`), named in robots.txt
|
|
12
|
+
* /.well-known/poppy.json Personal Agent Protocol discovery (`discovery.poppy`): the APIs and the issuer
|
|
13
|
+
* `createAgentApi`, `serveGuide` and `mountMcp` wire their parts themselves; an app adds `serveAiCatalog` last. OAuth
|
|
14
|
+
* metadata comes with `oauth.ts` when the app is its own authorization server.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const app = createAgentApi({ …, discovery: { contentSignal: { aiTrain: false, search: false, aiInput: false } } })
|
|
18
|
+
* serveGuide(app, { dir, vars, full }) // skills index + archives
|
|
19
|
+
* mountMcp(app, { factory, allowedHostnames }) // server card
|
|
20
|
+
* serveAiCatalog(app, { host: 'Cookbook', queries: { api: ['find a quick dinner recipe'], mcp: ['…'] } })
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { gzipSync } from 'node:zlib'
|
|
24
|
+
import type { Context, Hono } from 'hono'
|
|
25
|
+
import { originOf } from './hal.js'
|
|
26
|
+
|
|
27
|
+
/** What a discovery document links to; the kit serves each of these. */
|
|
28
|
+
export const WELL_KNOWN = {
|
|
29
|
+
apiCatalog: '/.well-known/api-catalog',
|
|
30
|
+
skillsIndex: '/.well-known/agent-skills/index.json',
|
|
31
|
+
serverCard: '/.well-known/mcp/server-card.json',
|
|
32
|
+
aiCatalog: '/.well-known/ai-catalog.json',
|
|
33
|
+
agentCard: '/.well-known/agent-card.json',
|
|
34
|
+
openapi: '/openapi.json',
|
|
35
|
+
docs: '/docs',
|
|
36
|
+
guide: '/llms.txt',
|
|
37
|
+
health: '/api/health',
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
export const OPENAPI_TYPE = 'application/vnd.oai.openapi+json;version=3.1'
|
|
41
|
+
export const MARKDOWN_TYPE = 'text/markdown; charset=utf-8'
|
|
42
|
+
|
|
43
|
+
// ---- Shared response rules ----
|
|
44
|
+
|
|
45
|
+
const PUBLIC_CACHE = 'public, max-age=3600'
|
|
46
|
+
const CORS = {
|
|
47
|
+
'access-control-allow-origin': '*',
|
|
48
|
+
'access-control-allow-methods': 'GET, HEAD',
|
|
49
|
+
'access-control-allow-headers': 'Content-Type, If-None-Match',
|
|
50
|
+
'access-control-expose-headers': 'ETag',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A public metadata document: open CORS, an hour of caching, a strong ETag and `304` on `If-None-Match` (what the
|
|
57
|
+
* Server Card and skills specs ask for; harmless for the rest).
|
|
58
|
+
*/
|
|
59
|
+
export const sendPublic = (c: Context, body: string | Uint8Array, type: string) => {
|
|
60
|
+
const etag = `"${sha256(body).slice(0, 32)}"`
|
|
61
|
+
const headers = { 'content-type': type, 'cache-control': PUBLIC_CACHE, etag, ...CORS }
|
|
62
|
+
const sent = c.req.header('if-none-match')?.split(',').map((value) => value.trim())
|
|
63
|
+
if (sent?.includes(etag) || sent?.includes('*')) return c.body(null, 304, headers)
|
|
64
|
+
return c.body(typeof body === 'string' ? body : (body as Uint8Array<ArrayBuffer>), 200, headers)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- RFC 9727 API catalog and RFC 8288 Link headers ----
|
|
68
|
+
|
|
69
|
+
/** The API catalog for one API rooted at `<origin>/api`: its description, docs and status. */
|
|
70
|
+
export const apiCatalog = (origin: string) => ({
|
|
71
|
+
linkset: [
|
|
72
|
+
{
|
|
73
|
+
anchor: `${origin}/api`,
|
|
74
|
+
'service-desc': [{ href: origin + WELL_KNOWN.openapi, type: OPENAPI_TYPE }],
|
|
75
|
+
'service-doc': [
|
|
76
|
+
{ href: origin + WELL_KNOWN.docs, type: 'text/html' },
|
|
77
|
+
{ href: origin + WELL_KNOWN.guide, type: 'text/markdown' },
|
|
78
|
+
],
|
|
79
|
+
status: [{ href: origin + WELL_KNOWN.health, type: 'application/json' }],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export const API_CATALOG_TYPE = 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'
|
|
85
|
+
|
|
86
|
+
/** The `Link` header value pointing at the catalog and what it lists (relative references, valid per RFC 8288). */
|
|
87
|
+
export const LINK_HEADER = [
|
|
88
|
+
`<${WELL_KNOWN.apiCatalog}>; rel="api-catalog"`,
|
|
89
|
+
`<${WELL_KNOWN.openapi}>; rel="service-desc"; type="${OPENAPI_TYPE}"`,
|
|
90
|
+
`<${WELL_KNOWN.docs}>; rel="service-doc"; type="text/html"`,
|
|
91
|
+
`<${WELL_KNOWN.guide}>; rel="describedby"; type="text/markdown"`,
|
|
92
|
+
].join(', ')
|
|
93
|
+
|
|
94
|
+
// ---- robots.txt with Content Signals ----
|
|
95
|
+
|
|
96
|
+
export type DiscoveryConfig = {
|
|
97
|
+
/**
|
|
98
|
+
* Content Signals (contentsignals.org): may crawlers train on this, index it for search, use it as input to AI
|
|
99
|
+
* answers. Defaults to no, no, no: private by default.
|
|
100
|
+
*/
|
|
101
|
+
contentSignal?: { aiTrain?: boolean; search?: boolean; aiInput?: boolean }
|
|
102
|
+
/** Whether crawlers may walk the site at all (`Disallow: /` when false, the default). */
|
|
103
|
+
crawl?: boolean
|
|
104
|
+
/** Public paths for `/sitemap.xml` (and its `Sitemap:` line in robots.txt). */
|
|
105
|
+
sitemap?: string[]
|
|
106
|
+
/** Personal Agent Protocol discovery: the organization's name and what it offers agents, as paths on this origin. */
|
|
107
|
+
poppy?: { name: string; apis?: { type: string; path: string; description: string }[]; protocols?: { type: string; path: string }[]; scopes?: Record<string, { description: string; changes: 'none' | 'reversible' | 'irreversible'; personalData: boolean }> }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const robotsTxt = ({ contentSignal = {}, crawl = false, sitemap, private: hidden }: DiscoveryConfig & { private?: boolean }, origin: string) => {
|
|
111
|
+
const yesNo = (value: boolean | undefined) => (value ? 'yes' : 'no')
|
|
112
|
+
// A private service names nothing: no guide, no catalog, no sitemap
|
|
113
|
+
if (hidden) return ['User-agent: *', 'Content-Signal: ai-train=no, search=no, ai-input=no', 'Disallow: /', ''].join('\n')
|
|
114
|
+
return [
|
|
115
|
+
'User-agent: *',
|
|
116
|
+
`Content-Signal: ai-train=${yesNo(contentSignal.aiTrain)}, search=${yesNo(contentSignal.search)}, ai-input=${yesNo(contentSignal.aiInput)}`,
|
|
117
|
+
crawl ? 'Allow: /' : 'Disallow: /',
|
|
118
|
+
'',
|
|
119
|
+
`# Agents: the guide is ${origin}${WELL_KNOWN.guide}, the catalog ${origin}${WELL_KNOWN.aiCatalog}`,
|
|
120
|
+
`Agentmap: ${origin}${WELL_KNOWN.aiCatalog}`,
|
|
121
|
+
...(sitemap?.length ? [`Sitemap: ${origin}/sitemap.xml`] : []),
|
|
122
|
+
'',
|
|
123
|
+
].join('\n')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The sitemap of public pages (sitemaps.org). */
|
|
127
|
+
export const sitemapXml = (origin: string, paths: string[]) =>
|
|
128
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paths.map((path) => ` <url><loc>${origin}${path.replace(/&/g, '&')}</loc></url>`).join('\n')}\n</urlset>\n`
|
|
129
|
+
|
|
130
|
+
/** `/.well-known/poppy.json` (Personal Agent Protocol draft 0.3, section 3): this origin is the organization and the issuer. */
|
|
131
|
+
export const poppyJson = (origin: string, { name, apis = [], protocols = [], scopes }: NonNullable<DiscoveryConfig['poppy']>, withIssuer: boolean) => ({
|
|
132
|
+
protocol_version: '0.3',
|
|
133
|
+
organization: { name, domain: new URL(origin).hostname },
|
|
134
|
+
...(withIssuer
|
|
135
|
+
? {
|
|
136
|
+
auth: {
|
|
137
|
+
issuer: origin,
|
|
138
|
+
...(scopes
|
|
139
|
+
? {
|
|
140
|
+
scopes: Object.fromEntries(Object.entries(scopes).map(([scope, one]) => [scope, one.description])),
|
|
141
|
+
scope_hints: Object.fromEntries(Object.entries(scopes).map(([scope, one]) => [scope, { changes: one.changes, personal_data: one.personalData }])),
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
: {}),
|
|
147
|
+
...(protocols.length ? { agent: { protocols: protocols.map((one) => ({ type: one.type, endpoint: `${origin}${one.path}` })) } } : {}),
|
|
148
|
+
...(apis.length ? { apis: apis.map((one) => ({ type: one.type, url: `${origin}${one.path}`, description: one.description })) } : {}),
|
|
149
|
+
web: {},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/** Rough tokens for `x-markdown-tokens`: about four characters each. */
|
|
153
|
+
export const markdownTokens = (text: string) => Math.ceil(text.length / 4)
|
|
154
|
+
|
|
155
|
+
/** True when the client asked for Markdown ahead of HTML (`Accept: text/markdown`). */
|
|
156
|
+
export const wantsMarkdown = (accept: string | undefined) => {
|
|
157
|
+
const types = (accept ?? '').split(',').map((part) => part.split(';')[0]!.trim().toLowerCase())
|
|
158
|
+
const markdown = types.indexOf('text/markdown')
|
|
159
|
+
const html = types.indexOf('text/html')
|
|
160
|
+
return markdown >= 0 && (html < 0 || markdown < html)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Skills: index and deterministic archives ----
|
|
164
|
+
|
|
165
|
+
export const SKILLS_INDEX_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'
|
|
166
|
+
|
|
167
|
+
/** `name` and `description` from a SKILL.md's YAML frontmatter (single-line values, as the skills spec writes them). */
|
|
168
|
+
export const frontmatterOf = (markdown: string) => {
|
|
169
|
+
const block = markdown.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
|
|
170
|
+
const field = (key: string) => block.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim().replace(/^(['"])(.*)\1$/, '$2')
|
|
171
|
+
return { name: field('name'), description: field('description') }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A gzipped ustar archive of `files` (path → text) with fixed metadata, so the same files always give the same bytes
|
|
176
|
+
* and the same digest. SKILL.md comes first; the skill's files sit at the root, as the discovery spec asks.
|
|
177
|
+
*/
|
|
178
|
+
export const tarGz = (files: [string, string][]) => {
|
|
179
|
+
const blocks: Buffer[] = []
|
|
180
|
+
const octal = (value: number, width: number) => value.toString(8).padStart(width - 1, '0') + '\0'
|
|
181
|
+
// SKILL.md first, so a reader can stop after it; the rest in a fixed order
|
|
182
|
+
for (const [path, text] of [...files].sort(([a], [b]) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md') || a.localeCompare(b))) {
|
|
183
|
+
if (path.startsWith('/') || path.split('/').includes('..') || Buffer.byteLength(path) > 100) throw new Error(`Unsafe or too long archive path: ${path}`)
|
|
184
|
+
const content = Buffer.from(text, 'utf8')
|
|
185
|
+
const header = Buffer.alloc(512)
|
|
186
|
+
header.write(path, 0, 'utf8')
|
|
187
|
+
header.write(octal(0o644, 8), 100)
|
|
188
|
+
header.write(octal(0, 8), 108)
|
|
189
|
+
header.write(octal(0, 8), 116)
|
|
190
|
+
header.write(octal(content.length, 12), 124)
|
|
191
|
+
header.write(octal(0, 12), 136)
|
|
192
|
+
header.write(' ', 148)
|
|
193
|
+
header.write('0', 156)
|
|
194
|
+
header.write('ustar\0', 257)
|
|
195
|
+
header.write('00', 263)
|
|
196
|
+
let sum = 0
|
|
197
|
+
for (const byte of header) sum += byte
|
|
198
|
+
header.write(octal(sum, 7) + ' ', 148)
|
|
199
|
+
blocks.push(header, content, Buffer.alloc((512 - (content.length % 512)) % 512))
|
|
200
|
+
}
|
|
201
|
+
blocks.push(Buffer.alloc(1024))
|
|
202
|
+
return new Uint8Array(gzipSync(Buffer.concat(blocks), { level: 9 }))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type SkillArtifact = { name: string; description: string; type: 'skill-md' | 'archive'; url: string; bytes: Uint8Array | string; mediaType: string }
|
|
206
|
+
|
|
207
|
+
/** The index entry for each skill, its digest taken over the exact bytes its `url` serves. */
|
|
208
|
+
export const skillsIndex = (origin: string, skills: SkillArtifact[]) => ({
|
|
209
|
+
$schema: SKILLS_INDEX_SCHEMA,
|
|
210
|
+
skills: skills.map((skill) => ({ name: skill.name, type: skill.type, description: skill.description, url: origin + skill.url, digest: `sha256:${sha256(skill.bytes)}` })),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ---- MCP Server Card (SEP-2127) ----
|
|
214
|
+
|
|
215
|
+
export const SERVER_CARD_SCHEMA = 'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json'
|
|
216
|
+
export const SERVER_CARD_TYPE = 'application/mcp-server-card+json'
|
|
217
|
+
/** The 2026 revision the kit's servers speak; the SDK answers the 2025 ones through its fallback. */
|
|
218
|
+
export const MODERN_PROTOCOL_VERSION = '2026-07-28'
|
|
219
|
+
|
|
220
|
+
export type ServerCardInfo = { name: string; version: string; title?: string; description?: string; websiteUrl?: string }
|
|
221
|
+
|
|
222
|
+
/** `cookbook.example.com` + `cookbook` → `com.example.cookbook/cookbook`: the reverse-DNS name the card spec asks for. */
|
|
223
|
+
export const reverseDnsName = (origin: string, name: string) => `${new URL(origin).hostname.split('.').reverse().join('.')}/${name}`
|
|
224
|
+
|
|
225
|
+
export const serverCard = (origin: string, path: string, info: ServerCardInfo, protocolVersions: string[]) => ({
|
|
226
|
+
$schema: SERVER_CARD_SCHEMA,
|
|
227
|
+
name: reverseDnsName(origin, info.name),
|
|
228
|
+
version: info.version,
|
|
229
|
+
description: info.description ?? info.title ?? info.name,
|
|
230
|
+
...(info.title ? { title: info.title } : {}),
|
|
231
|
+
websiteUrl: info.websiteUrl ?? `${origin}${WELL_KNOWN.guide}`,
|
|
232
|
+
remotes: [
|
|
233
|
+
{
|
|
234
|
+
type: 'streamable-http' as const,
|
|
235
|
+
url: origin + path,
|
|
236
|
+
headers: [
|
|
237
|
+
{
|
|
238
|
+
name: 'Authorization',
|
|
239
|
+
description: 'An API key issued per client by the owner.',
|
|
240
|
+
isRequired: true,
|
|
241
|
+
isSecret: true,
|
|
242
|
+
value: 'Bearer {api_key}',
|
|
243
|
+
variables: { api_key: { description: 'Your API key.', isRequired: true, isSecret: true } },
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
supportedProtocolVersions: protocolVersions,
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// ---- What an app has mounted, for the catalogs ----
|
|
252
|
+
|
|
253
|
+
/** What the guide needs of an MCP tool: the kit's `McpTool` fits, so does any tool with a name, a description and a call. */
|
|
254
|
+
export type ToolLike = { name: string; description: string; call: (args: never) => { method?: string; path: string } }
|
|
255
|
+
/** What the guide needs of an MCP resource: its URI template and what it is. */
|
|
256
|
+
export type ResourceLike = { uriTemplate: string; title: string; description: string }
|
|
257
|
+
|
|
258
|
+
type Mounted = {
|
|
259
|
+
skills?: (origin: string) => SkillArtifact[]
|
|
260
|
+
/** Builds the skill artifacts for an origin (they are rendered per origin and per data snapshot). */
|
|
261
|
+
refreshSkills?: (origin: string, credentials?: Record<string, string>) => Promise<SkillArtifact[]>
|
|
262
|
+
mcp?: { path: string; card: (origin: string) => ReturnType<typeof serverCard>; tools: ToolLike[]; resources?: ResourceLike[] }
|
|
263
|
+
}
|
|
264
|
+
const mountedByApp = new WeakMap<object, Mounted>()
|
|
265
|
+
|
|
266
|
+
/** `serveGuide` and `mountMcp` record what they serve so `serveAiCatalog` lists exactly that. */
|
|
267
|
+
export const recordMounted = (app: object, part: Mounted) => mountedByApp.set(app, { ...mountedByApp.get(app), ...part })
|
|
268
|
+
export const mountedOf = (app: object) => mountedByApp.get(app) ?? {}
|
|
269
|
+
|
|
270
|
+
// ---- AI Catalog (ARD) ----
|
|
271
|
+
|
|
272
|
+
export const AI_CATALOG_TYPE = 'application/ai-catalog+json'
|
|
273
|
+
|
|
274
|
+
export type AiCatalogConfig = {
|
|
275
|
+
/** Who operates this, shown by registries. */
|
|
276
|
+
host: string
|
|
277
|
+
/** The publisher segment of `urn:air` identifiers; defaults to the request's host name. */
|
|
278
|
+
publisher?: string
|
|
279
|
+
/** 2–5 questions per artifact that registries embed to match it to what people ask. */
|
|
280
|
+
queries: { api: string[]; mcp?: string[]; skills?: Record<string, string[]> }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const aiCatalog = (origin: string, config: AiCatalogConfig, mounted: Mounted, apiTitle: string) => {
|
|
284
|
+
const publisher = config.publisher ?? new URL(origin).hostname
|
|
285
|
+
const urn = (namespace: string, name: string) => `urn:air:${publisher}:${namespace}:${name}`
|
|
286
|
+
const card = mounted.mcp?.card(origin)
|
|
287
|
+
return {
|
|
288
|
+
specVersion: '1.0',
|
|
289
|
+
host: { displayName: config.host, identifier: `did:web:${new URL(origin).hostname}`, documentationUrl: origin + WELL_KNOWN.guide },
|
|
290
|
+
entries: [
|
|
291
|
+
{ identifier: urn('api', 'openapi'), displayName: apiTitle, type: OPENAPI_TYPE, url: origin + WELL_KNOWN.openapi, representativeQueries: config.queries.api },
|
|
292
|
+
// ARD asks every entry for a displayName; the card's own title, so the two can't drift
|
|
293
|
+
...(card && mounted.mcp ? [{ identifier: urn('mcp', card.name.split('/').pop()!), displayName: card.title ?? card.name, type: SERVER_CARD_TYPE, url: `${origin}${mounted.mcp.path}/server-card`, representativeQueries: config.queries.mcp ?? config.queries.api }] : []),
|
|
294
|
+
...(mounted.skills?.(origin) ?? []).map((skill) => ({
|
|
295
|
+
identifier: urn('skill', skill.name),
|
|
296
|
+
displayName: skill.name,
|
|
297
|
+
description: skill.description,
|
|
298
|
+
type: skill.type === 'archive' ? 'application/agent-skills+gzip' : 'application/agent-skills+md',
|
|
299
|
+
url: origin + skill.url,
|
|
300
|
+
digest: `sha256:${sha256(skill.bytes)}`,
|
|
301
|
+
representativeQueries: config.queries.skills?.[skill.name] ?? config.queries.api,
|
|
302
|
+
})),
|
|
303
|
+
],
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Serves the AI Catalog over whatever the app mounted; call after `serveGuide` and `mountMcp`. */
|
|
308
|
+
export const serveAiCatalog = (app: Hono<any, any, any>, config: AiCatalogConfig & { apiTitle: string }) => {
|
|
309
|
+
app.get(WELL_KNOWN.aiCatalog, async (c) => {
|
|
310
|
+
const mounted = mountedOf(app)
|
|
311
|
+
// The caller's own credentials: a private service renders its skills only for them
|
|
312
|
+
await mounted.refreshSkills?.(originOf(c), Object.fromEntries((['authorization', 'cookie', 'cf-access-jwt-assertion'] as const).flatMap((name) => (c.req.header(name) ? [[name, c.req.header(name)!]] : []))))
|
|
313
|
+
return sendPublic(c, JSON.stringify(aiCatalog(originOf(c), config, mounted, config.apiTitle), null, 2), AI_CATALOG_TYPE)
|
|
314
|
+
})
|
|
315
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two guards for calls that change something or cost money, both keyed by the caller:
|
|
3
|
+
* idempotency `Idempotency-Key` on POST and PATCH (IETF httpapi-idempotency-key): a retry with the same key and
|
|
4
|
+
* body gets the stored answer (`Idempotent-Replayed: true`), the same key with another body is a 422,
|
|
5
|
+
* one still in flight a 409. Kept 24 hours.
|
|
6
|
+
* rateLimit at most `limit` calls per `windowSeconds` per caller on the routes it guards, with the draft
|
|
7
|
+
* `RateLimit-Policy` / `RateLimit` headers (IETF httpapi-ratelimit-headers) and a 429 problem with
|
|
8
|
+
* `Retry-After` past it: a looping agent can't drain a paid budget.
|
|
9
|
+
* Both keep their state in a `GuardStore`: `memoryGuardStore()` (the default) is right for one Node process; on
|
|
10
|
+
* Workers, where every isolate has its own memory, pass `dbGuardStore(db)` (`db`) so all of them share it.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const guards = await dbGuardStore(db)
|
|
14
|
+
* const app = createAgentApi({ …, guards: { store: guards } }) // idempotency, wired for you
|
|
15
|
+
* app.use('/api/v1/products/:id/gaps', rateLimit({ policy: 'paid', limit: 120, windowSeconds: 3600, store: guards }))
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import type { Context, MiddlewareHandler } from 'hono'
|
|
19
|
+
import { problemResponse } from './problem.js'
|
|
20
|
+
|
|
21
|
+
/** A stored answer to replay: the body is base64, so a store can keep it as JSON. */
|
|
22
|
+
export type StoredAnswer = { bodyHash: string; status: number; headers: [string, string][]; body: string }
|
|
23
|
+
|
|
24
|
+
/** Where the guards keep their state. Each method is atomic, so concurrent requests can't both pass a check. */
|
|
25
|
+
export type GuardStore = {
|
|
26
|
+
/** Counts one call against `id`; a window that started `windowMs` ago or earlier starts over. */
|
|
27
|
+
count: (id: string, windowMs: number, now: number) => Promise<{ start: number; used: number }>
|
|
28
|
+
/**
|
|
29
|
+
* Claims an idempotency key for `lockMs`: null when this request now holds it, `'running'` when another request
|
|
30
|
+
* does, or the answer saved for it.
|
|
31
|
+
*/
|
|
32
|
+
claim: (id: string, lockMs: number, now: number) => Promise<StoredAnswer | 'running' | null>
|
|
33
|
+
/** Saves the answer for a key this request holds, kept until `expiresAt` (epoch ms). */
|
|
34
|
+
save: (id: string, answer: StoredAnswer, expiresAt: number) => Promise<void>
|
|
35
|
+
/** Lets go of a key this request holds without an answer (a server error), so a retry can run. */
|
|
36
|
+
release: (id: string) => Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The guards' state in this process's memory: one Node instance. Old entries go once there are `maxEntries`. */
|
|
40
|
+
export const memoryGuardStore = ({ maxEntries = 2000 }: { maxEntries?: number } = {}): GuardStore => {
|
|
41
|
+
const windows = new Map<string, { start: number; used: number }>()
|
|
42
|
+
const keys = new Map<string, { answer: StoredAnswer | null; expiresAt: number }>()
|
|
43
|
+
const prune = (now: number) => {
|
|
44
|
+
for (const [id, entry] of keys) if (entry.expiresAt <= now || keys.size > maxEntries) keys.delete(id)
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
count: async (id, windowMs, now) => {
|
|
48
|
+
const current = windows.get(id)
|
|
49
|
+
const window = current && now - current.start < windowMs ? current : { start: now, used: 0 }
|
|
50
|
+
window.used++
|
|
51
|
+
windows.set(id, window)
|
|
52
|
+
if (windows.size > maxEntries) for (const [old, entry] of windows) if (now - entry.start >= windowMs) windows.delete(old)
|
|
53
|
+
return { ...window }
|
|
54
|
+
},
|
|
55
|
+
claim: async (id, lockMs, now) => {
|
|
56
|
+
prune(now)
|
|
57
|
+
const entry = keys.get(id)
|
|
58
|
+
if (entry) return entry.answer ?? 'running'
|
|
59
|
+
keys.set(id, { answer: null, expiresAt: now + lockMs })
|
|
60
|
+
return null
|
|
61
|
+
},
|
|
62
|
+
save: async (id, answer, expiresAt) => void keys.set(id, { answer, expiresAt }),
|
|
63
|
+
release: async (id) => {
|
|
64
|
+
if (keys.get(id)?.answer === null) keys.delete(id)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const callerOf = (c: Context) => {
|
|
70
|
+
const caller = c.get('caller' as never) as { kind: string; name?: string; email?: string } | undefined
|
|
71
|
+
return caller ? `${caller.kind}:${caller.name ?? caller.email}` : `ip:${c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'local'}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const idempotency = ({ ttlMs = 24 * 60 * 60 * 1000, lockMs = 60_000, store = memoryGuardStore() }: { ttlMs?: number; lockMs?: number; store?: GuardStore } = {}): MiddlewareHandler =>
|
|
75
|
+
async (c, next) => {
|
|
76
|
+
const key = c.req.header('idempotency-key')
|
|
77
|
+
if (!key || (c.req.method !== 'POST' && c.req.method !== 'PATCH')) return next()
|
|
78
|
+
if (key.length > 255) return problemResponse(c, 'bad_request', { detail: 'Idempotency-Key is at most 255 characters.', errors: [{ in: 'header', path: 'Idempotency-Key', message: 'too long' }] })
|
|
79
|
+
const id = `${callerOf(c)} ${c.req.method} ${c.req.path} ${key}`
|
|
80
|
+
const bodyHash = createHash('sha256').update(new Uint8Array(await c.req.raw.clone().arrayBuffer())).digest('hex')
|
|
81
|
+
const now = Date.now()
|
|
82
|
+
const hit = await store.claim(id, lockMs, now)
|
|
83
|
+
if (hit === 'running') return problemResponse(c, 'conflict', { detail: 'A request with this Idempotency-Key is still running. Retry when it has answered.' })
|
|
84
|
+
if (hit) {
|
|
85
|
+
if (hit.bodyHash !== bodyHash) return problemResponse(c, 'unprocessable', { detail: 'This Idempotency-Key was used with a different body. Use a new key for a new request.' })
|
|
86
|
+
return new Response(Buffer.from(hit.body, 'base64'), { status: hit.status, headers: [...hit.headers, ['idempotent-replayed', 'true']] })
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await next()
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await store.release(id)
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
// Server errors aren't kept: a retry with the same key should be able to succeed
|
|
95
|
+
if (c.res.status >= 500) return void (await store.release(id))
|
|
96
|
+
const body = Buffer.from(await c.res.clone().arrayBuffer()).toString('base64')
|
|
97
|
+
await store.save(id, { bodyHash, status: c.res.status, headers: [...c.res.headers.entries()], body }, now + ttlMs)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const rateLimit = ({ policy, limit, windowSeconds, store = memoryGuardStore() }: { policy: string; limit: number; windowSeconds: number; store?: GuardStore }): MiddlewareHandler =>
|
|
101
|
+
async (c, next) => {
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
const window = await store.count(`${policy} ${callerOf(c)}`, windowSeconds * 1000, now)
|
|
104
|
+
const reset = Math.max(1, Math.ceil((window.start + windowSeconds * 1000 - now) / 1000))
|
|
105
|
+
const headers = { 'ratelimit-policy': `"${policy}";q=${limit};w=${windowSeconds}`, ratelimit: `"${policy}";r=${Math.max(0, limit - window.used)};t=${reset}` }
|
|
106
|
+
if (window.used > limit) return problemResponse(c, 'too_many_requests', { detail: `More than ${limit} calls to ${policy} routes in ${windowSeconds / 60} minutes. Try again in ${reset} seconds; answers already computed are cached, so repeating one is not needed.`, headers: { ...headers, 'retry-after': String(reset) } })
|
|
107
|
+
await next()
|
|
108
|
+
for (const [name, value] of Object.entries(headers)) c.res.headers.set(name, value)
|
|
109
|
+
}
|
package/src/core/hal.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HAL hypermedia (`application/hal+json`): every resource carries `_links` with absolute `href`s and, when the caller
|
|
3
|
+
* asked for neighbours, `_embedded`. An agent chains calls by following links instead of building URLs, so a route can
|
|
4
|
+
* move without breaking anyone who started from `/api`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const origin = originOf(c)
|
|
8
|
+
* linksOf(origin, { self: '/api/v1/cooks/123', recipes: { path: '/api/v1/cooks/123/recipes', title: 'Its 12 recipes' } })
|
|
9
|
+
* // { self: { href: 'https://cookbook.example.com/api/v1/cooks/123' }, recipes: { href: '…/recipes', title: 'Its 12 recipes' } }
|
|
10
|
+
* pathWith('/api/v1/search/recipes', { q: 'soup', platform: undefined }) // '/api/v1/search/recipes?q=soup'
|
|
11
|
+
*/
|
|
12
|
+
import { z } from '@hono/zod-openapi'
|
|
13
|
+
import type { Context } from 'hono'
|
|
14
|
+
|
|
15
|
+
export const HAL_JSON = 'application/hal+json'
|
|
16
|
+
|
|
17
|
+
export type Link = { href: string; title?: string; templated?: boolean; type?: string }
|
|
18
|
+
export type Links = Record<string, Link>
|
|
19
|
+
export type LinkTarget = string | { path: string; title?: string; templated?: boolean; type?: string } | null | undefined
|
|
20
|
+
|
|
21
|
+
// Behind Traefik and Cloudflare the socket is plain http: trust the forwarded proto and host for absolute URLs
|
|
22
|
+
export const originOf = (c: Context) => {
|
|
23
|
+
const url = new URL(c.req.url)
|
|
24
|
+
const proto = c.req.header('x-forwarded-proto')?.split(',')[0]?.trim()
|
|
25
|
+
return `${proto ?? url.protocol.replace(':', '')}://${c.req.header('x-forwarded-host') ?? url.host}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A path with its query, leaving out empty values; arrays repeat the key (`platform=ios&platform=web`). */
|
|
29
|
+
export const pathWith = (path: string, query: Record<string, string | number | boolean | string[] | null | undefined> = {}) => {
|
|
30
|
+
const params = new URLSearchParams()
|
|
31
|
+
for (const [key, value] of Object.entries(query)) {
|
|
32
|
+
if (value === undefined || value === null || value === '') continue
|
|
33
|
+
for (const one of Array.isArray(value) ? value : [value]) params.append(key, String(one))
|
|
34
|
+
}
|
|
35
|
+
const search = params.toString()
|
|
36
|
+
return search ? `${path}?${search}` : path
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Relative paths → absolute links; `null` and `undefined` targets are left out, so optional links just disappear. */
|
|
40
|
+
export const linksOf = (origin: string, targets: Record<string, LinkTarget>): Links =>
|
|
41
|
+
Object.fromEntries(
|
|
42
|
+
Object.entries(targets)
|
|
43
|
+
.filter((entry): entry is [string, Exclude<LinkTarget, null | undefined>] => entry[1] != null)
|
|
44
|
+
.map(([rel, target]) => {
|
|
45
|
+
if (typeof target === 'string') return [rel, { href: origin + target }]
|
|
46
|
+
const { path, ...rest } = target
|
|
47
|
+
return [rel, { href: origin + path, ...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined)) }]
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
export const LinkSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
href: z.string().url().openapi({ description: 'Absolute URL to follow.', example: 'https://api.example.com/api/v1/cooks/123' }),
|
|
54
|
+
title: z.string().optional().openapi({ description: 'What is at the other end, in a few words.' }),
|
|
55
|
+
templated: z.boolean().optional().openapi({ description: 'True when `href` is a URI template (RFC 6570) to fill in first.' }),
|
|
56
|
+
type: z.string().optional().openapi({ description: 'Media type of the target when it is not HAL JSON (an image, Markdown).' }),
|
|
57
|
+
})
|
|
58
|
+
.openapi('Link', { description: 'A HAL link.' })
|
|
59
|
+
|
|
60
|
+
export const LinksSchema = z.record(z.string(), LinkSchema).openapi('Links', {
|
|
61
|
+
description: 'HAL `_links`: relation name → link. `self` is always present; follow links instead of building URLs.',
|
|
62
|
+
example: { self: { href: 'https://api.example.com/api/v1/cooks/123' } },
|
|
63
|
+
})
|
package/src/core/kv.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key-value store with expiry: what the OAuth server keeps (codes, tokens, registrations) and A2A's tasks. Values
|
|
3
|
+
* are small JSON objects under a `kind` and an id; `expiresAt` is epoch seconds, and an expired value reads as null.
|
|
4
|
+
* `memoryStore` is for tests and single-process scripts; `dbOAuthStore(db)` (`oauth`) keeps them in the app's database.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const store = memoryStore()
|
|
8
|
+
* await store.put('a2a-task', id, task, Math.floor(Date.now() / 1000) + 86_400)
|
|
9
|
+
*/
|
|
10
|
+
export type KeyValueStore = {
|
|
11
|
+
get: (kind: string, id: string) => Promise<Record<string, unknown> | null>
|
|
12
|
+
put: (kind: string, id: string, value: Record<string, unknown>, expiresAt: number) => Promise<void>
|
|
13
|
+
delete: (kind: string, id: string) => Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const now = () => Math.floor(Date.now() / 1000)
|
|
17
|
+
|
|
18
|
+
/** A store in memory. */
|
|
19
|
+
export const memoryStore = (): KeyValueStore => {
|
|
20
|
+
const rows = new Map<string, { value: Record<string, unknown>; expiresAt: number }>()
|
|
21
|
+
return {
|
|
22
|
+
get: async (kind, id) => {
|
|
23
|
+
const row = rows.get(`${kind}:${id}`)
|
|
24
|
+
return row && row.expiresAt > now() ? row.value : null
|
|
25
|
+
},
|
|
26
|
+
put: async (kind, id, value, expiresAt) => void rows.set(`${kind}:${id}`, { value, expiresAt }),
|
|
27
|
+
delete: async (kind, id) => void rows.delete(`${kind}:${id}`),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors as RFC 9457 problem details (`application/problem+json`). Every error has a stable machine `code` (also the
|
|
3
|
+
* last segment of its `type` URI, which the API serves as a page), a human `title`, the HTTP `status`, and a `detail`
|
|
4
|
+
* that says what to do next. Validation errors list each bad field in `errors`.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* throw new ProblemError(404, 'not_found', { detail: 'No item 42. Search with GET /api/v1/search/recipes.' })
|
|
8
|
+
* createRoute({ …, responses: { 200: …, ...problemResponses(400, 404) } })
|
|
9
|
+
*/
|
|
10
|
+
import { z } from '@hono/zod-openapi'
|
|
11
|
+
import type { Context } from 'hono'
|
|
12
|
+
import { HTTPException } from 'hono/http-exception'
|
|
13
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
|
14
|
+
import { originOf } from './hal.js'
|
|
15
|
+
|
|
16
|
+
export const PROBLEM_JSON = 'application/problem+json'
|
|
17
|
+
|
|
18
|
+
/** Every problem the API returns, with the status it comes with and what it means. */
|
|
19
|
+
export const PROBLEMS = {
|
|
20
|
+
bad_request: { status: 400, title: 'Bad request', description: 'A parameter or the body is invalid. `errors` lists each one with its path; fix them and retry.' },
|
|
21
|
+
unauthorized: { status: 401, title: 'Unauthorized', description: 'No valid API key. Send `Authorization: Bearer <key>`; keys are issued per client.' },
|
|
22
|
+
forbidden: { status: 403, title: 'Forbidden', description: 'The key is valid but may not do this.' },
|
|
23
|
+
not_found: { status: 404, title: 'Not found', description: 'Nothing at this path or with this id in the current snapshot. Find ids through search or a list, then follow `_links`.' },
|
|
24
|
+
method_not_allowed: { status: 405, title: 'Method not allowed', description: 'This path exists but not with this method.' },
|
|
25
|
+
conflict: { status: 409, title: 'Conflict', description: 'The change clashes with the current state (a name already taken, a stale If-Match).' },
|
|
26
|
+
gone: { status: 410, title: 'Gone', description: 'This existed in an earlier snapshot and was removed.' },
|
|
27
|
+
payload_too_large: { status: 413, title: 'Payload too large', description: 'The body is over the limit given in `detail`.' },
|
|
28
|
+
unsupported_media_type: { status: 415, title: 'Unsupported media type', description: 'Send the content type the operation lists.' },
|
|
29
|
+
unprocessable: { status: 422, title: 'Unprocessable', description: 'The request is well formed but can\'t be applied as sent, e.g. an Idempotency-Key reused with a different body.' },
|
|
30
|
+
too_many_requests: { status: 429, title: 'Too many requests', description: 'Slow down: wait the seconds in `Retry-After`, then retry.' },
|
|
31
|
+
spend_cap: { status: 429, title: 'Spend cap reached', description: 'This operation costs money and its budget is used up. Free operations still work.' },
|
|
32
|
+
internal: { status: 500, title: 'Internal error', description: 'A bug on our side. Retrying the same request rarely helps; report the `instance`.' },
|
|
33
|
+
upstream: { status: 503, title: 'Upstream failed', description: 'A service this call depends on (a model, storage) failed. Retry after `Retry-After` seconds.' },
|
|
34
|
+
unavailable: { status: 503, title: 'Unavailable', description: 'The data is still loading or being replaced. Retry after `Retry-After` seconds.' },
|
|
35
|
+
} as const satisfies Record<string, { status: number; title: string; description: string }>
|
|
36
|
+
|
|
37
|
+
export type ProblemCode = keyof typeof PROBLEMS
|
|
38
|
+
|
|
39
|
+
export const ProblemSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
type: z.string().url().openapi({ description: 'URI of the problem type; its last segment is `code`, and it opens a page describing it.', example: 'https://api.example.com/problems/not_found' }),
|
|
42
|
+
code: z.enum(Object.keys(PROBLEMS) as [ProblemCode, ...ProblemCode[]]).openapi({ description: 'Stable machine-readable code. Branch on this, not on `title`.' }),
|
|
43
|
+
title: z.string().openapi({ description: 'Short, human summary of the problem type.', example: 'Not found' }),
|
|
44
|
+
status: z.number().int().openapi({ description: 'The HTTP status, repeated for convenience.', example: 404 }),
|
|
45
|
+
detail: z.string().optional().openapi({ description: 'What went wrong this time and what to do next.', example: 'No item 42.' }),
|
|
46
|
+
instance: z.string().optional().openapi({ description: 'The request id; quote it when reporting a bug.', example: 'req_01J…' }),
|
|
47
|
+
errors: z
|
|
48
|
+
.array(z.object({ in: z.enum(['path', 'query', 'header', 'body']).optional(), path: z.string(), message: z.string() }))
|
|
49
|
+
.optional()
|
|
50
|
+
.openapi({ description: 'For `bad_request`: every invalid field, where it is (`in`) and why.' }),
|
|
51
|
+
})
|
|
52
|
+
.catchall(z.unknown())
|
|
53
|
+
.openapi('Problem', { description: 'An error, as RFC 9457 problem details (`application/problem+json`).' })
|
|
54
|
+
|
|
55
|
+
export type Problem = z.infer<typeof ProblemSchema>
|
|
56
|
+
|
|
57
|
+
export class ProblemError extends Error {
|
|
58
|
+
constructor(
|
|
59
|
+
readonly code: ProblemCode,
|
|
60
|
+
readonly options: { detail?: string; errors?: Problem['errors']; headers?: Record<string, string>; extensions?: Record<string, unknown> } = {},
|
|
61
|
+
) {
|
|
62
|
+
super(options.detail ?? PROBLEMS[code].title)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const problemBody = (c: Context, code: ProblemCode, options: ProblemError['options'] = {}): Problem => ({
|
|
67
|
+
type: `${originOf(c)}/problems/${code}`,
|
|
68
|
+
code,
|
|
69
|
+
title: PROBLEMS[code].title,
|
|
70
|
+
status: PROBLEMS[code].status,
|
|
71
|
+
...(options.detail ? { detail: options.detail } : {}),
|
|
72
|
+
...(c.get('requestId' as never) ? { instance: c.get('requestId' as never) as string } : {}),
|
|
73
|
+
...(options.errors ? { errors: options.errors } : {}),
|
|
74
|
+
...options.extensions,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/** Seconds an `upstream` problem asks the client to wait unless the code passes its own `retry-after`. */
|
|
78
|
+
export const UPSTREAM_RETRY_AFTER = 30
|
|
79
|
+
|
|
80
|
+
export const problemResponse = (c: Context, code: ProblemCode, options: ProblemError['options'] = {}) =>
|
|
81
|
+
c.body(JSON.stringify(problemBody(c, code, options)), PROBLEMS[code].status as ContentfulStatusCode, {
|
|
82
|
+
'content-type': PROBLEM_JSON,
|
|
83
|
+
// 503, never 502: Cloudflare replaces a 502's body with its own page, and the problem is lost
|
|
84
|
+
...(code === 'upstream' ? { 'retry-after': String(UPSTREAM_RETRY_AFTER) } : {}),
|
|
85
|
+
...options.headers,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/** Response entries for `createRoute`: `...problemResponses(400, 404)` documents those errors as problem+json. */
|
|
89
|
+
export const problemResponses = (...statuses: number[]) =>
|
|
90
|
+
Object.fromEntries(
|
|
91
|
+
statuses.map((status) => {
|
|
92
|
+
const codes = Object.entries(PROBLEMS).filter(([, problem]) => problem.status === status)
|
|
93
|
+
return [status, { description: codes.map(([code, problem]) => `\`${code}\`: ${problem.description}`).join(' '), content: { [PROBLEM_JSON]: { schema: ProblemSchema } } }]
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
type Issue = { path: PropertyKey[]; message: string }
|
|
98
|
+
|
|
99
|
+
/** `defaultHook` for OpenAPIHono: a failed zod validation becomes a 400 problem listing every bad field. */
|
|
100
|
+
export const validationHook = (result: { success: boolean; target?: string; error?: { issues: Issue[] } }, c: Context) => {
|
|
101
|
+
if (result.success) return
|
|
102
|
+
const where = ({ param: 'path', query: 'query', header: 'header', json: 'body', form: 'body' } as const)[result.target as 'param'] ?? undefined
|
|
103
|
+
const errors = (result.error?.issues ?? []).map((issue) => ({ ...(where ? { in: where } : {}), path: issue.path.map(String).join('.') || '(root)', message: issue.message }))
|
|
104
|
+
return problemResponse(c, 'bad_request', { detail: `${errors.length} invalid ${errors.length === 1 ? 'field' : 'fields'}: ${errors.map((error) => error.path).join(', ')}.`, errors })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `app.onError`: problems as themselves, Hono's HTTP errors by status, anything else as a logged 500. */
|
|
108
|
+
export const errorHandler = (error: Error, c: Context) => {
|
|
109
|
+
if (error instanceof ProblemError) return problemResponse(c, error.code, error.options)
|
|
110
|
+
if (error instanceof HTTPException) {
|
|
111
|
+
// A bare 503 says the service can't answer now, not that something behind it failed: `upstream` is only ever named
|
|
112
|
+
const code = (error.status === 503 ? 'unavailable' : (Object.entries(PROBLEMS).find(([, problem]) => problem.status === error.status)?.[0] ?? 'internal')) as ProblemCode
|
|
113
|
+
return problemResponse(c, code, { detail: error.message })
|
|
114
|
+
}
|
|
115
|
+
console.error(`${c.req.method} ${c.req.path}`, error)
|
|
116
|
+
return problemResponse(c, 'internal')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A wrong path says how to find the right one: a trailing slash dropped, the root, the spec. */
|
|
120
|
+
export const notFoundHandler = (c: Context) => {
|
|
121
|
+
const path = c.req.path
|
|
122
|
+
const hint = path.length > 1 && path.endsWith('/') ? ` Drop the trailing slash: ${path.slice(0, -1)}.` : ''
|
|
123
|
+
return problemResponse(c, 'not_found', { detail: `No route ${c.req.method} ${path}.${hint} Start from GET /api and follow _links, look the operation up in GET /openapi.json, or read /llms.txt.` })
|
|
124
|
+
}
|