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