@escalay/kit 0.0.0-stage → 1.7.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/cli.mjs +432 -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/package.json +58 -3
  8. package/registry.json +512 -0
  9. package/src/a2a/README.md +25 -0
  10. package/src/a2a/index.ts +262 -0
  11. package/src/boards/README.md +35 -0
  12. package/src/boards/handlers.ts +87 -0
  13. package/src/boards/index.ts +64 -0
  14. package/src/boards/refs.ts +23 -0
  15. package/src/boards/repo.ts +50 -0
  16. package/src/boards/routes.ts +83 -0
  17. package/src/boards/schema.ts +28 -0
  18. package/src/boards/tools.ts +76 -0
  19. package/src/browser/README.md +31 -0
  20. package/src/browser/boards-client.ts +74 -0
  21. package/src/browser/boards-react.ts +73 -0
  22. package/src/browser/search-client.ts +258 -0
  23. package/src/browser/search-react.ts +116 -0
  24. package/src/browser/vector-search-block.tsx +94 -0
  25. package/src/core/README.md +37 -0
  26. package/src/core/api.ts +239 -0
  27. package/src/core/auth.ts +147 -0
  28. package/src/core/cache.ts +53 -0
  29. package/src/core/discovery.ts +315 -0
  30. package/src/core/guards.ts +109 -0
  31. package/src/core/hal.ts +63 -0
  32. package/src/core/kv.ts +29 -0
  33. package/src/core/problem.ts +124 -0
  34. package/src/db/README.md +39 -0
  35. package/src/db/guards.ts +61 -0
  36. package/src/db/index.ts +122 -0
  37. package/src/guide/README.md +38 -0
  38. package/src/guide/guide.ts +152 -0
  39. package/src/guide/reference.ts +204 -0
  40. package/src/index.ts +15 -0
  41. package/src/mcp/README.md +34 -0
  42. package/src/mcp/index.ts +251 -0
  43. package/src/oauth/README.md +29 -0
  44. package/src/oauth/index.ts +439 -0
  45. package/src/oauth/schema.ts +7 -0
  46. package/src/oauth/store.ts +34 -0
  47. package/src/pagination/README.md +31 -0
  48. package/src/pagination/index.ts +65 -0
  49. package/src/testing/README.md +31 -0
  50. package/src/testing/index.ts +162 -0
  51. package/src/vector-search/README.md +27 -0
  52. package/src/vector-search/index.ts +367 -0
  53. package/src/vectors/README.md +30 -0
  54. package/src/vectors/index.ts +326 -0
  55. package/src/web/README.md +21 -0
  56. package/src/web/index.ts +100 -0
  57. package/templates/app/AGENTS.md +41 -0
  58. package/templates/app/CLAUDE.md +3 -0
  59. package/templates/app/Dockerfile +12 -0
  60. package/templates/app/container/entrypoint.sh +7 -0
  61. package/templates/app/gitignore +4 -0
  62. package/templates/app/guide/llms.txt +11 -0
  63. package/templates/app/pnpm-workspace.yaml +5 -0
  64. package/templates/app/scripts/guide.ts +8 -0
  65. package/templates/app/src/app.ts +39 -0
  66. package/templates/app/src/features/index.ts +30 -0
  67. package/templates/app/src/server.ts +14 -0
  68. package/templates/app/test/api.test.ts +34 -0
  69. package/templates/app/tsconfig.json +16 -0
  70. package/templates/feature/feature.test.ts +19 -0
  71. package/templates/feature/handlers.ts +43 -0
  72. package/templates/feature/index.ts +34 -0
  73. package/templates/feature/repo.ts +24 -0
  74. package/templates/feature/routes.ts +43 -0
  75. package/templates/feature/schema.ts +14 -0
  76. package/templates/feature/tools.ts +46 -0
  77. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  78. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  79. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  80. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  81. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  82. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  83. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  84. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  85. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  86. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
