@fabrika/app 0.3.2 → 0.4.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 (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,1085 @@
1
+ // The per-request pipeline of a contract application (ADR-0012 §5): canonical path and endpoint selection, the
2
+ // endpoint the proxy applied (ADR-0022 rule 5), the token, the caller kind, the requirement, admission, strict input, the access form, the handler, output
3
+ // projection. The caller-kind, requirement and access checks are authorization; the same-origin and content-type
4
+ // checks are admission. Neither is the proxy's decision: the proxy has already decided that the request may reach us.
5
+ // The anonymous kinds (health, form, webhook, the share SPA) read no token and take their own fixed steps.
6
+
7
+ import type { IamMembership, MembershipRequestCaller } from '@fabrika/auth'
8
+ import {
9
+ type CompiledEndpointTable,
10
+ type CredentialedEndpoint,
11
+ DELEGATION_HEADER,
12
+ ENDPOINT_HEADER,
13
+ type EndpointPlan,
14
+ type EndpointRow,
15
+ type ExportDescription,
16
+ isAttachmentShape,
17
+ type LinkTokenClaims,
18
+ parseEndpointHeader,
19
+ type PermissionEntry,
20
+ permits,
21
+ planEndpoint,
22
+ PROXY_SECRET_HEADER,
23
+ PROXY_TOKEN_HEADER,
24
+ rawRequestTarget,
25
+ satisfies,
26
+ scopedValues,
27
+ sha256Hex,
28
+ } from '@fabrika/auth-core'
29
+ import { type CapabilityAuthority, type ContainerGrant, MAX_WITHIN_VALUES } from '@fabrika/db/capability'
30
+ import type { DataAccess, FrameworkTables } from '@fabrika/db/framework'
31
+ import type { DataKind } from '@fabrika/db/tables'
32
+ import type { TokenVerifier } from '@fabrika/jwks'
33
+ import { createHash, timingSafeEqual } from 'node:crypto'
34
+ import { z } from 'zod/v4'
35
+ import { BROWSER_CONFIG_FILE, joinPath, PAGE_CALLER_FILE, WEBHOOK_BINDING_PARAMETER } from '../contract/defaults'
36
+ import type { Schema } from '../contract/define'
37
+ import {
38
+ BadRequestError,
39
+ ForbiddenError,
40
+ HttpError,
41
+ isStructuralError,
42
+ jsonResponse,
43
+ NotFoundError,
44
+ toErrorResponse,
45
+ UnauthorizedError,
46
+ } from '../errors'
47
+ import type { RequestExecutionContext } from '../fetch'
48
+ import type { FileCollections } from '../files/collections'
49
+ import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
50
+ import { bootstrapPath, bootstrapResponse, githubAccounts, readBoundedBody, verifyGithubSignature, withBootstrap } from './anonymous'
51
+ import { attachmentResponse } from './attachment'
52
+ import { handlerSignal } from './bounded'
53
+ import type { BrowserConfig } from './browser-config'
54
+ import { CapabilityLimitError, type CapabilityMint, RequestFrame, runInRequest } from './capability'
55
+ import { CHALLENGE_LIMITS, type ChallengeVerifier } from './challenge'
56
+ import { connectContext, type ImportWire } from './connect'
57
+ import type { FailureReports } from './failures'
58
+ import { ServerFault } from './faults'
59
+ import { ACCEPTED_BUCKET, clientBucket, type SubmissionFields, submissionOf } from './form-admission'
60
+ import { type HeaderPolicy, withHeaderPolicy } from './headers'
61
+ import type { ContractDatabase } from './implement'
62
+ import { endpointData, Invocation, noFileCollections, siteData, type SiteDeclaration, undeclaredSql } from './invocation'
63
+ import {
64
+ applicationDirectory,
65
+ createScope,
66
+ type IssuableLink,
67
+ linkIssuing,
68
+ memberDirectory,
69
+ memberManagement,
70
+ type MembershipRequest,
71
+ type ReadDirectory,
72
+ } from './membership'
73
+ import { pageCallerOf, presentedBy, type Principal } from './principal'
74
+ import { project, ProjectionRefusedError } from './project'
75
+ import { deliveryKey, WEBHOOK_REPLAY_RETENTION_MS, type WebhookPrincipal, webhookSubject } from './webhook'
76
+ import type { WorkloadCredential } from './workload'
77
+
78
+ /** SHA-256 of a proxy secret: 32 bytes whatever the secret's length, as `timingSafeEqual` needs. */
79
+ export function proxySecretDigest(secret: string): Uint8Array {
80
+ return createHash('sha256').update(secret).digest()
81
+ }
82
+
83
+ /** Serves the files of an SPA mount. The runtime adapter supplies it: Workers Assets, or the build output on disk. */
84
+ export interface SpaAssets {
85
+ serve(mount: { readonly prefix: string; readonly dir: string }, request: Request): Promise<Response>
86
+ }
87
+
88
+ /** What a procedure's context carries beyond its access form, decided once from the artifact. */
89
+ export interface Facilities {
90
+ /**
91
+ * `ctx.members`: management where the action is the dimension's `members.manage`; otherwise the member directory of the
92
+ * nearest dimension, the endpoint's own or one containing it, that declares members (`endpointDirectory`); for an
93
+ * `app` endpoint of the application's `members.list`, its own people (`endpointAppDirectory`).
94
+ */
95
+ readonly members: 'manage' | ReadDirectory | 'application' | null
96
+ /** The link mounts whose dimension and issuing action are the endpoint's. */
97
+ readonly links: readonly IssuableLink[]
98
+ }
99
+
100
+ export const NO_FACILITIES: Facilities = Object.freeze({ members: null, links: Object.freeze([]) })
101
+
102
+ /** A staged-kit site: its handler reaches data through `ctx.db`, in the invocation's transaction. */
103
+ export interface ServedSite {
104
+ /** A query and an export read only, in a `READ ONLY` transaction; a mutation also writes. */
105
+ readonly kind: DataKind
106
+ readonly declaration: SiteDeclaration
107
+ }
108
+
109
+ export type ServedEndpoint =
110
+ | {
111
+ readonly kind: 'procedure'
112
+ readonly row: CredentialedEndpoint
113
+ readonly input: Schema | null
114
+ readonly output: Schema
115
+ readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }) => unknown
116
+ readonly facilities: Facilities
117
+ /** An export procedure: its request may only read. */
118
+ readonly readOnly?: true
119
+ /** `ctx.connect`, for an endpoint an import's `usedBy` names. */
120
+ readonly connect?: readonly ImportWire[]
121
+ /** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
122
+ readonly site?: ServedSite
123
+ }
124
+ /** What an export's consumer vendors, behind the export's own callers. */
125
+ | { readonly kind: 'description'; readonly row: CredentialedEndpoint; readonly description: ExportDescription }
126
+ | { readonly kind: 'spa'; readonly row: CredentialedEndpoint; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
127
+ /** An SPA anyone opens: anonymous, served without a token. */
128
+ | { readonly kind: 'public-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
129
+ | { readonly kind: 'health'; readonly row: EndpointRow }
130
+ | {
131
+ /** A file collection's upload or download, served by `src/files` once the access form minted its target. */
132
+ readonly kind: 'file'
133
+ readonly row: CredentialedEndpoint
134
+ readonly serve: (request: Request, target: unknown) => Promise<Response | FileDownload>
135
+ /**
136
+ * A link holder's download: the `file:<name>` dimension whose `:fileId` must lie beneath the link's object. Its
137
+ * row is `on` the owner's dimension, the link's object; the file is resolved under it.
138
+ */
139
+ readonly beneath?: string
140
+ }
141
+ /** The shell of a link mount's share SPA: anonymous, and served with the fragment bootstrap. */
142
+ | { readonly kind: 'link-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string }
143
+ | {
144
+ readonly kind: 'form'
145
+ readonly row: EndpointRow
146
+ readonly form: string
147
+ readonly input: Schema
148
+ readonly admission: FormAdmission
149
+ /** The job each accepted submission kicks once its row committed; `null` without `then`. */
150
+ readonly then: string | null
151
+ }
152
+ | {
153
+ readonly kind: 'webhook'
154
+ readonly row: EndpointRow
155
+ readonly webhook: string
156
+ readonly dimension: string
157
+ readonly actions: readonly string[]
158
+ readonly input: Schema
159
+ readonly handler: (args: { readonly input: unknown; readonly ctx: object }) => unknown
160
+ /** The webhook's key: its refs carry only what IAM signs for it. */
161
+ readonly credential: WorkloadCredential
162
+ /** A staged-kit webhook's declaration: its delivery runs in one transaction, through `ctx.db`. */
163
+ readonly site?: SiteDeclaration
164
+ }
165
+
166
+ /** What a form admits, decided once at boot from the approved artifact and the application's variables. */
167
+ export interface FormAdmission {
168
+ /** Requests per minute from one client, admitted before the body is read. */
169
+ readonly perClient: number
170
+ /** Submissions per minute the form accepts in all: only one that passed its honeypot and its challenge counts. */
171
+ readonly perForm: number
172
+ readonly fields: SubmissionFields
173
+ /** The challenge's verifier, with the secret the application variable holds; `null` without a challenge. */
174
+ readonly challenge: ChallengeVerifier | null
175
+ }
176
+
177
+ export interface RuntimeParts {
178
+ readonly table: CompiledEndpointTable
179
+ readonly endpoints: ReadonlyMap<string, ServedEndpoint>
180
+ /** Endpoint id → `requirementDigest` of its row: what the proxy's `X-Fabrika-Endpoint` must name. */
181
+ readonly digests: ReadonlyMap<string, string>
182
+ readonly verifier: TokenVerifier
183
+ /** The token audience: this application's IAM id in this environment, `<app>@<env>`. */
184
+ readonly audience: string
185
+ /** `proxySecretDigest` of the proxy secret every request must carry; `null` where the hop is a service binding. */
186
+ readonly proxySecretDigest: Uint8Array | null
187
+ readonly policy: HeaderPolicy
188
+ readonly database: ContractDatabase | null
189
+ /** Dimensions a table owns, so `in` and `on` must find the object. */
190
+ readonly ownedDimensions: ReadonlySet<string>
191
+ /** Each dimension's containers, nearest first; a grant on one reaches every value inside it. */
192
+ readonly containers: ReadonlyMap<string, readonly string[]>
193
+ readonly assets: SpaAssets | null
194
+ /** IAM's membership operations; `boot` refuses a contract that needs them when the adapter supplies none. */
195
+ readonly membership: IamMembership | null
196
+ /** The framework's own writes (inboxes, admissions, webhook tables); `boot` refuses a form or webhook without them. */
197
+ readonly framework: FrameworkTables | null
198
+ /** This runtime's capability mint, which `boot` alone holds, and the authority its stores are bound to. */
199
+ readonly mint: CapabilityMint
200
+ readonly capabilities: CapabilityAuthority
201
+ /** The mini-ORM of the bound `scoped(tables)` layer; `boot` refuses a site without one. */
202
+ readonly data: DataAccess | null
203
+ /** The contract's file collections, behind a staged-kit site's `ctx.files`; `null` when it declares none. */
204
+ readonly files: FileCollections | null
205
+ /** Where a 5xx goes: the server log, and Operations when the application has a DSN. */
206
+ readonly failures: FailureReports
207
+ /** Kicks a contract job under its own key, in the background (`BoundJobs.kick`); it never rejects. */
208
+ readonly kick: (job: string) => Promise<void>
209
+ /** The contract's actions, sorted: what a caller document lists a pattern grant as. */
210
+ readonly actions: readonly string[]
211
+ }
212
+
213
+ interface Caller {
214
+ readonly principal: Principal
215
+ /** The principal id a capability is granted to: the token's subject. */
216
+ readonly subject: string
217
+ readonly perms: PermissionEntry[]
218
+ /** The verified token, which IAM resolves the caller from on a membership request. */
219
+ readonly token: string
220
+ /** The mount and chain a share-link token is bound to. */
221
+ readonly link: LinkTokenClaims | null
222
+ }
223
+
224
+ /** The proxy's id of this request (proxy-core's `REQUEST_ID_HEADER`); a delegation grant is bound to it. */
225
+ const REQUEST_ID_HEADER = 'X-Request-Id'
226
+ /** A binding id is a UUID; anything else names no binding and reaches no query. */
227
+ const BINDING_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
228
+
229
+ interface Access {
230
+ readonly context: Readonly<Record<string, unknown>>
231
+ readonly input: Readonly<Record<string, unknown>>
232
+ }
233
+
234
+ function isRecord(value: unknown): value is Record<string, unknown> {
235
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
236
+ }
237
+
238
+ /** The media type of a `Content-Type` value, without parameters, lower-cased. */
239
+ function mediaType(header: string | null): string | null {
240
+ const type = header?.split(';')[0]?.trim().toLowerCase()
241
+ return type === undefined || type === '' ? null : type
242
+ }
243
+
244
+ function describeRequirement(row: CredentialedEndpoint): string {
245
+ return row.requirement.kind === 'member' ? 'a permission in this application' : `'${row.requirement.action}' in some scope`
246
+ }
247
+
248
+ export class ContractRuntime {
249
+ readonly #parts: RuntimeParts
250
+
251
+ constructor(parts: RuntimeParts) {
252
+ this.#parts = parts
253
+ }
254
+
255
+ /**
256
+ * Every answer of a download endpoint, a denial included, carries the file treatment; every other one the policy.
257
+ * `exec` keeps a failure's report alive past the response, where the runtime would otherwise end it.
258
+ */
259
+ async fetch(request: Request, exec?: RequestExecutionContext): Promise<Response> {
260
+ const plan = planEndpoint(this.#parts.table, request.method, rawRequestTarget(request.url).pathname)
261
+ const answer = await this.#answer(request, plan, exec)
262
+ if (answer instanceof FileDownload) {
263
+ return fileResponse(answer)
264
+ }
265
+ return plan.outcome === 'endpoint' && plan.endpoint.kind === 'download' ? withFileTreatment(answer) : withHeaderPolicy(answer, this.#parts.policy)
266
+ }
267
+
268
+ async #answer(request: Request, plan: EndpointPlan, exec: RequestExecutionContext | undefined): Promise<Response | FileDownload> {
269
+ let where = 'request'
270
+ try {
271
+ const served = plan.outcome === 'endpoint' ? this.#parts.endpoints.get(plan.endpoint.id) : undefined
272
+ // Before any other answer: a direct dial learns nothing, not even whether its path exists.
273
+ if (served?.kind !== 'health' && !this.#throughProxy(request)) {
274
+ throw new ForbiddenError('This application answers only its proxy')
275
+ }
276
+ if (plan.outcome === 'non-canonical') {
277
+ throw new BadRequestError('The request path is not canonical')
278
+ }
279
+ if (plan.outcome !== 'endpoint' || served === undefined) {
280
+ throw new NotFoundError(`No endpoint serves ${request.method} ${rawRequestTarget(request.url).pathname}`)
281
+ }
282
+ where = served.row.id
283
+ this.#checkEndpoint(served, request)
284
+ switch (served.kind) {
285
+ case 'health':
286
+ return jsonResponse({ status: 'ok' }, 200)
287
+ case 'spa': {
288
+ const caller = await this.#authorize(served.row, request)
289
+ return await this.#spaFile(served, request, caller)
290
+ }
291
+ case 'public-spa':
292
+ return await this.#spaFile(served, request, null)
293
+ case 'link-spa':
294
+ if (rawRequestTarget(request.url).pathname === bootstrapPath(served.prefix)) {
295
+ return bootstrapResponse()
296
+ }
297
+ return await withBootstrap(await this.#assets().serve({ prefix: served.prefix, dir: served.dir }, request), served.prefix)
298
+ case 'procedure':
299
+ return await this.#procedure(served, request)
300
+ case 'file':
301
+ return await this.#file(served, request, plan.params)
302
+ case 'form':
303
+ return await this.#form(served, request, exec)
304
+ case 'webhook':
305
+ return await this.#webhook(served, request, plan.params)
306
+ case 'description':
307
+ await this.#authorize(served.row, request)
308
+ await readInput({ row: served.row, input: null }, request)
309
+ return jsonResponse({ result: served.description }, 200)
310
+ }
311
+ } catch (error) {
312
+ const response = errorResponse(error, where)
313
+ if (response.status >= 500) {
314
+ const failure = { endpoint: where, requestId: request.headers.get(REQUEST_ID_HEADER), status: response.status, error }
315
+ const delivery = this.#parts.failures.record(failure)
316
+ exec?.waitUntil(delivery)
317
+ }
318
+ return response
319
+ }
320
+ }
321
+
322
+ /**
323
+ * An SPA mount's files, at `<prefix>/__fabrika-config.json` its browser config (`browser-config.ts`), and at
324
+ * `<prefix>/__fabrika-me.json` the caller document of the person the mount admitted. A public mount admits no one:
325
+ * the proxy forwards no token there, so it cannot say whether the visitor is signed in.
326
+ */
327
+ async #spaFile(
328
+ served: { readonly prefix: string; readonly dir: string; readonly config: BrowserConfig },
329
+ request: Request,
330
+ caller: Caller | null,
331
+ ): Promise<Response> {
332
+ const path = rawRequestTarget(request.url).pathname
333
+ if (path === joinPath(served.prefix, BROWSER_CONFIG_FILE)) {
334
+ return jsonResponse(served.config, 200)
335
+ }
336
+ if (path === joinPath(served.prefix, PAGE_CALLER_FILE)) {
337
+ if (caller === null || caller.principal.kind !== 'user') {
338
+ throw new UnauthorizedError('A public page carries no caller')
339
+ }
340
+ return jsonResponse(pageCallerOf(caller.principal, caller.perms, this.#parts.actions), 200)
341
+ }
342
+ return await this.#assets().serve({ prefix: served.prefix, dir: served.dir }, request)
343
+ }
344
+
345
+ /**
346
+ * The proxy proves it sent the request with the secret of this application environment (ADR-0022 rule 5). The
347
+ * fixed-size digests are compared in constant time, so neither the length nor a prefix of the secret leaks.
348
+ */
349
+ #throughProxy(request: Request): boolean {
350
+ const expected = this.#parts.proxySecretDigest
351
+ if (expected === null) {
352
+ return true
353
+ }
354
+ const presented = request.headers.get(PROXY_SECRET_HEADER) ?? ''
355
+ return presented !== '' && timingSafeEqual(proxySecretDigest(presented), expected)
356
+ }
357
+
358
+ /**
359
+ * The proxy names the endpoint it selected and the digest of the rules it applied. Any disagreement with this
360
+ * version's own selection or rules is a 503, so no request is served under rules the proxy did not apply. A
361
+ * health endpoint answers a constant and is what the platform's liveness probe dials directly, so it alone may
362
+ * arrive without the header.
363
+ */
364
+ #checkEndpoint(served: ServedEndpoint, request: Request): void {
365
+ const header = request.headers.get(ENDPOINT_HEADER)
366
+ if (header === null && served.kind === 'health') {
367
+ return
368
+ }
369
+ const named = header === null ? null : parseEndpointHeader(header)
370
+ if (named === null) {
371
+ throw new ServerFault('endpoint_decision_missing', `'${served.row.id}' was reached without the proxy's endpoint decision`, 503)
372
+ }
373
+ if (named.id !== served.row.id || named.digest !== this.#parts.digests.get(served.row.id)) {
374
+ throw new ServerFault(
375
+ 'endpoint_decision_mismatch',
376
+ `the proxy applied '${named.id}' with other rules than this version serves '${served.row.id}' with`,
377
+ 503,
378
+ )
379
+ }
380
+ }
381
+
382
+ #assets(): SpaAssets {
383
+ const assets = this.#parts.assets
384
+ if (assets === null) {
385
+ throw new ServerFault('facility_unbound', 'no asset server serves the SPA')
386
+ }
387
+ return assets
388
+ }
389
+
390
+ /** Token, caller kind, requirement, then admission. */
391
+ async #authorize(row: CredentialedEndpoint, request: Request): Promise<Caller> {
392
+ const token = request.headers.get(PROXY_TOKEN_HEADER)?.trim() ?? ''
393
+ if (token === '') {
394
+ throw new UnauthorizedError('This endpoint needs a credential, and the request carries none')
395
+ }
396
+ const verified = await this.#parts.verifier.verify(token, this.#parts.audience)
397
+ if (!verified.ok) {
398
+ if (verified.reason === 'unavailable') {
399
+ throw new ServerFault('key_set_unavailable', 'the IAM key set could not be fetched; nothing was checked', 503)
400
+ }
401
+ throw new UnauthorizedError('The credential does not verify')
402
+ }
403
+ const presented = presentedBy(verified.claims)
404
+ if (presented === 'no_mailbox') {
405
+ throw new UnauthorizedError('The credential names a person without an address')
406
+ }
407
+ if (presented === 'no_caller' || !row.callers.includes(presented.caller)) {
408
+ const caller = presented === 'no_caller' ? 'resource' : presented.caller
409
+ throw new ForbiddenError(`A ${caller} caller cannot call '${row.id}' (callers: ${row.callers.join(', ')})`)
410
+ }
411
+ if (!satisfies(row.requirement, verified.claims.perms)) {
412
+ throw new ForbiddenError(`'${row.id}' needs ${describeRequirement(row)}, and the caller holds none`)
413
+ }
414
+ admit(row, request)
415
+ return {
416
+ principal: presented.principal,
417
+ subject: verified.claims.sub,
418
+ perms: verified.claims.perms,
419
+ token,
420
+ link: verified.claims.link ?? null,
421
+ }
422
+ }
423
+
424
+ /**
425
+ * A staged-kit site runs in one transaction: its statements commit after the handler returned, the output passed
426
+ * validation and an attachment was encoded, and roll back on a throw, a refused output, a file over its limit or a
427
+ * cancelled request. So an audited export keeps its log row only when its file goes out.
428
+ */
429
+ async #procedure(served: Extract<ServedEndpoint, { kind: 'procedure' }>, request: Request): Promise<Response> {
430
+ const caller = await this.#authorize(served.row, request)
431
+ const input = await readInput(served, request)
432
+ const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.readOnly === true, endpoint: served.row.id })
433
+ const site = served.site
434
+ const invocation = site === undefined ? null : new Invocation(frame, this.#data(), site.kind === 'query' || served.readOnly === true)
435
+ const signal = handlerSignal(request)
436
+ try {
437
+ let result: unknown
438
+ try {
439
+ result = await runInRequest(frame, async () => {
440
+ const membership = this.#membershipRequest(served.row, caller, request, frame)
441
+ const access = await this.#access(served.row, caller, input, frame, membership)
442
+ const ctx = Object.freeze({
443
+ principal: caller.principal,
444
+ requestId: frame.id,
445
+ signal,
446
+ ...access.context,
447
+ ...this.#facilities(served.facilities, served.row, membership, access.context['target'], {
448
+ credential: caller.token,
449
+ requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id,
450
+ }),
451
+ ...(served.connect === undefined ? {} : { connect: connectContext(served.connect, frame) }),
452
+ ...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context)),
453
+ // An export's consumer reaches its rows only, never the files of their owners.
454
+ ...(site === undefined || invocation === null || served.readOnly === true
455
+ ? {}
456
+ : { files: this.#files(served.row.id, frame, invocation, site.kind) }),
457
+ })
458
+ return await served.handler({ input: access.input, ctx })
459
+ })
460
+ } finally {
461
+ frame.end()
462
+ }
463
+ const answer = this.#answerOf(served, result)
464
+ await invocation?.commit(signal)
465
+ return answer
466
+ } finally {
467
+ await invocation?.rollback()
468
+ }
469
+ }
470
+
471
+ /**
472
+ * The output as JSON, projected and validated whole; or an attachment's file, whose rows are projected, validated
473
+ * and encoded one at a time, so a result over a limit stops before it is copied.
474
+ */
475
+ #answerOf(served: Extract<ServedEndpoint, { kind: 'procedure' }>, result: unknown): Response {
476
+ const file = served.row.output !== null && isAttachmentShape(served.row.output) ? served.row.output : null
477
+ if (file !== null) {
478
+ return attachmentResponse(served.row.id, file, served.output, result)
479
+ }
480
+ const output = z.safeParse(served.output, projectOutput(served, result))
481
+ if (!output.success) {
482
+ throw new ServerFault('output_refused', `'${served.row.id}' returned a value its output schema refuses`)
483
+ }
484
+ return jsonResponse({ result: output.data }, 200)
485
+ }
486
+
487
+ /** `ctx.files` of a staged-kit site, in its invocation's transaction. */
488
+ #files(endpoint: string, frame: RequestFrame, invocation: Invocation, kind: DataKind): object {
489
+ const where = `'${endpoint}'`
490
+ return this.#parts.files === null ? noFileCollections(where) : this.#parts.files.site({ frame, ...invocation.fileSite(where, kind) })
491
+ }
492
+
493
+ #data(): DataAccess {
494
+ const data = this.#parts.data
495
+ if (data === null) {
496
+ throw new ServerFault('facility_unbound', 'a site reaches data through ctx.db, and boot bound no @fabrika/db layer')
497
+ }
498
+ return data
499
+ }
500
+
501
+ /**
502
+ * The access form reads its value from the path parameter it names: `:owner` of an upload, `:fileId` of a download.
503
+ * A link holder's row names none: its owner is the link's object, checked as a link procedure's is.
504
+ */
505
+ async #file(
506
+ served: Extract<ServedEndpoint, { kind: 'file' }>,
507
+ request: Request,
508
+ params: Readonly<Record<string, string>>,
509
+ ): Promise<Response | FileDownload> {
510
+ const caller = await this.#authorize(served.row, request)
511
+ const access = served.row.requirement.kind === 'action' ? served.row.requirement.access : null
512
+ if (access?.form === 'on' && access.from === null && served.row.requirement.kind === 'action') {
513
+ return await this.#linkFile(served, request, params, caller, served.row.requirement.action, access.dimension)
514
+ }
515
+ const from = access?.form === 'on' ? access.from : null
516
+ const raw = from === null ? undefined : params[from]
517
+ if (from === null || raw === undefined) {
518
+ throw new ServerFault('contract_inconsistent', `'${served.row.id}' names no path parameter for its access form`)
519
+ }
520
+ const frame = new RequestFrame(crypto.randomUUID())
521
+ try {
522
+ return await runInRequest(frame, async () => {
523
+ const { context } = await this.#access(served.row, caller, { [from]: decodePathParameter(raw) }, frame, null)
524
+ return await served.serve(request, context['target'])
525
+ })
526
+ } finally {
527
+ frame.end()
528
+ }
529
+ }
530
+
531
+ /**
532
+ * A link holder's upload or download. The link's mount and its chain are checked as `#linkTarget` checks them; a
533
+ * download then resolves `:fileId` beneath that object, and a file of any other object is indistinguishable from a
534
+ * missing one.
535
+ */
536
+ async #linkFile(
537
+ served: Extract<ServedEndpoint, { kind: 'file' }>,
538
+ request: Request,
539
+ params: Readonly<Record<string, string>>,
540
+ caller: Caller,
541
+ action: string,
542
+ dimension: string,
543
+ ): Promise<Response | FileDownload> {
544
+ const frame = new RequestFrame(crypto.randomUUID())
545
+ try {
546
+ return await runInRequest(frame, async () => {
547
+ const owner = await this.#linkTarget(served.row, caller, action, dimension, {}, frame)
548
+ const beneath = served.beneath
549
+ if (beneath === undefined) {
550
+ return await served.serve(request, owner.context['target'])
551
+ }
552
+ const raw = params['fileId']
553
+ const fileId = raw === undefined ? '' : decodePathParameter(raw)
554
+ const [object, ...containers] = caller.link?.chain ?? []
555
+ const chain = fileId === '' ? null : await this.#chain(beneath, fileId)
556
+ const ownedHere = chain !== null && object !== undefined && chain.get(dimension) === object.value
557
+ && containers.every((scope) => chain.get(scope.type) === scope.value)
558
+ if (chain === null || object === undefined || !ownedHere) {
559
+ throw new NotFoundError('Not found')
560
+ }
561
+ return await served.serve(request, this.#parts.mint.ref(frame, beneath, action, caller.subject, fileId, chain))
562
+ })
563
+ } finally {
564
+ frame.end()
565
+ }
566
+ }
567
+
568
+ /** The caller's authority at IAM for this request, when the endpoint's access form names a dimension and an action. */
569
+ #membershipRequest(row: CredentialedEndpoint, caller: Caller, request: Request, frame: RequestFrame): MembershipRequest | null {
570
+ const iam = this.#parts.membership
571
+ if (iam === null || row.requirement.kind !== 'action' || row.requirement.access.form === 'app') {
572
+ return null
573
+ }
574
+ return {
575
+ iam,
576
+ caller: { credential: caller.token, requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id },
577
+ delegation: request.headers.get(DELEGATION_HEADER),
578
+ dimension: row.requirement.access.dimension,
579
+ action: row.requirement.action,
580
+ capabilities: this.#parts.capabilities,
581
+ }
582
+ }
583
+
584
+ /** `ctx.members` and `ctx.links`, for exactly the endpoints the artifact gives them. */
585
+ #facilities(
586
+ facilities: Facilities,
587
+ row: CredentialedEndpoint,
588
+ membership: MembershipRequest | null,
589
+ target: unknown,
590
+ caller: MembershipRequestCaller,
591
+ ): Readonly<Record<string, unknown>> {
592
+ if (facilities.members === 'application') {
593
+ return { members: applicationDirectory(this.#parts.membership, caller, row.id) }
594
+ }
595
+ const reads = facilities.members === null || facilities.members === 'manage' || row.requirement.kind !== 'action' ? null : facilities.members
596
+ const directory = reads === null ? {} : { members: memberDirectory(membership, target, { endpoint: row.id, ...reads }) }
597
+ if (facilities.members !== 'manage' && facilities.links.length === 0) {
598
+ return directory
599
+ }
600
+ if (membership === null) {
601
+ throw new ServerFault('facility_unbound', `'${row.id}' manages members or links, and boot bound no IAM membership transport`)
602
+ }
603
+ const members = facilities.members === 'manage' ? { members: memberManagement(membership) } : directory
604
+ const links = facilities.links.length === 0
605
+ ? {}
606
+ : { links: linkIssuing(membership, facilities.links, this.#parts.containers.get(membership.dimension) ?? []) }
607
+ return { ...members, ...links }
608
+ }
609
+
610
+ async #access(
611
+ row: CredentialedEndpoint,
612
+ caller: Caller,
613
+ input: Readonly<Record<string, unknown>>,
614
+ frame: RequestFrame,
615
+ membership: MembershipRequest | null,
616
+ ): Promise<Access> {
617
+ if (row.requirement.kind === 'member') {
618
+ return { context: {}, input }
619
+ }
620
+ const { action, access } = row.requirement
621
+ switch (access.form) {
622
+ case 'over': {
623
+ const direct = scopedValues(caller.perms, action, access.dimension)
624
+ if (direct === null) {
625
+ return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, 'all') }, input }
626
+ }
627
+ const held = this.#heldContainers(caller.perms, action, access.dimension)
628
+ if (direct.length === 0 && held.length === 0) {
629
+ throw new ForbiddenError(`The caller holds '${action}' in no ${access.dimension}`)
630
+ }
631
+ return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, direct, held) }, input }
632
+ }
633
+ case 'in': {
634
+ const { value, rest } = takeField(input, access.from)
635
+ const direct = permits(caller.perms, action, { type: access.dimension, value })
636
+ // Without a grant on the value or on a container of its dimension, nothing is read: 403 whether it exists or not.
637
+ if (!direct && this.#heldContainers(caller.perms, action, access.dimension).length === 0) {
638
+ throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
639
+ }
640
+ const chain = await this.#chain(access.dimension, value)
641
+ if (!holdsOnChain(caller.perms, action, access.dimension, value, chain ?? new Map())) {
642
+ throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
643
+ }
644
+ if (chain === null) {
645
+ throw new NotFoundError('Not found')
646
+ }
647
+ return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
648
+ }
649
+ case 'on': {
650
+ if (access.from === null) {
651
+ return await this.#linkTarget(row, caller, action, access.dimension, input, frame)
652
+ }
653
+ const { value, rest } = takeField(input, access.from)
654
+ const chain = await this.#chain(access.dimension, value)
655
+ // An object the caller may not act on is indistinguishable from a missing one.
656
+ if (chain === null || !holdsOnChain(caller.perms, action, access.dimension, value, chain)) {
657
+ throw new NotFoundError('Not found')
658
+ }
659
+ return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
660
+ }
661
+ case 'creates': {
662
+ if (membership === null) {
663
+ throw new ServerFault('facility_unbound', `'${row.id}' creates a scope, and boot bound no IAM membership transport`)
664
+ }
665
+ const value = await createScope(membership)
666
+ return { context: { created: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, new Map()) }, input }
667
+ }
668
+ case 'app': {
669
+ // `#authorize` ran `satisfies`, which the app form meets only with a global grant of the action.
670
+ return { context: { app: this.#parts.mint.app(frame, action, caller.subject) }, input }
671
+ }
672
+ }
673
+ }
674
+
675
+ /**
676
+ * A link procedure acts on the object its key is bound to. The key's mount must be this endpoint's, and the chain
677
+ * it was issued for must still be where the object lies: a link for a note moved since stops working.
678
+ */
679
+ async #linkTarget(
680
+ row: CredentialedEndpoint,
681
+ caller: Caller,
682
+ action: string,
683
+ dimension: string,
684
+ input: Readonly<Record<string, unknown>>,
685
+ frame: RequestFrame,
686
+ ): Promise<Access> {
687
+ const link = caller.link
688
+ if (link === null || link.mount !== row.source.name) {
689
+ throw new ForbiddenError(`'${row.id}' admits only a key of the link mount '${row.source.name}'`)
690
+ }
691
+ const [object, ...containers] = link.chain
692
+ if (object === undefined || object.type !== dimension || !permits(caller.perms, action, object)) {
693
+ throw new ForbiddenError(`The link does not grant '${action}' on a ${dimension}`)
694
+ }
695
+ const chain = await this.#chain(dimension, object.value)
696
+ const unchanged = chain !== null && chain.size === containers.length && containers.every((scope) => chain.get(scope.type) === scope.value)
697
+ if (chain === null || !unchanged) {
698
+ throw new NotFoundError('This link no longer reaches its object')
699
+ }
700
+ return { context: { target: this.#parts.mint.ref(frame, dimension, action, caller.subject, object.value, chain) }, input }
701
+ }
702
+
703
+ /**
704
+ * A form appends one row of its declared fields to its inbox and answers a constant; it reads nothing. In order: the
705
+ * client's request bucket (before the body is read), the input, the honeypot (a filled one is answered as accepted
706
+ * and writes nothing), the challenge, then the form's bucket of accepted submissions with the row, in one
707
+ * transaction, so nothing refused or failed can empty it. Only after that commit does `then` kick its job, which the
708
+ * answer neither waits for nor depends on.
709
+ */
710
+ async #form(served: Extract<ServedEndpoint, { kind: 'form' }>, request: Request, exec: RequestExecutionContext | undefined): Promise<Response> {
711
+ admitContentType(served.row, request)
712
+ const { admission } = served
713
+ const admissions = this.#framework().forms
714
+ if (!(await admissions.take(served.form, clientBucket(request), admission.perClient))) {
715
+ throw formRateLimited(served.row)
716
+ }
717
+ const body = await readBoundedBody(request, maxBytesOf(served.row))
718
+ const submission = submissionOf(strictInput(served.row, served.input, parseJson(body)), admission.fields)
719
+ if (submission.trapped) {
720
+ return jsonResponse({ result: { received: true } }, 200)
721
+ }
722
+ if (admission.challenge !== null) {
723
+ await this.#challenge(served.form, admission.challenge, submission.token, request)
724
+ }
725
+ // One transaction: a row that fails to insert gives its place in the form's bucket back.
726
+ if (!(await this.#framework().appendToInbox(served.form, submission.row, { bucket: ACCEPTED_BUCKET, limit: admission.perForm }))) {
727
+ throw formRateLimited(served.row)
728
+ }
729
+ if (served.then !== null) {
730
+ // Without an execution context the kick runs detached; it never rejects.
731
+ const kicked = this.#parts.kick(served.then)
732
+ exec?.waitUntil(kicked)
733
+ }
734
+ return jsonResponse({ result: { received: true } }, 200)
735
+ }
736
+
737
+ /** The token verifies for this host and this form, and was not accepted before: a failure is 400, an unreachable provider 503. */
738
+ async #challenge(form: string, verifier: ChallengeVerifier, token: string | null, request: Request): Promise<void> {
739
+ const outcome = token === null ? 'failed' : await verifier.verify({ token, hostname: new URL(request.url).hostname, action: form })
740
+ if (outcome === 'unavailable') {
741
+ throw new ServerFault('challenge_unavailable', `the ${verifier.provider} challenge of '${form}' could not be verified`, 503)
742
+ }
743
+ if (outcome === 'misconfigured') {
744
+ throw new ServerFault('challenge_misconfigured', `the ${verifier.provider} challenge of '${form}' refused the application's secret`, 503)
745
+ }
746
+ const refused = token === null || outcome === 'failed'
747
+ || !(await this.#framework().forms.recordChallenge(form, sha256Hex(token), CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs))
748
+ if (refused) {
749
+ throw new HttpError(400, 'challenge_failed', 'The challenge did not verify; solve it again')
750
+ }
751
+ }
752
+
753
+ /**
754
+ * A GitHub delivery, in order: the binding the path names, the signature over the raw body with that binding's
755
+ * secret, the account the payload names, the input, then the delivery key — the hash of the binding and the
756
+ * signed body, so a replay is ignored whatever its unsigned headers say. The handler gets a `Ref` of the bound
757
+ * value for each declared action and the key, and runs at least once: a failed run is forgotten and redelivered.
758
+ */
759
+ async #webhook(served: Extract<ServedEndpoint, { kind: 'webhook' }>, request: Request, params: Readonly<Record<string, string>>): Promise<Response> {
760
+ admitContentType(served.row, request)
761
+ const body = await readBoundedBody(request, maxBytesOf(served.row))
762
+ const tables = this.#framework().webhooks
763
+ const bindingId = params[WEBHOOK_BINDING_PARAMETER] ?? ''
764
+ const binding = BINDING_ID.test(bindingId) ? await tables.binding(served.webhook, bindingId) : null
765
+ if (binding === null) {
766
+ throw new NotFoundError('Not found')
767
+ }
768
+ if (!(await verifyGithubSignature(request.headers.get('X-Hub-Signature-256'), binding.secret, body))) {
769
+ throw new UnauthorizedError('The delivery signature does not verify with this binding')
770
+ }
771
+ const payload = parseJson(body)
772
+ if (!githubAccounts(payload).includes(binding.account)) {
773
+ throw new ForbiddenError('The delivery names another account than its binding accepts')
774
+ }
775
+ const input = strictInput(served.row, served.input, stripped(served, payload))
776
+ if (binding.scope.type !== served.dimension) {
777
+ throw new ServerFault(
778
+ 'webhook_binding_mismatch',
779
+ `binding '${binding.id}' reaches a '${binding.scope.type}', and '${served.webhook}' is bound to '${served.dimension}'`,
780
+ )
781
+ }
782
+ const chain = await this.#chain(binding.scope.type, binding.scope.value)
783
+ if (chain === null) {
784
+ throw new NotFoundError('The bound value no longer exists')
785
+ }
786
+ // Asked before the delivery is recorded: a delivery that cannot run is not one GitHub must not redeliver.
787
+ const authority = await served.credential.authority()
788
+ if (!authority.ok) {
789
+ throw new ServerFault('workload_unauthorized', `the key of webhook '${served.webhook}' gives it no authority: ${authority.reason}`, 503)
790
+ }
791
+ const unheld = served.actions.find((action) => !permits([...authority.perms], action))
792
+ if (unheld !== undefined) {
793
+ throw new ServerFault('workload_unauthorized', `the token IAM signed for the key of webhook '${served.webhook}' does not carry '${unheld}'`, 503)
794
+ }
795
+ const key = await deliveryKey(binding.id, body)
796
+ const now = Date.now()
797
+ if (!(await tables.recordDelivery(binding.id, key, now, now - WEBHOOK_REPLAY_RETENTION_MS))) {
798
+ return jsonResponse({ result: { received: true } }, 200)
799
+ }
800
+ const frame = new RequestFrame(crypto.randomUUID())
801
+ const site = served.site
802
+ // A staged-kit delivery is one transaction; the delivery record above stays outside it, so a failure forgets it.
803
+ const invocation = site === undefined ? null : new Invocation(frame, this.#data(), false)
804
+ try {
805
+ try {
806
+ await runInRequest(frame, async () => {
807
+ const subject = webhookSubject(binding.id)
808
+ const refs = Object.freeze(
809
+ Object.fromEntries(
810
+ served.actions.map((action) => [action, this.#parts.mint.ref(frame, served.dimension, action, subject, binding.scope.value, chain)]),
811
+ ),
812
+ )
813
+ const principal: WebhookPrincipal = Object.freeze({ kind: 'webhook', bindingId: binding.id })
814
+ const delivery = Object.freeze({ key })
815
+ const data = site === undefined || invocation === null
816
+ ? {}
817
+ : {
818
+ db: siteData(invocation.handles('mutation', site, subject)),
819
+ sql: site.sql === null ? undeclaredSql(served.row.id, 'mutation') : invocation.sql(site.sql),
820
+ }
821
+ const signal = handlerSignal(request)
822
+ await served.handler({ input, ctx: Object.freeze({ principal, requestId: frame.id, signal, refs, delivery, ...data }) })
823
+ })
824
+ } finally {
825
+ frame.end()
826
+ }
827
+ await invocation?.commit(null)
828
+ } catch (error) {
829
+ await invocation?.rollback()
830
+ // The provider redelivers a failed delivery; forgetting its key lets the redelivery run. The unsigned
831
+ // delivery header is the caller's word, so the log names only the endpoint.
832
+ console.error(`fabrika: ${served.row.id}: delivery_failed; the provider's redelivery runs it again`)
833
+ await tables.forgetDelivery(binding.id, key)
834
+ throw error
835
+ }
836
+ return jsonResponse({ result: { received: true } }, 200)
837
+ }
838
+
839
+ #framework(): FrameworkTables {
840
+ const framework = this.#parts.framework
841
+ if (framework === null) {
842
+ throw new ServerFault('facility_unbound', 'no data layer serves the framework tables')
843
+ }
844
+ return framework
845
+ }
846
+
847
+ /** The object's containers, read now, so a Ref carries where it lies today; `null` for a missing object a table owns. */
848
+ async #chain(dimension: string, value: string): Promise<ReadonlyMap<string, string> | null> {
849
+ const database = this.#parts.database
850
+ if (database === null || !this.#parts.ownedDimensions.has(dimension)) {
851
+ return new Map()
852
+ }
853
+ return await database.locate(dimension, value)
854
+ }
855
+
856
+ /** The container values of `dimension` in which the caller holds `action`. */
857
+ #heldContainers(perms: PermissionEntry[], action: string, dimension: string): ContainerGrant[] {
858
+ return (this.#parts.containers.get(dimension) ?? []).flatMap((container) => {
859
+ const values = scopedValues(perms, action, container) ?? []
860
+ return values.length === 0 ? [] : [{ dimension: container, values }]
861
+ })
862
+ }
863
+ }
864
+
865
+ /** `ctx.db` (by the access form) and `ctx.sql` of a staged-kit endpoint: the declared tag, or one that refuses. */
866
+ function siteContext(
867
+ row: CredentialedEndpoint,
868
+ site: ServedSite,
869
+ invocation: Invocation,
870
+ principal: string,
871
+ access: Readonly<Record<string, unknown>>,
872
+ ): Readonly<Record<string, unknown>> {
873
+ const form = row.requirement.kind === 'member' ? 'member' : row.requirement.access.form
874
+ const db = endpointData(invocation.handles(site.kind, site.declaration, principal), form, {
875
+ target: access['target'],
876
+ within: access['within'],
877
+ created: access['created'],
878
+ app: access['app'],
879
+ })
880
+ return { db, sql: site.declaration.sql === null ? undeclaredSql(row.id, site.kind) : invocation.sql(site.declaration.sql) }
881
+ }
882
+
883
+ /** Whether the caller holds `action` on the value or on any container of its chain; IAM evaluates each as a flat scope. */
884
+ function holdsOnChain(perms: PermissionEntry[], action: string, dimension: string, value: string, chain: ReadonlyMap<string, string>): boolean {
885
+ const scopes: (readonly [string, string])[] = [[dimension, value], ...chain]
886
+ return scopes.some(([type, scope]) => permits(perms, action, { type, value: scope }))
887
+ }
888
+
889
+ function projectOutput(served: Extract<ServedEndpoint, { kind: 'procedure' }>, result: unknown): unknown {
890
+ try {
891
+ return project(served.output, result)
892
+ } catch (error) {
893
+ if (error instanceof ProjectionRefusedError) {
894
+ throw new ServerFault('output_refused', `'${served.row.id}' returned a value no branch of its output union accepts`)
895
+ }
896
+ throw error
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Admission. A request that carries no `Authorization` header is authenticated by the session cookie the proxy
902
+ * redeemed; when it is not a `GET`, it must come from this origin. A cross-site page cannot add the header: it is
903
+ * not CORS-safelisted, and the application answers no preflight.
904
+ */
905
+ function admit(row: CredentialedEndpoint, request: Request): void {
906
+ if (request.method !== 'GET' && !request.headers.has('Authorization') && request.headers.get('Sec-Fetch-Site') !== 'same-origin') {
907
+ throw new HttpError(403, 'admission', 'A session request that is not a GET must be same-origin (Sec-Fetch-Site: same-origin)')
908
+ }
909
+ admitContentType(row, request)
910
+ }
911
+
912
+ /** The content types an endpoint admits; an anonymous endpoint has no session, so this is its whole admission. */
913
+ function formRateLimited(row: EndpointRow): HttpError {
914
+ return new HttpError(429, 'rate_limited', `'${row.id}' takes no more submissions right now; try again in a minute`)
915
+ }
916
+
917
+ function admitContentType(row: EndpointRow, request: Request): void {
918
+ if (row.admission !== null) {
919
+ const type = mediaType(request.headers.get('Content-Type'))
920
+ if (type === null || !row.admission.contentTypes.includes(type)) {
921
+ throw new HttpError(400, 'admission', `'${row.id}' admits Content-Type ${row.admission.contentTypes.join(' or ')}`)
922
+ }
923
+ }
924
+ }
925
+
926
+ /** An anonymous endpoint's body limit: the framework's, because the proxy never sees the body. */
927
+ function maxBytesOf(row: EndpointRow): number {
928
+ const limit = row.admission?.maxBytes
929
+ if (limit === null || limit === undefined) {
930
+ throw new ServerFault('contract_inconsistent', `'${row.id}' declares no body limit`)
931
+ }
932
+ return limit
933
+ }
934
+
935
+ function parseJson(body: Uint8Array): unknown {
936
+ try {
937
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body))
938
+ } catch {
939
+ throw new BadRequestError('The body is not JSON')
940
+ }
941
+ }
942
+
943
+ /** A webhook input keeps only the fields it declares: a provider sends many more, and none reaches the handler. */
944
+ function stripped(served: Extract<ServedEndpoint, { kind: 'webhook' }>, payload: unknown): unknown {
945
+ try {
946
+ return project(served.input, payload)
947
+ } catch (error) {
948
+ if (error instanceof ProjectionRefusedError) {
949
+ throw new BadRequestError(`'${served.row.id}': the delivery matches no branch of the declared input`)
950
+ }
951
+ throw error
952
+ }
953
+ }
954
+
955
+ /** `value` parsed with the endpoint's strict input schema; a refusal is a 400 naming each issue. */
956
+ function strictInput(row: EndpointRow, schema: Schema, value: unknown): Readonly<Record<string, unknown>> {
957
+ const parsed = z.safeParse(schema, value)
958
+ if (!parsed.success) {
959
+ const issues = parsed.error.issues.map((issue) => ({ path: issue.path.map(String).join('.'), code: issue.code, message: issue.message }))
960
+ const first = issues[0]
961
+ throw new HttpError(400, 'validation', first === undefined ? 'Invalid input' : `${first.path || 'input'}: ${first.message}`, { issues })
962
+ }
963
+ if (!isRecord(parsed.data)) {
964
+ throw new BadRequestError(`'${row.id}': the input is not an object`)
965
+ }
966
+ const unstorable = nulAt(parsed.data, [])
967
+ if (unstorable !== null) {
968
+ const path = unstorable.join('.')
969
+ throw new HttpError(400, 'validation', `${path || 'input'}: a string may not contain U+0000`, {
970
+ issues: [{ path, code: 'invalid_format', message: 'a string may not contain U+0000' }],
971
+ })
972
+ }
973
+ return parsed.data
974
+ }
975
+
976
+ /**
977
+ * The path of the first string, or record key, holding U+0000. Postgres `text` cannot store one, so a value the schema
978
+ * admits would otherwise fail its statement as a 500 after the request was admitted.
979
+ */
980
+ function nulAt(value: unknown, path: readonly string[]): readonly string[] | null {
981
+ if (typeof value === 'string') {
982
+ return value.includes('\u0000') ? path : null
983
+ }
984
+ if (Array.isArray(value)) {
985
+ for (const [index, item] of value.entries()) {
986
+ const found = nulAt(item, [...path, String(index)])
987
+ if (found !== null) {
988
+ return found
989
+ }
990
+ }
991
+ return null
992
+ }
993
+ if (isRecord(value)) {
994
+ for (const [key, item] of Object.entries(value)) {
995
+ const found = key.includes('\u0000') ? [...path, key] : nulAt(item, [...path, key])
996
+ if (found !== null) {
997
+ return found
998
+ }
999
+ }
1000
+ }
1001
+ return null
1002
+ }
1003
+
1004
+ /** The strictly parsed input; a procedure without input takes an empty body or `{}`. */
1005
+ async function readInput(
1006
+ served: { readonly row: CredentialedEndpoint; readonly input: Schema | null },
1007
+ request: Request,
1008
+ ): Promise<Readonly<Record<string, unknown>>> {
1009
+ const text = await request.text()
1010
+ let body: unknown = {}
1011
+ if (text.trim() !== '') {
1012
+ try {
1013
+ body = JSON.parse(text)
1014
+ } catch {
1015
+ throw new BadRequestError('The body is not JSON')
1016
+ }
1017
+ }
1018
+ if (served.input === null) {
1019
+ if (!isRecord(body) || Object.keys(body).length > 0) {
1020
+ throw new BadRequestError(`'${served.row.id}' takes no input; send {} or an empty body`)
1021
+ }
1022
+ return {}
1023
+ }
1024
+ return strictInput(served.row, served.input, body)
1025
+ }
1026
+
1027
+ /** A canonical path's segments all decode; a value that does not is no id of anything. */
1028
+ function decodePathParameter(raw: string): string {
1029
+ let decoded: string
1030
+ try {
1031
+ decoded = decodeURIComponent(raw)
1032
+ } catch {
1033
+ throw new BadRequestError('A path parameter does not decode')
1034
+ }
1035
+ if (decoded.includes('\u0000')) {
1036
+ throw new BadRequestError('A path parameter may not contain U+0000')
1037
+ }
1038
+ return decoded
1039
+ }
1040
+
1041
+ /** Removes the `from` field: the only copy of that id the handler gets is the checked capability. */
1042
+ function takeField(
1043
+ input: Readonly<Record<string, unknown>>,
1044
+ field: string,
1045
+ ): { readonly value: string; readonly rest: Readonly<Record<string, unknown>> } {
1046
+ const value = input[field]
1047
+ if (typeof value !== 'string') {
1048
+ throw new BadRequestError(`${field}: a string is required`)
1049
+ }
1050
+ const rest = Object.fromEntries(Object.entries(input).filter(([name]) => name !== field))
1051
+ return { value, rest: Object.freeze(rest) }
1052
+ }
1053
+
1054
+ /** The only body a 5xx carries: what failed stays on the server, because it may quote a credential. */
1055
+ function serverError(status: number): Response {
1056
+ return status === 503
1057
+ ? jsonResponse({ error: { type: 'unavailable', message: 'Service unavailable' } }, 503)
1058
+ : jsonResponse({ error: { type: 'internal', message: 'Internal error' } }, status)
1059
+ }
1060
+
1061
+ /**
1062
+ * One envelope, `{ error: { type, message, issues? } }`. A client error keeps its own message and issues; every
1063
+ * 5xx and every refused capability answers a fixed message; what made a 5xx reaches only `FailureReports`, as a code.
1064
+ */
1065
+ export function errorResponse(error: unknown, where: string): Response {
1066
+ if (isStructuralError(error) && error.httpStatus < 500) {
1067
+ return toErrorResponse(error)
1068
+ }
1069
+ if (error instanceof CapabilityLimitError) {
1070
+ console.error(`fabrika: ${where}: capability_limit: the caller holds the action in more scope values than one list may carry`)
1071
+ return toErrorResponse(new ForbiddenError(`The caller holds the action in more scope values than one list may carry (${MAX_WITHIN_VALUES})`))
1072
+ }
1073
+ const name = error instanceof Error ? error.name : ''
1074
+ // `@fabrika/db` errors carry no status; they are read by name, never by a cross-package `instanceof`.
1075
+ if (name === 'NotFoundError') {
1076
+ return toErrorResponse(new NotFoundError('Not found'))
1077
+ }
1078
+ if (name === 'ConflictError') {
1079
+ return toErrorResponse(new HttpError(409, 'conflict', 'The object changed since it was read'))
1080
+ }
1081
+ if (error instanceof ServerFault) {
1082
+ return serverError(error.status)
1083
+ }
1084
+ return serverError(isStructuralError(error) ? error.httpStatus : 500)
1085
+ }