@@ -0,0 +1,439 @@
1
+ /**
2
+ * The service as its own OAuth authorization server, so an agent can get a token without anyone pasting a key. Two
3
+ * ways in, both ending at a person who approves it on a page behind the site's single sign-on:
4
+ * MCP clients (claude.ai, Claude Desktop, Cursor) follow the 401's `resource_metadata` to the protected-resource
5
+ * metadata, register themselves (RFC 7591), send the owner to `/oauth/authorize` (authorization code + PKCE S256),
6
+ * and refresh with rotating refresh tokens.
7
+ * Any other agent follows `/auth.md` (agent registration, anonymous or by email): it registers at `/agent/identity`,
8
+ * asks for a claim at `/agent/identity/claim`, hands the owner a 6-digit code to type at `/oauth/claim`, and polls
9
+ * the token endpoint until the owner has.
10
+ * Access tokens are ES256 JWTs (one hour) signed with the app's key and checked against a revocation list on every
11
+ * request; refresh tokens, codes and registrations live in the app's store. Nothing is granted before a person
12
+ * approves: an unclaimed registration can't get a token.
13
+ *
14
+ * @example
15
+ * const oauth = createOAuth({ name: 'Cookbook', signingKey: env.OAUTH_SIGNING_KEY, store, owner: (c) => ownerOf(c) })
16
+ * oauth.mount(app) // the metadata, the endpoints and auth.md
17
+ * app.use('*', authenticate({ realm: 'cookbook', keys, tokens: oauth.verify, resourceMetadata: oauth.resourceMetadataFor }))
18
+ */
19
+ import { createHash, randomBytes, randomInt, timingSafeEqual } from 'node:crypto'
20
+ import type { Context, Hono } from 'hono'
21
+ import { calculateJwkThumbprint, exportJWK, importJWK, jwtVerify, SignJWT, type JWK } from 'jose'
22
+ import { OAUTH_OPEN_PATHS, SCOPES } from '../core/auth.js'
23
+ import { originOf } from '../core/hal.js'
24
+ import { problemResponse } from '../core/problem.js'
25
+ import type { OAuthStore } from './store.js'
26
+
27
+ export { OAUTH_MIGRATIONS, oauthKeys } from './schema.js'
28
+ export { dbOAuthStore, memoryOAuthStore, type OAuthStore } from './store.js'
29
+
30
+ const HOUR = 3600
31
+ const ACCESS_TTL = HOUR
32
+ const CODE_TTL = 600
33
+ const REFRESH_TTL = 30 * 24 * HOUR
34
+ const CLIENT_TTL = 365 * 24 * HOUR
35
+ const REGISTRATION_TTL = 24 * HOUR
36
+ const CLAIM_TTL = 600
37
+ const CLAIM_GRANT = 'urn:workos:agent-auth:grant-type:claim'
38
+ const JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
39
+
40
+ export type OAuthConfig = {
41
+ /** Shown on the consent page and in the metadata (`resource_name`). */
42
+ name: string
43
+ /** The private ES256 key as a JWK JSON string (`generateOAuthKey()` makes one). */
44
+ signingKey: string
45
+ store: OAuthStore
46
+ /** The signed-in person's email on the approval pages (from the single sign-on), or null: only they may approve. */
47
+ owner: (c: Context) => Promise<string | null>
48
+ /** Emails allowed to claim an agent registration; empty means whoever `owner` lets in. */
49
+ owners?: string[]
50
+ /** Extra fields for the authorization server metadata, e.g. `poppy_domains`. */
51
+ metadata?: Record<string, unknown>
52
+ }
53
+
54
+ export type TokenCaller = { kind: 'agent'; name: string; scopes: string[]; owner: string }
55
+
56
+ const now = () => Math.floor(Date.now() / 1000)
57
+ const token = (prefix: string) => `${prefix}_${randomBytes(24).toString('base64url')}`
58
+ const sha = (value: string) => createHash('sha256').update(value).digest('hex')
59
+ const same = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b))
60
+ const escape = (text: string) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`)
61
+
62
+ /** A fresh ES256 signing key as a private JWK JSON string, for the app's secrets. */
63
+ export const generateOAuthKey = async () => {
64
+ const { privateKey } = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
65
+ return JSON.stringify({ ...(await exportJWK(privateKey)), alg: 'ES256' })
66
+ }
67
+
68
+ const UNSAFE_SCHEMES = new Set(['javascript:', 'data:', 'file:', 'vbscript:', 'blob:'])
69
+
70
+ /** Redirect URIs a client may register: https anywhere, http only on this machine, or an app's own scheme (`cursor://`). */
71
+ const redirectAllowed = (uri: string) => {
72
+ const url = URL.canParse(uri) ? new URL(uri) : null
73
+ if (!url || url.hash || UNSAFE_SCHEMES.has(url.protocol)) return false
74
+ if (url.protocol === 'http:') return ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
75
+ return true
76
+ }
77
+
78
+ const bodyOf = async (c: Context): Promise<Record<string, string>> => {
79
+ const type = c.req.header('content-type') ?? ''
80
+ if (type.includes('application/json')) return ((await c.req.json().catch(() => ({}))) ?? {}) as Record<string, string>
81
+ const form = await c.req.parseBody().catch(() => ({}))
82
+ return Object.fromEntries(Object.entries(form).map(([key, value]) => [key, String(value)]))
83
+ }
84
+
85
+ const oauthError = (c: Context, status: 400 | 401 | 403 | 410 | 429, error: string, description: string, extra: Record<string, unknown> = {}) =>
86
+ c.json({ error, error_description: description, ...extra }, status, { 'cache-control': 'no-store' })
87
+
88
+ const page = (title: string, body: string) =>
89
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title>
90
+ <style>:root{color-scheme:light dark;--bg:#fafaf9;--fg:#111;--muted:#ececeb;--mfg:#6b6b6b}@media(prefers-color-scheme:dark){:root{--bg:#0b0b0b;--fg:#f2f2f2;--muted:#1c1c1c;--mfg:#9a9a9a}}
91
+ body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;padding:16px}
92
+ main{max-width:28rem;width:100%;background:var(--muted);border-radius:12px;padding:28px}h1{font-size:20px;font-weight:400;letter-spacing:-.01em;margin:0 0 8px}p{color:var(--mfg);margin:0 0 16px}
93
+ form{display:flex;gap:8px;flex-wrap:wrap;margin-top:20px}button,input{font:inherit;border-radius:8px;border:0;padding:10px 16px}button{background:var(--fg);color:var(--bg);cursor:pointer}button.quiet{background:transparent;color:var(--fg);box-shadow:inset 0 0 0 1px var(--mfg)}
94
+ input{background:var(--bg);color:var(--fg);letter-spacing:.3em;width:9ch;text-align:center}code{font-size:14px}</style></head><body><main>${body}</main></body></html>`
95
+
96
+ export const createOAuth = (config: OAuthConfig) => {
97
+ const { store } = config
98
+ const ready = (async () => {
99
+ const jwk = JSON.parse(config.signingKey) as JWK
100
+ const privateKey = await importJWK(jwk, 'ES256')
101
+ const { d: _d, ...publicJwk } = jwk
102
+ const kid = await calculateJwkThumbprint(publicJwk)
103
+ const publicKey = await importJWK(publicJwk, 'ES256')
104
+ return { privateKey, publicKey, publicJwk: { ...publicJwk, kid, use: 'sig', alg: 'ES256' }, kid }
105
+ })()
106
+
107
+ const sign = async (claims: Record<string, unknown>, ttl: number, origin: string) => {
108
+ const { privateKey, kid } = await ready
109
+ return new SignJWT(claims).setProtectedHeader({ alg: 'ES256', kid }).setIssuer(origin).setIssuedAt().setExpirationTime(now() + ttl).sign(privateKey)
110
+ }
111
+
112
+ const issue = async (origin: string, grant: { client: string; name: string; owner: string; scopes: string[]; resource?: string }) => {
113
+ const jti = randomBytes(12).toString('base64url')
114
+ const access = await sign({ sub: grant.owner, client_id: grant.client, name: grant.name, scope: grant.scopes.join(' '), jti }, ACCESS_TTL, origin)
115
+ await store.put('access', jti, { client: grant.client }, now() + ACCESS_TTL)
116
+ return { access_token: access, token_type: 'Bearer', expires_in: ACCESS_TTL, scope: grant.scopes.join(' ') }
117
+ }
118
+
119
+ const refreshFor = async (grant: { client: string; name: string; owner: string; scopes: string[] }) => {
120
+ const refresh = token('rt')
121
+ await store.put('refresh', sha(refresh), grant, now() + REFRESH_TTL)
122
+ return refresh
123
+ }
124
+
125
+ /** The protected-resource metadata URL for a request: suffixed with `/mcp` for MCP, the bare one elsewhere. */
126
+ const resourceMetadataFor = (c: Context) => `${originOf(c)}/.well-known/oauth-protected-resource${c.req.path.startsWith('/mcp') ? '/mcp' : ''}`
127
+
128
+ /** An access token → its caller, or null (expired, revoked, or not ours). */
129
+ const verify = async (jwt: string, c: Context): Promise<TokenCaller | null> => {
130
+ const { publicKey } = await ready
131
+ const origin = originOf(c)
132
+ const { payload } = await jwtVerify(jwt, publicKey, { issuer: origin, algorithms: ['ES256'] }).catch(() => ({ payload: null }))
133
+ if (!payload || typeof payload.jti !== 'string' || typeof payload.client_id !== 'string') return null
134
+ if (!(await store.get('access', payload.jti))) return null
135
+ if (await store.get('revoked-client', payload.client_id)) return null
136
+ return { kind: 'agent', name: String(payload.name ?? payload.client_id), scopes: String(payload.scope ?? '').split(' ').filter(Boolean), owner: String(payload.sub) }
137
+ }
138
+
139
+ const authorizationServer = (origin: string) => ({
140
+ issuer: origin,
141
+ authorization_endpoint: `${origin}/oauth/authorize`,
142
+ token_endpoint: `${origin}/oauth/token`,
143
+ registration_endpoint: `${origin}/oauth/register`,
144
+ revocation_endpoint: `${origin}/oauth/revoke`,
145
+ jwks_uri: `${origin}/.well-known/jwks.json`,
146
+ scopes_supported: [...SCOPES],
147
+ response_types_supported: ['code'],
148
+ response_modes_supported: ['query'],
149
+ grant_types_supported: ['authorization_code', 'refresh_token', JWT_BEARER, CLAIM_GRANT],
150
+ code_challenge_methods_supported: ['S256'],
151
+ token_endpoint_auth_methods_supported: ['none'],
152
+ revocation_endpoint_auth_methods_supported: ['none'],
153
+ authorization_response_iss_parameter_supported: true,
154
+ service_documentation: `${origin}/llms.txt`,
155
+ // Restated from the protected-resource metadata, as auth.md does
156
+ resource: origin,
157
+ authorization_servers: [origin],
158
+ bearer_methods_supported: ['header'],
159
+ // Personal Agent Protocol (3.2): the resources this issuer speaks for, and the domains it confirms
160
+ protected_resources: [origin, `${origin}/mcp`],
161
+ poppy_domains: [new URL(origin).hostname],
162
+ agent_auth: {
163
+ skill: `${origin}/auth.md`,
164
+ register_uri: `${origin}/agent/identity`,
165
+ identity_endpoint: `${origin}/agent/identity`,
166
+ claim_uri: `${origin}/agent/identity/claim`,
167
+ claim_endpoint: `${origin}/agent/identity/claim`,
168
+ identity_types_supported: ['anonymous', 'service_auth'],
169
+ anonymous: { credential_types_supported: ['identity_assertion'], claim_uri: `${origin}/agent/identity/claim`, pre_claim_scopes: [], post_claim_scopes: [...SCOPES] },
170
+ service_auth: { credential_types_supported: ['identity_assertion'], claim_uri: `${origin}/agent/identity/claim`, post_claim_scopes: [...SCOPES] },
171
+ },
172
+ ...config.metadata,
173
+ })
174
+
175
+ const protectedResource = (origin: string, suffix: string) => ({
176
+ resource: `${origin}${suffix}`,
177
+ resource_name: config.name,
178
+ authorization_servers: [origin],
179
+ scopes_supported: [...SCOPES],
180
+ bearer_methods_supported: ['header'],
181
+ resource_documentation: `${origin}/llms.txt`,
182
+ })
183
+
184
+ const authMd = (origin: string) => `# ${config.name} auth.md
185
+
186
+ You are an agent. ${config.name} lets you register and get an access token, but **nothing is granted until its owner
187
+ approves you** on a page behind their sign-in. There are two ways; pick the one your client supports.
188
+
189
+ ## A. OAuth with the owner in the browser (MCP clients, apps)
190
+
191
+ 1. Call any API route without a token. The 401 carries
192
+ \`WWW-Authenticate: Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"\` (\`/mcp\` adds \`/mcp\`).
193
+ 2. Read that document, then \`${origin}/.well-known/oauth-authorization-server\`.
194
+ 3. Register: \`POST ${origin}/oauth/register\` with \`{"client_name": "…", "redirect_uris": ["https://…/callback"]}\`
195
+ (RFC 7591, public client, no secret). Keep the \`client_id\`.
196
+ 4. Send the owner to \`${origin}/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…&scope=read%20write\`.
197
+ They sign in and approve.
198
+ 5. Exchange the code: \`POST ${origin}/oauth/token\` with \`grant_type=authorization_code&code=…&redirect_uri=…&client_id=…&code_verifier=…\`.
199
+ You get an access token (one hour) and a refresh token (\`grant_type=refresh_token\`, rotated on each use).
200
+
201
+ ## B. Agent registration (auth.md profile)
202
+
203
+ 1. Register: \`POST ${origin}/agent/identity\` with \`{"type": "anonymous"}\`, or \`{"type": "service_auth", "login_hint": "<owner email>"}\`.
204
+ Keep the \`claim_token\`; it is returned once. An anonymous registration has no scopes yet.
205
+ 2. Ask for a claim (anonymous only): \`POST ${origin}/agent/identity/claim\` with \`{"claim_token": "…", "email": "<owner email>"}\`.
206
+ Only ${config.owners?.length ? 'the owner' : 'a person who can sign in to this site'} can complete it.
207
+ 3. Give the owner the \`verification_uri\` and the 6-digit \`user_code\` in one message. They sign in and type the code.
208
+ 4. Poll \`POST ${origin}/oauth/token\` with \`grant_type=${CLAIM_GRANT}&claim_token=…\` every \`interval\` seconds
209
+ (\`authorization_pending\` until they do). Success returns an access token and an \`identity_assertion\`.
210
+ 5. When the access token expires, exchange the assertion: \`grant_type=${JWT_BEARER}&assertion=…\`.
211
+
212
+ ## Using the token
213
+
214
+ \`Authorization: Bearer <access_token>\` on \`/api\` and \`/mcp\`. Scopes: \`read\` (everything you can GET) and \`write\`
215
+ (saved items and other changes). Revoke with \`POST ${origin}/oauth/revoke\` and \`token=…\`. What to call next: ${origin}/llms.txt.
216
+
217
+ | Error | Means |
218
+ |---|---|
219
+ | \`authorization_pending\` | the owner hasn't approved yet; keep polling at \`interval\` |
220
+ | \`expired_token\` | the 10-minute code window passed; ask for a new claim |
221
+ | \`invalid_grant\` | the code, refresh token or assertion is used, expired or revoked; start again |
222
+ | \`access_denied\` | the owner said no |
223
+ `
224
+
225
+ const mount = (app: Hono<any, any, any>) => {
226
+ const json = (c: Context, body: unknown, type = 'application/json') => c.body(JSON.stringify(body, null, 2), 200, { 'content-type': type, 'cache-control': 'public, max-age=300', 'access-control-allow-origin': '*' })
227
+
228
+ app.get('/.well-known/oauth-authorization-server', (c) => json(c, authorizationServer(originOf(c))))
229
+ app.get('/.well-known/oauth-authorization-server/*', (c) => json(c, authorizationServer(originOf(c))))
230
+ app.get('/.well-known/oauth-protected-resource', (c) => json(c, protectedResource(originOf(c), '')))
231
+ app.get('/.well-known/oauth-protected-resource/*', (c) => json(c, protectedResource(originOf(c), c.req.path.slice('/.well-known/oauth-protected-resource'.length))))
232
+ app.get('/.well-known/jwks.json', async (c) => json(c, { keys: [(await ready).publicJwk] }))
233
+ app.get('/auth.md', (c) => c.body(authMd(originOf(c)), 200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'public, max-age=300' }))
234
+
235
+ // RFC 7591: a public client registers itself; registering grants nothing until the owner approves a request
236
+ app.post('/oauth/register', async (c) => {
237
+ const body = (await c.req.json().catch(() => ({}))) as { redirect_uris?: unknown; client_name?: unknown; token_endpoint_auth_method?: unknown }
238
+ const uris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((uri): uri is string => typeof uri === 'string') : []
239
+ if (!uris.length || !uris.every(redirectAllowed)) return oauthError(c, 400, 'invalid_redirect_uri', 'Send redirect_uris: https URLs, or http on localhost for apps on this machine.')
240
+ const name = typeof body.client_name === 'string' && body.client_name.trim() ? body.client_name.trim().slice(0, 80) : 'An unnamed client'
241
+ const clientId = token('client')
242
+ const issuedAt = now()
243
+ await store.put('client', clientId, { name, redirectUris: uris }, issuedAt + CLIENT_TTL)
244
+ return c.json({ client_id: clientId, client_id_issued_at: issuedAt, client_name: name, redirect_uris: uris, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }, 201, { 'cache-control': 'no-store' })
245
+ })
246
+
247
+ const authorizeParams = async (c: Context, params: Record<string, string>) => {
248
+ const client = params.client_id ? await store.get('client', params.client_id) : null
249
+ if (!client) return { error: 'This client is not registered here (or its registration expired). Register again at /oauth/register.' }
250
+ if (!(client.redirectUris as string[]).includes(params.redirect_uri ?? '')) return { error: 'The redirect_uri is not one this client registered.' }
251
+ if (params.response_type !== 'code') return { error: 'Only response_type=code is supported.', redirect: true }
252
+ if (!params.code_challenge || params.code_challenge_method !== 'S256') return { error: 'PKCE is required: code_challenge with code_challenge_method=S256.', redirect: true }
253
+ const scopes = (params.scope || SCOPES.join(' ')).split(' ').filter((scope) => (SCOPES as readonly string[]).includes(scope))
254
+ return { client, scopes }
255
+ }
256
+
257
+ const backTo = (redirectUri: string, origin: string, query: Record<string, string | undefined>) => {
258
+ const url = new URL(redirectUri)
259
+ for (const [key, value] of Object.entries({ ...query, iss: origin })) if (value !== undefined) url.searchParams.set(key, value)
260
+ return url.toString()
261
+ }
262
+
263
+ // The consent page: behind the single sign-on, and the owner check runs here too
264
+ app.get('/oauth/authorize', async (c) => {
265
+ const params = c.req.query()
266
+ const owner = await config.owner(c)
267
+ if (!owner) return c.html(page('Sign in first', '<h1>Sign in first</h1><p>Approving an agent needs you signed in to this site. Open it in your browser, sign in, then try again.</p>'), 401)
268
+ const checked = await authorizeParams(c, params)
269
+ if ('error' in checked) {
270
+ if (checked.redirect && params.redirect_uri) return c.redirect(backTo(params.redirect_uri, originOf(c), { error: 'invalid_request', error_description: checked.error, state: params.state }), 302)
271
+ return c.html(page('Can’t approve this', `<h1>Can’t approve this request</h1><p>${escape(checked.error!)}</p>`), 400)
272
+ }
273
+ const ticket = token('consent')
274
+ await store.put('consent', ticket, { ...params, owner }, now() + CODE_TTL)
275
+ const what = checked.scopes.includes('write') ? 'read everything and make changes' : 'read everything'
276
+ return c.html(page(`Allow ${checked.client.name}?`, `<h1>Allow <strong>${escape(String(checked.client.name))}</strong> to use ${escape(config.name)}?</h1>
277
+ <p>It will be able to ${what} as you (${escape(owner)}), until you revoke it. It sends you back to <code>${escape(new URL(params.redirect_uri!).host)}</code>.</p>
278
+ <form method="post" action="/oauth/authorize"><input type="hidden" name="ticket" value="${ticket}"><button name="decision" value="allow">Allow</button><button class="quiet" name="decision" value="deny">Deny</button></form>`))
279
+ })
280
+
281
+ app.post('/oauth/authorize', async (c) => {
282
+ const owner = await config.owner(c)
283
+ const site = c.req.header('sec-fetch-site')
284
+ if (!owner || (site && site !== 'same-origin')) return c.html(page('Not allowed', '<h1>Not allowed</h1><p>Approve from the page this site showed you, signed in.</p>'), 403)
285
+ const body = await bodyOf(c)
286
+ const consent = body.ticket ? await store.get('consent', body.ticket) : null
287
+ if (!consent || consent.owner !== owner) return c.html(page('Expired', '<h1>This approval expired</h1><p>Start again from the app that asked.</p>'), 400)
288
+ await store.delete('consent', body.ticket!)
289
+ const params = consent as Record<string, string>
290
+ const origin = originOf(c)
291
+ if (body.decision !== 'allow') return c.redirect(backTo(params.redirect_uri!, origin, { error: 'access_denied', error_description: 'The owner said no.', state: params.state }), 302)
292
+ const checked = await authorizeParams(c, params)
293
+ if ('error' in checked) return c.html(page('Can’t approve this', `<h1>Can’t approve this request</h1><p>${escape(checked.error!)}</p>`), 400)
294
+ const code = token('code')
295
+ await store.put('code', sha(code), { client: params.client_id, redirectUri: params.redirect_uri, challenge: params.code_challenge, scopes: checked.scopes, owner, name: checked.client.name, resource: params.resource ?? null }, now() + CODE_TTL)
296
+ return c.redirect(backTo(params.redirect_uri!, origin, { code, state: params.state }), 302)
297
+ })
298
+
299
+ // auth.md registration: anonymous or bound to an email, claimed by a person typing the code
300
+ app.post('/agent/identity', async (c) => {
301
+ const body = (await c.req.json().catch(() => ({}))) as { type?: string; login_hint?: string; client_name?: string }
302
+ if (body.type === 'identity_assertion') return oauthError(c, 400, 'issuer_not_enabled', 'Identity assertions (ID-JAG) are not accepted here: register as anonymous or service_auth.')
303
+ if (body.type !== 'anonymous' && body.type !== 'service_auth') return oauthError(c, 400, 'invalid_request', 'Send {"type": "anonymous"} or {"type": "service_auth", "login_hint": "<email>"}.')
304
+ if (body.type === 'service_auth' && !body.login_hint) return oauthError(c, 400, 'invalid_request', 'service_auth needs login_hint: the owner’s email.')
305
+ const origin = originOf(c)
306
+ const registrationId = token('reg')
307
+ const claimToken = token('clm')
308
+ const expires = now() + REGISTRATION_TTL
309
+ const name = typeof body.client_name === 'string' && body.client_name.trim() ? body.client_name.trim().slice(0, 80) : `Agent ${registrationId.slice(4, 10)}`
310
+ await store.put('registration', registrationId, { name, type: body.type, claimed: false, email: body.login_hint ?? null }, expires)
311
+ await store.put('claim-token', sha(claimToken), { registration: registrationId }, expires)
312
+ const assertion = await sign({ sub: registrationId, typ: 'agent_identity' }, REGISTRATION_TTL, origin)
313
+ const base = { registration_id: registrationId, registration_type: body.type, claim_url: `${origin}/agent/identity/claim`, claim_token: claimToken, claim_token_expires: new Date(expires * 1000).toISOString(), post_claim_scopes: [...SCOPES] }
314
+ if (body.type === 'anonymous') return c.json({ ...base, identity_assertion: assertion, assertion_expires: new Date(expires * 1000).toISOString(), pre_claim_scopes: [] }, 200, { 'cache-control': 'no-store' })
315
+ return c.json({ ...base, claim: await startClaim(origin, registrationId, body.login_hint!) }, 200, { 'cache-control': 'no-store' })
316
+ })
317
+
318
+ const startClaim = async (origin: string, registrationId: string, email: string) => {
319
+ const userCode = String(randomInt(0, 1_000_000)).padStart(6, '0')
320
+ const attempt = token('cla')
321
+ await store.put('claim-attempt', sha(attempt), { registration: registrationId, email: email.toLowerCase(), code: sha(userCode), tries: 0 }, now() + CLAIM_TTL)
322
+ return { user_code: userCode, expires_in: CLAIM_TTL, verification_uri: `${origin}/oauth/claim?claim_attempt_token=${attempt}`, interval: 5 }
323
+ }
324
+
325
+ app.post('/agent/identity/claim', async (c) => {
326
+ const body = (await c.req.json().catch(() => ({}))) as { claim_token?: string; email?: string }
327
+ const held = body.claim_token ? await store.get('claim-token', sha(body.claim_token)) : null
328
+ if (!held) return oauthError(c, 400, 'invalid_claim_token', 'This claim_token is wrong or expired: register again.')
329
+ const registration = await store.get('registration', String(held.registration))
330
+ if (!registration) return oauthError(c, 410, 'claim_expired', 'The registration expired before it was claimed: register again.')
331
+ if (registration.claimed) return oauthError(c, 400, 'claimed_or_in_flight', 'This registration is already claimed: poll the token endpoint.')
332
+ const email = (body.email ?? String(registration.email ?? '')).toLowerCase()
333
+ if (!email) return oauthError(c, 400, 'invalid_request', 'Send email: the address of the person who will approve you.')
334
+ if (config.owners?.length && !config.owners.map((one) => one.toLowerCase()).includes(email)) return oauthError(c, 403, 'access_denied', 'Only the owner of this service can claim an agent.')
335
+ const attempt = await startClaim(originOf(c), String(held.registration), email)
336
+ return c.json({ registration_id: held.registration, status: 'initiated', expires_at: new Date((now() + CLAIM_TTL) * 1000).toISOString(), claim_attempt: attempt }, 200, { 'cache-control': 'no-store' })
337
+ })
338
+
339
+ // The claim page: behind the single sign-on; the signed-in person must be the one the claim names
340
+ app.get('/oauth/claim', async (c) => {
341
+ const owner = await config.owner(c)
342
+ if (!owner) return c.html(page('Sign in first', '<h1>Sign in first</h1><p>Open this link in the browser where you are signed in to this site.</p>'), 401)
343
+ const attemptToken = c.req.query('claim_attempt_token') ?? ''
344
+ const attempt = await store.get('claim-attempt', sha(attemptToken))
345
+ if (!attempt) return c.html(page('Expired', '<h1>This code expired</h1><p>Ask the agent for a new one.</p>'), 410)
346
+ const registration = await store.get('registration', String(attempt.registration))
347
+ return c.html(page('Approve an agent', `<h1>Approve <strong>${escape(String(registration?.name ?? 'an agent'))}</strong>?</h1>
348
+ <p>You are signed in as ${escape(owner)}. Type the 6-digit code the agent gave you to let it read and change ${escape(config.name)} as you.</p>
349
+ <form method="post" action="/oauth/claim"><input type="hidden" name="claim_attempt_token" value="${escape(attemptToken)}"><input name="user_code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}" required aria-label="Code"><button>Approve</button></form>`))
350
+ })
351
+
352
+ app.post('/oauth/claim', async (c) => {
353
+ const owner = await config.owner(c)
354
+ const site = c.req.header('sec-fetch-site')
355
+ if (!owner || (site && site !== 'same-origin')) return c.html(page('Not allowed', '<h1>Not allowed</h1>'), 403)
356
+ const body = await bodyOf(c)
357
+ const key = sha(body.claim_attempt_token ?? '')
358
+ const attempt = await store.get('claim-attempt', key)
359
+ if (!attempt) return c.html(page('Expired', '<h1>This code expired</h1><p>Ask the agent for a new one.</p>'), 410)
360
+ if (String(attempt.email) !== owner.toLowerCase()) return c.html(page('Not yours', `<h1>This claim names someone else</h1><p>Sign in as the person the agent asked for.</p>`), 403)
361
+ if (!same(sha(body.user_code ?? ''), String(attempt.code))) {
362
+ const tries = Number(attempt.tries) + 1
363
+ if (tries >= 5) await store.delete('claim-attempt', key)
364
+ else await store.put('claim-attempt', key, { ...attempt, tries }, now() + CLAIM_TTL)
365
+ return c.html(page('Wrong code', `<h1>That code doesn’t match</h1><p>${tries >= 5 ? 'Too many tries: ask the agent for a new code.' : 'Check the code the agent gave you and try again.'}</p>`), 400)
366
+ }
367
+ await store.delete('claim-attempt', key)
368
+ const registration = await store.get('registration', String(attempt.registration))
369
+ if (!registration) return c.html(page('Expired', '<h1>This registration expired</h1>'), 410)
370
+ await store.put('registration', String(attempt.registration), { ...registration, claimed: true, owner }, now() + REGISTRATION_TTL)
371
+ return c.html(page('Approved', `<h1>Approved</h1><p>${escape(String(registration.name))} can now use ${escape(config.name)}. You can close this page.</p>`))
372
+ })
373
+
374
+ app.post('/oauth/token', async (c) => {
375
+ const body = await bodyOf(c)
376
+ const origin = originOf(c)
377
+ switch (body.grant_type) {
378
+ case 'authorization_code': {
379
+ const grant = body.code ? await store.get('code', sha(body.code)) : null
380
+ if (!grant) return oauthError(c, 400, 'invalid_grant', 'This code is used, expired or unknown.')
381
+ await store.delete('code', sha(body.code!))
382
+ if (grant.client !== body.client_id || grant.redirectUri !== body.redirect_uri) return oauthError(c, 400, 'invalid_grant', 'client_id or redirect_uri differ from the authorization request.')
383
+ const challenge = createHash('sha256').update(body.code_verifier ?? '').digest('base64url')
384
+ if (!body.code_verifier || !same(challenge, String(grant.challenge))) return oauthError(c, 400, 'invalid_grant', 'The code_verifier doesn’t match the code_challenge.')
385
+ const issued = { client: String(grant.client), name: String(grant.name), owner: String(grant.owner), scopes: grant.scopes as string[] }
386
+ return c.json({ ...(await issue(origin, issued)), refresh_token: await refreshFor(issued) }, 200, { 'cache-control': 'no-store' })
387
+ }
388
+ case 'refresh_token': {
389
+ const held = body.refresh_token ? await store.get('refresh', sha(body.refresh_token)) : null
390
+ if (!held || (await store.get('revoked-client', String(held.client)))) return oauthError(c, 400, 'invalid_grant', 'This refresh token is used, expired or revoked: authorize again.')
391
+ await store.delete('refresh', sha(body.refresh_token!))
392
+ const issued = held as { client: string; name: string; owner: string; scopes: string[] }
393
+ return c.json({ ...(await issue(origin, issued)), refresh_token: await refreshFor(issued) }, 200, { 'cache-control': 'no-store' })
394
+ }
395
+ case CLAIM_GRANT: {
396
+ const held = body.claim_token ? await store.get('claim-token', sha(body.claim_token)) : null
397
+ if (!held) return oauthError(c, 400, 'expired_token', 'The claim window closed: register again.')
398
+ const registration = await store.get('registration', String(held.registration))
399
+ if (!registration) return oauthError(c, 400, 'expired_token', 'The registration expired: register again.')
400
+ if (!registration.claimed) return oauthError(c, 400, 'authorization_pending', 'The owner hasn’t typed the code yet.')
401
+ await store.delete('claim-token', sha(body.claim_token!))
402
+ const issued = { client: String(held.registration), name: String(registration.name), owner: String(registration.owner), scopes: [...SCOPES] }
403
+ const assertion = await sign({ sub: held.registration, typ: 'agent_identity', email: registration.owner, email_verified: true }, REGISTRATION_TTL, origin)
404
+ return c.json({ ...(await issue(origin, issued)), identity_assertion: assertion, assertion_expires: new Date((now() + REGISTRATION_TTL) * 1000).toISOString() }, 200, { 'cache-control': 'no-store' })
405
+ }
406
+ case JWT_BEARER: {
407
+ const { publicKey } = await ready
408
+ const { payload } = await jwtVerify(body.assertion ?? '', publicKey, { issuer: origin, algorithms: ['ES256'] }).catch(() => ({ payload: null }))
409
+ const registration = payload?.typ === 'agent_identity' && typeof payload.sub === 'string' ? await store.get('registration', payload.sub) : null
410
+ if (!registration || (await store.get('revoked-client', String(payload!.sub)))) return oauthError(c, 400, 'invalid_grant', 'This assertion is expired, revoked or unknown: register again.')
411
+ if (!registration.claimed) return oauthError(c, 400, 'invalid_grant', 'Not claimed yet: an unclaimed registration has no scopes. Run the claim (auth.md step B2).')
412
+ return c.json(await issue(origin, { client: String(payload!.sub), name: String(registration.name), owner: String(registration.owner), scopes: [...SCOPES] }), 200, { 'cache-control': 'no-store' })
413
+ }
414
+ default:
415
+ return oauthError(c, 400, 'unsupported_grant_type', `grant_type must be one of: authorization_code, refresh_token, ${JWT_BEARER}, ${CLAIM_GRANT}.`)
416
+ }
417
+ })
418
+
419
+ // RFC 7009: always 200, whatever the token was
420
+ app.post('/oauth/revoke', async (c) => {
421
+ const body = await bodyOf(c)
422
+ const value = body.token ?? ''
423
+ const refresh = await store.get('refresh', sha(value))
424
+ if (refresh) await store.delete('refresh', sha(value))
425
+ const { publicKey } = await ready
426
+ const { payload } = await jwtVerify(value, publicKey, { issuer: originOf(c), algorithms: ['ES256'] }).catch(() => ({ payload: null }))
427
+ if (typeof payload?.jti === 'string') await store.delete('access', payload.jti)
428
+ return c.body(null, 200, { 'cache-control': 'no-store' })
429
+ })
430
+ }
431
+
432
+ /** Revokes every token a client or agent registration holds (the owner's kill switch). */
433
+ const revokeClient = (clientId: string) => store.put('revoked-client', clientId, {}, now() + REFRESH_TTL)
434
+
435
+ return { mount, verify, resourceMetadataFor, revokeClient, authorizationServer, protectedResource, authMd }
436
+ }
437
+
438
+ export type OAuth = ReturnType<typeof createOAuth>
439
+
@@ -0,0 +1,7 @@
1
+ /** The OAuth server's table (a key-value store with expiry), and the SQL that makes it. */
2
+ import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
3
+
4
+ export const oauthKeys = sqliteTable('oauth', { kind: text('kind').notNull(), id: text('id').notNull(), value: text('value').notNull(), expiresAt: integer('expires_at').notNull() }, (table) => [primaryKey({ columns: [table.kind, table.id] })])
5
+
6
+ /** The table as SQL, one step per change; step 1 is what every app's state already holds. */
7
+ export const OAUTH_MIGRATIONS = ['create table if not exists oauth (kind text not null, id text not null, value text not null, expires_at integer not null, primary key (kind, id))'] as const
@@ -0,0 +1,34 @@
1
+ /** Where the OAuth server keeps what it issued: in memory for tests, or in the app's database. */
2
+ import { and, eq, gt, lte } from 'drizzle-orm'
3
+ import { memoryStore, type KeyValueStore } from '../core/kv.js'
4
+ import { migrate, type Db } from '../db/index.js'
5
+ import { OAUTH_MIGRATIONS, oauthKeys } from './schema.js'
6
+
7
+ const now = () => Math.floor(Date.now() / 1000)
8
+
9
+ /** Where the server keeps what it issued. Values are small JSON objects; `expiresAt` is epoch seconds. */
10
+ export type OAuthStore = KeyValueStore
11
+
12
+ /** A store in memory, for tests and single-process scripts. */
13
+ export const memoryOAuthStore = memoryStore
14
+
15
+ /**
16
+ * A store in the app's database (`openDb`), so issued tokens survive a restart and ride along in the app's backup.
17
+ * Migrates its `oauth` table and drops expired rows when made.
18
+ *
19
+ * @example
20
+ * const store = await dbOAuthStore(await openDb(`file:${join(env.STATE_DIR, 'state.sqlite')}`))
21
+ */
22
+ export const dbOAuthStore = async (db: Db): Promise<OAuthStore> => {
23
+ await migrate(db, 'oauth', OAUTH_MIGRATIONS)
24
+ await db.delete(oauthKeys).where(lte(oauthKeys.expiresAt, now()))
25
+ const at = (kind: string, id: string) => and(eq(oauthKeys.kind, kind), eq(oauthKeys.id, id))
26
+ return {
27
+ get: async (kind, id) => {
28
+ const [row] = await db.select({ value: oauthKeys.value }).from(oauthKeys).where(and(at(kind, id), gt(oauthKeys.expiresAt, now())))
29
+ return row ? JSON.parse(row.value) : null
30
+ },
31
+ put: async (kind, id, value, expiresAt) => void (await db.insert(oauthKeys).values({ kind, id, value: JSON.stringify(value), expiresAt }).onConflictDoUpdate({ target: [oauthKeys.kind, oauthKeys.id], set: { value: JSON.stringify(value), expiresAt } })),
32
+ delete: async (kind, id) => void (await db.delete(oauthKeys).where(at(kind, id))),
33
+ }
34
+ }
@@ -0,0 +1,31 @@
1
+ # pagination
2
+
3
+ Cursor pages and sorting for lists of any size, so every list pages the same way: HAL with its items under
4
+ `_embedded.<name>`, `count` on the page, `total` only when it is cheap, and `_links.next` until there is no more.
5
+
6
+ Needs: `core`.
7
+
8
+ ```ts
9
+ import { cursorQuery, decodeCursor, limitQuery, pageOf, pageSchema, sortQuery } from './kit/index.js'
10
+
11
+ request: { query: z.object({ limit: limitQuery({ fallback: 50 }), cursor: cursorQuery, ...sortQuery(['newest', 'name'], 'newest') }) },
12
+ responses: { 200: { content: { [HAL_JSON]: { schema: pageSchema('RecipePage', 'recipes', RecipeSchema, 'Recipes, newest first.') } }, description: 'Recipes.' } },
13
+
14
+ const after = decodeCursor(query.cursor, z.object({ id: z.string() })) // 400 on a cursor it didn't make
15
+ const rows = await repo.after(after?.id ?? '', limit + 1) // one extra row says there is more
16
+ return c.json(pageOf({ origin, path: c.req.path, query, name: 'recipes', items: rows.slice(0, limit).map(card),
17
+ next: rows.length > limit ? { id: rows[limit - 1]!.id } : null }), 200)
18
+ ```
19
+
20
+ | Export | What |
21
+ |---|---|
22
+ | `limitQuery({ fallback, max })`, `cursorQuery`, `sortQuery(fields, fallback)` | the query parameters, described (`sortQuery` gives `sortBy` + `order`) |
23
+ | `pageOf({ origin, path, query, name, items, next, total })` | the page, with `_links.next` carrying the encoded cursor |
24
+ | `pageSchema(component, name, item, description)` | the page's OpenAPI schema |
25
+ | `encodeCursor` / `decodeCursor(cursor, schema)` | opaque cursors (base64url JSON), validated on the way in |
26
+
27
+ ## Rules
28
+
29
+ - Cursors, not page numbers, when the data can change between calls: rows inserted mid-read never shift a page.
30
+ - An agent follows `_links.next.href` until it is absent; it never builds a cursor itself.
31
+ - Standard: `docs/STANDARD.md` §4.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cursor paging for lists of any size: a page is HAL with its items under `_embedded.<name>`, `count` on the page,
3
+ * `total` only when it is cheap, and `_links.next` until there is no more. The cursor is opaque (base64url JSON the
4
+ * route chooses, usually the last row's sort key), so rows inserted mid-read never shift a page.
5
+ *
6
+ * @example
7
+ * const after = decodeCursor(query.cursor, z.object({ id: z.string() }))
8
+ * const rows = db.prepare('select … where id > ? order by id limit ?').all(after?.id ?? '', limit + 1)
9
+ * return c.json(pageOf({ origin, path: c.req.path, query, name: 'recipes', items: rows.slice(0, limit).map(card),
10
+ * next: rows.length > limit ? { id: rows[limit - 1].id } : null }))
11
+ */
12
+ import { z } from '@hono/zod-openapi'
13
+ import { linksOf, LinksSchema, pathWith } from '../core/hal.js'
14
+ import { ProblemError } from '../core/problem.js'
15
+
16
+ export const limitQuery = ({ fallback = 20, max = 200 } = {}) =>
17
+ z.coerce.number().int().min(1).max(max).default(fallback).openapi({ description: `How many items per page (1–${max}, default ${fallback}).`, example: fallback })
18
+
19
+ export const cursorQuery = z.string().max(2048).optional().openapi({ description: 'Where to continue: copy it from `_links.next`, never build it yourself.' })
20
+
21
+ export const encodeCursor = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
22
+
23
+ export const decodeCursor = <T>(cursor: string | undefined, schema: z.ZodType<T>): T | undefined => {
24
+ if (!cursor) return undefined
25
+ try {
26
+ return schema.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')))
27
+ } catch {
28
+ throw new ProblemError('bad_request', { detail: 'The cursor is not one this API issued (or it belongs to another list). Start again without `cursor`.', errors: [{ in: 'query', path: 'cursor', message: 'invalid cursor' }] })
29
+ }
30
+ }
31
+
32
+ type Query = Record<string, string | number | boolean | string[] | null | undefined>
33
+
34
+ /**
35
+ * `sortBy` (one of the documented fields) and `order` (`asc` or `desc`; omitted, each field's natural order), for a
36
+ * list's query schema.
37
+ *
38
+ * @example
39
+ * query: z.object({ ...sortQuery(['saves', 'name'], 'saves'), ...pageQuery() })
40
+ */
41
+ export const sortQuery = <Field extends string>(fields: [Field, ...Field[]], fallback: Field) => ({
42
+ sortBy: z.enum(fields).default(fallback as never).openapi({ description: `Order by: ${fields.join(', ')}.` }),
43
+ order: z.enum(['asc', 'desc']).optional().openapi({ description: 'Ascending or descending; omitted, each field’s natural order (counts high to low, names A to Z).' }),
44
+ })
45
+
46
+ export const pageOf = <Item>({ origin, path, query, name, items, next, total }: { origin: string; path: string; query: Query; name: string; items: Item[]; next: unknown; total?: number }) => ({
47
+ count: items.length,
48
+ ...(total === undefined ? {} : { total }),
49
+ _links: linksOf(origin, {
50
+ self: pathWith(path, query),
51
+ next: next === null || next === undefined ? null : pathWith(path, { ...query, cursor: encodeCursor(next) }),
52
+ }),
53
+ _embedded: { [name]: items },
54
+ })
55
+
56
+ /** The OpenAPI schema of a page whose items live under `_embedded.<name>`. */
57
+ export const pageSchema = <Item extends z.ZodTypeAny>(component: string, name: string, item: Item, description: string) =>
58
+ z
59
+ .object({
60
+ count: z.number().int().openapi({ description: 'Items on this page.' }),
61
+ total: z.number().int().optional().openapi({ description: 'Items in the whole list, when it is cheap to count.' }),
62
+ _links: LinksSchema.openapi({ description: '`self`, and `next` while there is more: keep following `next.href` until it is absent.' }),
63
+ _embedded: z.object({ [name]: z.array(item) }),
64
+ })
65
+ .openapi(component, { description })
@@ -0,0 +1,31 @@
1
+ # testing
2
+
3
+ Checks that keep an API agent-ready as it grows. Each returns a list of problems, `[]` when all is well, so a test is
4
+ one line and a failure says exactly what to fix.
5
+
6
+ Needs: `guide`.
7
+
8
+ ```ts
9
+ import { guideGaps, guideRoutes, lintOpenApi, toolRoutes, toolsRun } from './kit/testing/index.js'
10
+
11
+ const doc = await (await app.request('/openapi.json', { headers })).json()
12
+ expect(lintOpenApi(doc)).toEqual([])
13
+ expect(guideRoutes(guideFiles(GUIDE_DIR), doc)).toEqual([])
14
+ expect(guideGaps(guideFiles(GUIDE_DIR), vars, { doc, tools: TOOLS })).toEqual([])
15
+ expect(toolRoutes(TOOLS, doc)).toEqual([])
16
+ expect(await toolsRun(TOOLS, app, { search_notes: { q: 'soup' }, get_note: { slug: 'fox' } }, { headers })).toEqual([])
17
+ ```
18
+
19
+ | Check | Catches |
20
+ |---|---|
21
+ | `lintOpenApi` | an operation an agent can't find or understand: missing ids, summaries, descriptions, tags, examples, problem errors |
22
+ | `guideRoutes` | a `GET /api/…` the guide mentions that doesn't exist |
23
+ | `guideGaps` | a `{{placeholder}}` nothing fills (a typo like `{{api.endpoint}}`) |
24
+ | `toolRoutes` | a tool whose call names no documented route |
25
+ | `toolsRun` | a tool that names the right route and still fails when run: a mangled id, a wrong parameter, a summary showing `undefined` or throwing. A tool with no sample arguments fails it, so a new tool can't ship untested |
26
+
27
+ ## Rules
28
+
29
+ - Run `toolsRun` over every tool in every app, with sample ids from the test's fixture data; tools run in order, so
30
+ make what they need (a board) first and put deleting tools last.
31
+ - Give the app an injectable embedder and data paths (`createApp(env, { embedder })`) so tests need no network or reload.