@fabrika/app 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -2,10 +2,12 @@
2
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
3
  // projection. The caller-kind, requirement and access checks are authorization; the same-origin and content-type
4
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.
5
+ // The anonymous kinds (health, public, webhook, the share SPA) read no token and take their own fixed steps.
6
6
 
7
7
  import type { IamMembership, MembershipRequestCaller } from '@fabrika/auth'
8
8
  import {
9
+ answersFile,
10
+ type CapabilitiesRequirement,
9
11
  type CompiledEndpointTable,
10
12
  type CredentialedEndpoint,
11
13
  DELEGATION_HEADER,
@@ -13,8 +15,10 @@ import {
13
15
  type EndpointPlan,
14
16
  type EndpointRow,
15
17
  type ExportDescription,
16
- type FormFileArtifact,
18
+ FORM_URLENCODED_TYPE,
17
19
  isAttachmentShape,
20
+ isFileShape,
21
+ isRedirectShape,
18
22
  type LinkTokenClaims,
19
23
  parseEndpointHeader,
20
24
  type PermissionEntry,
@@ -23,23 +27,30 @@ import {
23
27
  PROXY_SECRET_HEADER,
24
28
  PROXY_TOKEN_HEADER,
25
29
  rawRequestTarget,
30
+ type RedirectShape,
26
31
  satisfies,
32
+ type SchemaShape,
33
+ type ScopeBinding,
27
34
  scopedValues,
28
35
  sha256Hex,
36
+ type WebhookScheme,
29
37
  } from '@fabrika/auth-core'
30
38
  import { type CapabilityAuthority, type ContainerGrant, MAX_WITHIN_VALUES } from '@fabrika/db/capability'
31
- import type { DataAccess, FrameworkTables } from '@fabrika/db/framework'
39
+ import type { DataAccess, FileBinding, FrameworkTables } from '@fabrika/db/framework'
32
40
  import type { DataKind } from '@fabrika/db/tables'
33
41
  import type { TokenVerifier } from '@fabrika/jwks'
42
+ import { applyAnswerCache, withResponseHeaders } from '@fabrika/proxy-core/headers'
34
43
  import { createHash, timingSafeEqual } from 'node:crypto'
35
44
  import { z } from 'zod/v4'
45
+ import { fileFieldsOf } from '../contract/build'
36
46
  import {
37
47
  BROWSER_CONFIG_FILE,
38
- FORM_FILE_LIMITS,
39
- FORM_INPUT_PART,
48
+ CHALLENGE_FIELD,
49
+ FILE_INPUT_PART,
40
50
  FORM_MULTIPART_TYPE,
41
51
  joinPath,
42
52
  PAGE_CALLER_FILE,
53
+ PROCEDURE_LIMITS,
43
54
  WEBHOOK_BINDING_PARAMETER,
44
55
  } from '../contract/defaults'
45
56
  import type { Schema } from '../contract/define'
@@ -54,21 +65,30 @@ import {
54
65
  UnauthorizedError,
55
66
  } from '../errors'
56
67
  import type { RequestExecutionContext } from '../fetch'
57
- import type { FileCollections, ReceivedFormFile } from '../files/collections'
68
+ import { FileWork, type StoredFileKinds, storedFilesHandle } from '../files/kinds'
58
69
  import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
59
- import { abortedBody, bootstrapPath, bootstrapResponse, githubAccounts, readBoundedBody, verifyGithubSignature, withBootstrap } from './anonymous'
70
+ import { abortedBody, bodyDeadlineMs, bootstrapPath, bootstrapResponse, readBoundedBody, withBootstrap } from './anonymous'
60
71
  import { attachmentResponse } from './attachment'
61
- import { handlerSignal, readBoundedBytes } from './bounded'
72
+ import { boundedStream, handlerSignal, readBoundedBytes } from './bounded'
62
73
  import type { BrowserConfig } from './browser-config'
63
- import { CapabilityLimitError, type CapabilityMint, RequestFrame, runInRequest } from './capability'
64
- import { CHALLENGE_LIMITS, type ChallengeVerifier } from './challenge'
74
+ import {
75
+ type AppCapability,
76
+ CapabilityLimitError,
77
+ type CapabilityMint,
78
+ CapabilityRefusedError,
79
+ type Ref,
80
+ RequestFrame,
81
+ runInRequest,
82
+ type Within,
83
+ } from './capability'
84
+ import type { ChallengeIssuer, ChallengeVerifier } from './challenge'
65
85
  import { connectContext, type ImportWire } from './connect'
86
+ import { runAsSite } from './egress'
66
87
  import type { FailureReports } from './failures'
67
88
  import { ServerFault } from './faults'
68
- import { ACCEPTED_BUCKET, clientBucket, type Submission, type SubmissionFields, submissionOf } from './form-admission'
69
89
  import { type HeaderPolicy, withHeaderPolicy } from './headers'
70
90
  import type { ContractDatabase } from './implement'
71
- import { endpointData, Invocation, noFileCollections, siteData, type SiteDeclaration, undeclaredSql } from './invocation'
91
+ import { boundTo, boundWebhookApp, endpointData, Invocation, kickRecorder, siteData, type SiteDeclaration, undeclaredSql } from './invocation'
72
92
  import {
73
93
  applicationDirectory,
74
94
  createScope,
@@ -80,9 +100,23 @@ import {
80
100
  type ReadDirectory,
81
101
  } from './membership'
82
102
  import { multipartBoundary, MultipartError, multipartParts, type MultipartReader } from './multipart'
103
+ import { paramsObject, urlParams } from './params'
83
104
  import { pageCallerOf, presentedBy, type Principal } from './principal'
84
105
  import { project, ProjectionRefusedError } from './project'
85
- import { deliveryKey, WEBHOOK_REPLAY_RETENTION_MS, type WebhookPrincipal, webhookSubject } from './webhook'
106
+ import { bucketsOf, DEFAULT_ADMISSION, type InFlightShare, type ProcedureAdmission, requestClient } from './public'
107
+ import type { SignedTokens } from './tokens'
108
+ import type { AppUrl } from './urls'
109
+ import { githubAccounts, verifyDelivery } from './verifiers'
110
+ import {
111
+ APP_BINDING_SCOPE,
112
+ type BindableWebhook,
113
+ deliveryKey,
114
+ WEBHOOK_CLAIM_GRACE_MS,
115
+ WEBHOOK_REPLAY_RETENTION_MS,
116
+ webhookBindings,
117
+ type WebhookPrincipal,
118
+ webhookSubject,
119
+ } from './webhook'
86
120
  import type { WorkloadCredential } from './workload'
87
121
 
88
122
  /** SHA-256 of a proxy secret: 32 bytes whatever the secret's length, as `timingSafeEqual` needs. */
@@ -122,7 +156,7 @@ export type ServedEndpoint =
122
156
  readonly row: CredentialedEndpoint
123
157
  readonly input: Schema | null
124
158
  readonly output: Schema
125
- readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object }) => unknown
159
+ readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object; readonly query?: unknown }) => unknown
126
160
  readonly facilities: Facilities
127
161
  /** An export procedure: its request may only read. */
128
162
  readonly readOnly?: true
@@ -130,8 +164,14 @@ export type ServedEndpoint =
130
164
  readonly connect?: readonly ImportWire[]
131
165
  /** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
132
166
  readonly site?: ServedSite
133
- /** The cron job this mutation kicks once its transaction committed; absent without `then`. */
134
- readonly then?: string
167
+ /** The origins a staged-kit site's `fetch` reaches while its handler runs; absent for a handler `implement` bound. */
168
+ readonly egress?: readonly string[]
169
+ /** Its buckets and time limit; without them (a generated endpoint) no bucket and the request budget. */
170
+ readonly admission?: ProcedureAdmission
171
+ /** A POST's `query`, parsed from the URL beside the body: its schema and its shape. */
172
+ readonly query?: ProcedureQuery
173
+ /** The webhook whose bindings `ctx.webhooks` creates and removes; absent without `binds`. */
174
+ readonly binds?: BindableWebhook
135
175
  }
136
176
  /** What an export's consumer vendors, behind the export's own callers. */
137
177
  | { readonly kind: 'description'; readonly row: CredentialedEndpoint; readonly description: ExportDescription }
@@ -141,33 +181,32 @@ export type ServedEndpoint =
141
181
  /** An SPA anyone opens: anonymous, served without a token. */
142
182
  | { readonly kind: 'public-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string; readonly config: BrowserConfig }
143
183
  | { readonly kind: 'health'; readonly row: EndpointRow }
144
- | {
145
- /** A file collection's upload or download, served by `src/files` once the access form minted its target. */
146
- readonly kind: 'file'
147
- readonly row: CredentialedEndpoint
148
- readonly serve: (request: Request, target: unknown) => Promise<Response | FileDownload>
149
- /**
150
- * A link holder's download: the `file:<name>` dimension whose `:fileId` must lie beneath the link's object. Its
151
- * row is `on` the owner's dimension, the link's object; the file is resolved under it.
152
- */
153
- readonly beneath?: string
154
- }
155
184
  /** The shell of a link mount's share SPA: anonymous, and served with the fragment bootstrap. */
156
185
  | { readonly kind: 'link-spa'; readonly row: EndpointRow; readonly prefix: string; readonly dir: string }
157
186
  | {
158
- readonly kind: 'form'
187
+ /** A procedure anyone calls: its handler runs as the endpoint's own workload, holding `actions` only. */
188
+ readonly kind: 'public'
159
189
  readonly row: EndpointRow
160
- readonly form: string
161
- readonly input: Schema
162
- readonly admission: FormAdmission
163
- /** The job each accepted submission kicks once its row committed; `null` without `then`. */
164
- readonly then: string | null
190
+ readonly input: Schema | null
191
+ readonly output: Schema
192
+ readonly handler: (args: { readonly input: Readonly<Record<string, unknown>>; readonly ctx: object; readonly query?: unknown }) => unknown
193
+ readonly actions: readonly string[]
194
+ readonly query?: ProcedureQuery
195
+ /** The endpoint's key: `ctx.app` mints only what IAM signs for it. */
196
+ readonly credential: WorkloadCredential
197
+ readonly admission: ProcedureAdmission
198
+ readonly site: ServedSite
165
199
  }
200
+ /** The framework's own `GET` that issues a public procedure's ALTCHA challenge, behind buckets of its own. */
201
+ | { readonly kind: 'challenge'; readonly row: EndpointRow; readonly issuer: ChallengeIssuer; readonly admission: ProcedureAdmission }
166
202
  | {
167
203
  readonly kind: 'webhook'
168
204
  readonly row: EndpointRow
169
205
  readonly webhook: string
170
- readonly dimension: string
206
+ readonly scheme: WebhookScheme
207
+ readonly boundTo: ScopeBinding
208
+ /** How long a delivery's handler runs after the last body byte; its claim's lease outlasts it. */
209
+ readonly timeoutMs: number
171
210
  readonly actions: readonly string[]
172
211
  readonly input: Schema
173
212
  readonly handler: (args: { readonly input: unknown; readonly ctx: object }) => unknown
@@ -177,29 +216,10 @@ export type ServedEndpoint =
177
216
  readonly site?: SiteDeclaration
178
217
  }
179
218
 
180
- /** A form's file fields and the collections they land in: such a form reads `multipart/form-data`. */
181
- export interface FormFileAdmission {
182
- readonly fields: ReadonlyMap<string, FormFileArtifact>
183
- readonly collections: FileCollections
184
- /** The bytes all the form's files may write per hour; each file reserves its collection's `maxBytes` first. */
185
- readonly bytesPerHour: number
186
- /** How long the whole body may take to arrive. */
187
- readonly timeoutMs: number
188
- }
189
-
190
- /** What a form admits, decided once at boot from the approved artifact and the application's variables. */
191
- export interface FormAdmission {
192
- /** Requests per minute from one client, admitted before the body is read. */
193
- readonly perClient: number
194
- /** Submissions per minute the form accepts in all: only one that passed its honeypot and its challenge counts. */
195
- readonly perForm: number
196
- readonly fields: SubmissionFields
197
- /** The form's file fields; `null` for a form that takes none, whose body is JSON. */
198
- readonly files: FormFileAdmission | null
199
- /** The challenge's verifier, with the secret the application variable holds; `null` without a challenge. */
200
- readonly challenge: ChallengeVerifier | null
201
- /** The one token the challenge does not record, so it passes again: `fabrika app dev`'s test widget's; `null` otherwise. */
202
- readonly repeatableToken: string | null
219
+ /** A POST procedure's `http.query`: the schema it is parsed with, and its described shape, which reads the parameters. */
220
+ export interface ProcedureQuery {
221
+ readonly schema: Schema
222
+ readonly shape: SchemaShape
203
223
  }
204
224
 
205
225
  export interface RuntimeParts {
@@ -221,21 +241,31 @@ export interface RuntimeParts {
221
241
  readonly assets: SpaAssets | null
222
242
  /** IAM's membership operations; `boot` refuses a contract that needs them when the adapter supplies none. */
223
243
  readonly membership: IamMembership | null
224
- /** The framework's own writes (inboxes, admissions, webhook tables); `boot` refuses a form or webhook without them. */
244
+ /** The framework's own writes (admission buckets, webhook tables); `boot` refuses a webhook or a bucket without them. */
225
245
  readonly framework: FrameworkTables | null
226
246
  /** This runtime's capability mint, which `boot` alone holds, and the authority its stores are bound to. */
227
247
  readonly mint: CapabilityMint
228
248
  readonly capabilities: CapabilityAuthority
229
249
  /** The mini-ORM of the bound `scoped(tables)` layer; `boot` refuses a site without one. */
230
250
  readonly data: DataAccess | null
231
- /** The contract's file collections, behind a staged-kit site's `ctx.files`; `null` when it declares none. */
232
- readonly files: FileCollections | null
251
+ /** The contract's file kinds: file fields, file outputs, `ctx.files.put` and `.open`; `null` when it declares none. */
252
+ readonly fileKinds: StoredFileKinds | null
233
253
  /** Where a 5xx goes: the server log, and Operations when the application has a DSN. */
234
254
  readonly failures: FailureReports
235
255
  /** Kicks a contract job under its own key, in the background (`BoundJobs.kick`); it never rejects. */
236
256
  readonly kick: (job: string) => Promise<void>
237
257
  /** The contract's actions, sorted: what a caller document lists a pattern grant as. */
238
258
  readonly actions: readonly string[]
259
+ /** The connections all public procedures of this instance hold at once, at most. */
260
+ readonly publicShare: InFlightShare
261
+ /** Whether the proxy's `X-Fabrika-Client-Ip` is the visitor's, so `ctx.client.ip` reads it. */
262
+ readonly clientVouched: boolean
263
+ /** The environment's signed tokens: verified in each input and query, signed by `ctx.tokens`. */
264
+ readonly tokens: SignedTokens
265
+ /** `ctx.url`: absolute links to the application's own procedures. */
266
+ readonly url: AppUrl
267
+ /** The environment's public origin (`FABRIKA_APP_ORIGIN`), which a Twilio signature covers; `null` where it has none. */
268
+ readonly origin: string | null
239
269
  }
240
270
 
241
271
  interface Caller {
@@ -270,7 +300,15 @@ function mediaType(header: string | null): string | null {
270
300
  }
271
301
 
272
302
  function describeRequirement(row: CredentialedEndpoint): string {
273
- return row.requirement.kind === 'member' ? 'a permission in this application' : `'${row.requirement.action}' in some scope`
303
+ const { requirement } = row
304
+ switch (requirement.kind) {
305
+ case 'member':
306
+ return 'a permission in this application'
307
+ case 'action':
308
+ return `'${requirement.action}' in some scope`
309
+ case 'capabilities':
310
+ return `each of ${Object.values(requirement.capabilities).map((capability) => `'${capability.action}'`).join(', ')} in some scope`
311
+ }
274
312
  }
275
313
 
276
314
  export class ContractRuntime {
@@ -287,10 +325,16 @@ export class ContractRuntime {
287
325
  async fetch(request: Request, exec?: RequestExecutionContext): Promise<Response> {
288
326
  const plan = planEndpoint(this.#parts.table, request.method, rawRequestTarget(request.url).pathname)
289
327
  const answer = await this.#answer(request, plan, exec)
290
- if (answer instanceof FileDownload) {
291
- return fileResponse(answer)
292
- }
293
- return plan.outcome === 'endpoint' && plan.endpoint.kind === 'download' ? withFileTreatment(answer) : withHeaderPolicy(answer, this.#parts.policy)
328
+ const answered = answer instanceof FileDownload
329
+ ? fileResponse(answer)
330
+ : plan.outcome === 'endpoint' && answersFile(plan.endpoint)
331
+ ? withFileTreatment(answer)
332
+ : withHeaderPolicy(answer, this.#parts.policy)
333
+ if (plan.outcome !== 'endpoint' || plan.endpoint.cache === undefined) {
334
+ return answered
335
+ }
336
+ const row = plan.endpoint
337
+ return withResponseHeaders(answered, (headers) => applyAnswerCache(headers, row, answered.status))
294
338
  }
295
339
 
296
340
  async #answer(request: Request, plan: EndpointPlan, exec: RequestExecutionContext | undefined): Promise<Response | FileDownload> {
@@ -327,12 +371,12 @@ export class ContractRuntime {
327
371
  return await withBootstrap(await this.#asset(served, request), served.prefix)
328
372
  case 'procedure':
329
373
  return await this.#procedure(served, request, exec)
330
- case 'file':
331
- return await this.#file(served, request, plan.params)
332
- case 'form':
333
- return await this.#form(served, request, exec)
374
+ case 'public':
375
+ return await this.#public(served, request, exec)
376
+ case 'challenge':
377
+ return await this.#issueChallenge(served, request)
334
378
  case 'webhook':
335
- return await this.#webhook(served, request, plan.params)
379
+ return await this.#webhook(served, request, plan.params, exec)
336
380
  case 'description':
337
381
  await this.#authorize(served.row, request)
338
382
  await readInput({ row: served.row, input: null }, request)
@@ -463,79 +507,160 @@ export class ContractRuntime {
463
507
  * A staged-kit site runs in one transaction: its statements commit after the handler returned, the output passed
464
508
  * validation and an attachment was encoded, and roll back on a throw, a refused output, a file over its limit or a
465
509
  * cancelled request. So an audited export keeps its log row only when its file goes out. Only after that commit does
466
- * `then` kick its job, which the answer neither waits for nor depends on.
510
+ * `ctx.kick` send its jobs, which the answer neither waits for nor depends on.
467
511
  */
468
512
  async #procedure(
469
513
  served: Extract<ServedEndpoint, { kind: 'procedure' }>,
470
514
  request: Request,
471
515
  exec: RequestExecutionContext | undefined,
472
- ): Promise<Response> {
516
+ ): Promise<Response | FileDownload> {
473
517
  const caller = await this.#authorize(served.row, request)
474
- const input = await readInput(served, request)
518
+ const admission = served.admission ?? DEFAULT_ADMISSION
519
+ await this.#admit(served.row, admission, request, caller.subject)
520
+ const body = await procedureBody(served, request, this.#parts.fileKinds)
521
+ // Signed tokens are verified as the input is parsed; the texts they carry are what the request may redirect to.
522
+ const carried = new Set<string>()
523
+ const fields = this.#parts.tokens.verifyFields(served.row.input, body.multipart === null ? body.input : body.multipart.json, carried)
524
+ const query = this.#verifiedQuery(served.row, served.query, request, carried)
475
525
  const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.readOnly === true, endpoint: served.row.id })
526
+ const work = new FileWork(frame, frame.id)
476
527
  const site = served.site
477
528
  const invocation = site === undefined ? null : new Invocation(frame, this.#data(), site.kind === 'query' || served.readOnly === true)
478
- const signal = handlerSignal(request)
529
+ const kicks = kickRecorder(`'${served.row.id}'`, site?.declaration.kicks ?? [])
530
+ let outcome: 'open' | 'committing' = 'open'
479
531
  try {
532
+ const input = body.multipart === null
533
+ ? fields
534
+ : this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
535
+ // The handler's time starts after the last body byte, so a slow upload does not eat it.
536
+ const signal = handlerSignal(request, admission.timeoutMs)
480
537
  let result: unknown
481
538
  try {
482
539
  result = await runInRequest(frame, async () => {
483
540
  const membership = this.#membershipRequest(served.row, caller, request, frame)
484
541
  const access = await this.#access(served.row, caller, input, frame, membership)
542
+ const binding = this.#parts.fileKinds?.binding(work)
485
543
  const ctx = Object.freeze({
486
544
  principal: caller.principal,
487
545
  requestId: frame.id,
488
546
  signal,
547
+ client: requestClient(request, this.#parts.clientVouched),
548
+ url: this.#parts.url,
489
549
  ...access.context,
490
550
  ...this.#facilities(served.facilities, served.row, membership, access.context['target'], {
491
551
  credential: caller.token,
492
552
  requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id,
493
553
  }),
494
554
  ...(served.connect === undefined ? {} : { connect: connectContext(served.connect, frame) }),
495
- ...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context)),
555
+ ...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context, binding)),
496
556
  // An export's consumer reaches its rows only, never the files of their owners.
497
557
  ...(site === undefined || invocation === null || served.readOnly === true
498
558
  ? {}
499
- : { files: this.#files(served.row.id, frame, invocation, site.kind) }),
559
+ : { files: this.#files(served.row.id, invocation, site.kind, work) }),
560
+ ...(served.binds === undefined || invocation === null
561
+ ? {}
562
+ : { webhooks: this.#webhookBindings(served.row.id, served.binds, invocation, caller.subject) }),
563
+ ...this.#signing(`'${served.row.id}'`, site?.declaration.signs ?? []),
564
+ kick: kicks.kick,
500
565
  })
501
- return await served.handler({ input: access.input, ctx })
566
+ const args = { input: access.input, ctx, ...(query === undefined ? {} : { query }) }
567
+ const egress = served.egress
568
+ return egress === undefined
569
+ ? await served.handler(args)
570
+ : await runAsSite(`'${served.row.id}'`, egress, async () => await served.handler(args))
502
571
  })
503
572
  } finally {
504
573
  frame.end()
505
574
  }
506
- const answer = this.#answerOf(served, result)
507
- await invocation?.commit(signal)
508
- if (served.then !== undefined) {
509
- // Without an execution context the kick runs detached; it never rejects.
510
- const kicked = this.#parts.kick(served.then)
511
- exec?.waitUntil(kicked)
512
- }
575
+ const answer = await this.#answerOf(served, result, request, work, carried)
576
+ outcome = 'committing'
577
+ await commitAnswer(invocation, signal, answer)
578
+ this.#kickAll(kicks.kicked(), exec)
513
579
  return answer
514
580
  } finally {
515
581
  await invocation?.rollback()
582
+ await body.multipart?.close()
583
+ // Only a known rollback deletes what the request staged; after a commit that may have happened, the sweep decides.
584
+ if (outcome === 'open') {
585
+ await this.#parts.fileKinds?.release(work)
586
+ }
516
587
  }
517
588
  }
518
589
 
590
+ /** A multipart body's input: its JSON part with every file part staged for `work`, parsed strictly as one input. */
591
+ async #fileInput(
592
+ served: { readonly row: EndpointRow; readonly input: Schema | null },
593
+ multipart: MultipartInput,
594
+ work: FileWork,
595
+ ): Promise<Readonly<Record<string, unknown>>> {
596
+ const files = await multipart.files(work)
597
+ if (served.input === null) {
598
+ throw new ServerFault('contract_inconsistent', `'${served.row.id}' takes files and declares no input`)
599
+ }
600
+ return strictInput(served.row, served.input, { ...multipart.json, ...files })
601
+ }
602
+
519
603
  /**
520
- * The output as JSON, projected and validated whole; or an attachment's file, whose rows are projected, validated
521
- * and encoded one at a time, so a result over a limit stops before it is copied.
604
+ * The output as JSON, projected and validated whole; an attachment's file, whose rows are projected, validated and
605
+ * encoded one at a time, so a result over a limit stops before it is copied; or the stored file the handler answered.
522
606
  */
523
- #answerOf(served: Extract<ServedEndpoint, { kind: 'procedure' }>, result: unknown): Response {
524
- const file = served.row.output !== null && isAttachmentShape(served.row.output) ? served.row.output : null
607
+ async #answerOf(
608
+ served: Extract<ServedEndpoint, { kind: 'procedure' }>,
609
+ result: unknown,
610
+ request: Request,
611
+ work: FileWork,
612
+ carried: ReadonlySet<string>,
613
+ ): Promise<Response | FileDownload> {
614
+ const output = served.row.output
615
+ if (output !== null && isFileShape(output)) {
616
+ return await this.#fileKinds(served.row.id).download(work, served.row.id, output.kind, result)
617
+ }
618
+ const file = output !== null && isAttachmentShape(output) ? output : null
525
619
  if (file !== null) {
526
620
  return attachmentResponse(served.row.id, file, served.output, result)
527
621
  }
528
- const output = z.safeParse(served.output, projectOutput(served, result))
529
- if (!output.success) {
530
- throw new ServerFault('output_refused', `'${served.row.id}' returned a value its output schema refuses`)
622
+ return outputResponse(served.row, served.output, result, request, carried)
623
+ }
624
+
625
+ /** A POST's `query`, parsed from the URL, each signed token in it verified; `undefined` without one. */
626
+ #verifiedQuery(
627
+ row: EndpointRow,
628
+ query: ProcedureQuery | undefined,
629
+ request: Request,
630
+ carried: Set<string>,
631
+ ): Readonly<Record<string, unknown>> | undefined {
632
+ const read = readQuery(row, query?.schema, query?.shape, request)
633
+ return read === undefined ? undefined : this.#parts.tokens.verifyFields(query?.shape ?? null, read, carried)
634
+ }
635
+
636
+ /** `ctx.tokens` of a site that declares `signs`; nothing for one that declares none, whose type has no such member. */
637
+ #signing(site: string, signs: readonly string[]): { readonly tokens?: object } {
638
+ return signs.length === 0 ? {} : { tokens: this.#parts.tokens.context(site, signs) }
639
+ }
640
+
641
+ #fileKinds(endpoint: string): StoredFileKinds {
642
+ const kinds = this.#parts.fileKinds
643
+ if (kinds === null) {
644
+ throw new ServerFault('facility_unbound', `'${endpoint}' takes or answers a file, and boot bound no file kinds`)
531
645
  }
532
- return jsonResponse({ result: output.data }, 200)
646
+ return kinds
533
647
  }
534
648
 
535
- /** `ctx.files` of a staged-kit site, in its invocation's transaction. */
536
- #files(endpoint: string, frame: RequestFrame, invocation: Invocation, kind: DataKind): object {
537
- const where = `'${endpoint}'`
538
- return this.#parts.files === null ? noFileCollections(where) : this.#parts.files.site({ frame, ...invocation.fileSite(where, kind) })
649
+ /** `ctx.webhooks` of a procedure that declares `binds`: its bindings are written in the invocation's transaction. */
650
+ #webhookBindings(endpoint: string, webhook: BindableWebhook, invocation: Invocation, principal: string): (name: string) => object {
651
+ return webhookBindings({
652
+ site: `'${endpoint}'`,
653
+ webhook,
654
+ tables: () => this.#framework().webhooks,
655
+ authority: this.#parts.capabilities,
656
+ ...invocation.frameworkSite('ctx.webhooks'),
657
+ principal,
658
+ })
659
+ }
660
+
661
+ /** `ctx.files` of a staged-kit site: `put` and `open` of the stored files, bound to the invocation and the request's work. */
662
+ #files(endpoint: string, invocation: Invocation, kind: DataKind, work: FileWork): object {
663
+ return storedFilesHandle(this.#parts.fileKinds, work, { where: `'${endpoint}'`, check: invocation.fileCheck(), writes: kind !== 'query' })
539
664
  }
540
665
 
541
666
  #data(): DataAccess {
@@ -546,73 +671,6 @@ export class ContractRuntime {
546
671
  return data
547
672
  }
548
673
 
549
- /**
550
- * The access form reads its value from the path parameter it names: `:owner` of an upload, `:fileId` of a download.
551
- * A link holder's row names none: its owner is the link's object, checked as a link procedure's is.
552
- */
553
- async #file(
554
- served: Extract<ServedEndpoint, { kind: 'file' }>,
555
- request: Request,
556
- params: Readonly<Record<string, string>>,
557
- ): Promise<Response | FileDownload> {
558
- const caller = await this.#authorize(served.row, request)
559
- const access = served.row.requirement.kind === 'action' ? served.row.requirement.access : null
560
- if (access?.form === 'on' && access.from === null && served.row.requirement.kind === 'action') {
561
- return await this.#linkFile(served, request, params, caller, served.row.requirement.action, access.dimension)
562
- }
563
- const from = access?.form === 'on' ? access.from : null
564
- const raw = from === null ? undefined : params[from]
565
- if (from === null || raw === undefined) {
566
- throw new ServerFault('contract_inconsistent', `'${served.row.id}' names no path parameter for its access form`)
567
- }
568
- const frame = new RequestFrame(crypto.randomUUID())
569
- try {
570
- return await runInRequest(frame, async () => {
571
- const { context } = await this.#access(served.row, caller, { [from]: decodePathParameter(raw) }, frame, null)
572
- return await served.serve(request, context['target'])
573
- })
574
- } finally {
575
- frame.end()
576
- }
577
- }
578
-
579
- /**
580
- * A link holder's upload or download. The link's mount and its chain are checked as `#linkTarget` checks them; a
581
- * download then resolves `:fileId` beneath that object, and a file of any other object is indistinguishable from a
582
- * missing one.
583
- */
584
- async #linkFile(
585
- served: Extract<ServedEndpoint, { kind: 'file' }>,
586
- request: Request,
587
- params: Readonly<Record<string, string>>,
588
- caller: Caller,
589
- action: string,
590
- dimension: string,
591
- ): Promise<Response | FileDownload> {
592
- const frame = new RequestFrame(crypto.randomUUID())
593
- try {
594
- return await runInRequest(frame, async () => {
595
- const owner = await this.#linkTarget(served.row, caller, action, dimension, {}, frame)
596
- const beneath = served.beneath
597
- if (beneath === undefined) {
598
- return await served.serve(request, owner.context['target'])
599
- }
600
- const raw = params['fileId']
601
- const fileId = raw === undefined ? '' : decodePathParameter(raw)
602
- const [object, ...containers] = caller.link?.chain ?? []
603
- const chain = fileId === '' ? null : await this.#chain(beneath, fileId)
604
- const ownedHere = chain !== null && object !== undefined && chain.get(dimension) === object.value
605
- && containers.every((scope) => chain.get(scope.type) === scope.value)
606
- if (chain === null || object === undefined || !ownedHere) {
607
- throw new NotFoundError('Not found')
608
- }
609
- return await served.serve(request, this.#parts.mint.ref(frame, beneath, action, caller.subject, fileId, chain))
610
- })
611
- } finally {
612
- frame.end()
613
- }
614
- }
615
-
616
674
  /** The caller's authority at IAM for this request, when the endpoint's access form names a dimension and an action. */
617
675
  #membershipRequest(row: CredentialedEndpoint, caller: Caller, request: Request, frame: RequestFrame): MembershipRequest | null {
618
676
  const iam = this.#parts.membership
@@ -665,46 +723,23 @@ export class ContractRuntime {
665
723
  if (row.requirement.kind === 'member') {
666
724
  return { context: {}, input }
667
725
  }
726
+ if (row.requirement.kind === 'capabilities') {
727
+ return await this.#capabilities(row.requirement, caller, input, frame)
728
+ }
668
729
  const { action, access } = row.requirement
669
730
  switch (access.form) {
670
- case 'over': {
671
- const direct = scopedValues(caller.perms, action, access.dimension)
672
- if (direct === null) {
673
- return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, 'all') }, input }
674
- }
675
- const held = this.#heldContainers(caller.perms, action, access.dimension)
676
- if (direct.length === 0 && held.length === 0) {
677
- throw new ForbiddenError(`The caller holds '${action}' in no ${access.dimension}`)
678
- }
679
- return { context: { within: this.#parts.mint.within(frame, access.dimension, action, caller.subject, direct, held) }, input }
680
- }
731
+ case 'over':
732
+ return { context: { within: this.#within(caller, action, access.dimension, frame) }, input }
681
733
  case 'in': {
682
734
  const { value, rest } = takeField(input, access.from)
683
- const direct = permits(caller.perms, action, { type: access.dimension, value })
684
- // Without a grant on the value or on a container of its dimension, nothing is read: 403 whether it exists or not.
685
- if (!direct && this.#heldContainers(caller.perms, action, access.dimension).length === 0) {
686
- throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
687
- }
688
- const chain = await this.#chain(access.dimension, value)
689
- if (!holdsOnChain(caller.perms, action, access.dimension, value, chain ?? new Map())) {
690
- throw new ForbiddenError(`The caller does not hold '${action}' in this ${access.dimension}`)
691
- }
692
- if (chain === null) {
693
- throw new NotFoundError('Not found')
694
- }
695
- return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
735
+ return { context: { target: await this.#inRef(caller, action, access.dimension, value, frame) }, input: rest }
696
736
  }
697
737
  case 'on': {
698
738
  if (access.from === null) {
699
739
  return await this.#linkTarget(row, caller, action, access.dimension, input, frame)
700
740
  }
701
741
  const { value, rest } = takeField(input, access.from)
702
- const chain = await this.#chain(access.dimension, value)
703
- // An object the caller may not act on is indistinguishable from a missing one.
704
- if (chain === null || !holdsOnChain(caller.perms, action, access.dimension, value, chain)) {
705
- throw new NotFoundError('Not found')
706
- }
707
- return { context: { target: this.#parts.mint.ref(frame, access.dimension, action, caller.subject, value, chain) }, input: rest }
742
+ return { context: { target: await this.#onRef(caller, action, access.dimension, value, frame) }, input: rest }
708
743
  }
709
744
  case 'creates': {
710
745
  if (membership === null) {
@@ -720,6 +755,71 @@ export class ContractRuntime {
720
755
  }
721
756
  }
722
757
 
758
+ /** The values of `dimension` in which the caller holds `action`, directly or through a container; all for a global grant. */
759
+ #within(caller: Caller, action: string, dimension: string, frame: RequestFrame): Within<string, string> {
760
+ const direct = scopedValues(caller.perms, action, dimension)
761
+ if (direct === null) {
762
+ return this.#parts.mint.within(frame, dimension, action, caller.subject, 'all')
763
+ }
764
+ const held = this.#heldContainers(caller.perms, action, dimension)
765
+ if (direct.length === 0 && held.length === 0) {
766
+ throw new ForbiddenError(`The caller holds '${action}' in no ${dimension}`)
767
+ }
768
+ return this.#parts.mint.within(frame, dimension, action, caller.subject, direct, held)
769
+ }
770
+
771
+ /** A scope value the caller acts in: without a grant on it or on a container of it, 403 whether it exists or not. */
772
+ async #inRef(caller: Caller, action: string, dimension: string, value: string, frame: RequestFrame): Promise<Ref<string, string>> {
773
+ const direct = permits(caller.perms, action, { type: dimension, value })
774
+ if (!direct && this.#heldContainers(caller.perms, action, dimension).length === 0) {
775
+ throw new ForbiddenError(`The caller does not hold '${action}' in this ${dimension}`)
776
+ }
777
+ const chain = await this.#chain(dimension, value)
778
+ if (!holdsOnChain(caller.perms, action, dimension, value, chain ?? new Map())) {
779
+ throw new ForbiddenError(`The caller does not hold '${action}' in this ${dimension}`)
780
+ }
781
+ if (chain === null) {
782
+ throw new NotFoundError('Not found')
783
+ }
784
+ return this.#parts.mint.ref(frame, dimension, action, caller.subject, value, chain)
785
+ }
786
+
787
+ /** An object the caller acts on; one it may not act on is indistinguishable from a missing one. */
788
+ async #onRef(caller: Caller, action: string, dimension: string, value: string, frame: RequestFrame): Promise<Ref<string, string>> {
789
+ const chain = await this.#chain(dimension, value)
790
+ if (chain === null || !holdsOnChain(caller.perms, action, dimension, value, chain)) {
791
+ throw new NotFoundError('Not found')
792
+ }
793
+ return this.#parts.mint.ref(frame, dimension, action, caller.subject, value, chain)
794
+ }
795
+
796
+ /**
797
+ * Several capabilities, each minted as its single access form mints it, in their declared order, so the first one
798
+ * refused answers. Each reads its value from the input as the caller sent it; the fields they read are removed after.
799
+ */
800
+ async #capabilities(
801
+ requirement: CapabilitiesRequirement,
802
+ caller: Caller,
803
+ input: Readonly<Record<string, unknown>>,
804
+ frame: RequestFrame,
805
+ ): Promise<Access> {
806
+ const minted: Record<string, unknown> = {}
807
+ const read = new Set<string>()
808
+ for (const [name, { action, access }] of Object.entries(requirement.capabilities)) {
809
+ if (access.form === 'over') {
810
+ minted[name] = this.#within(caller, action, access.dimension, frame)
811
+ continue
812
+ }
813
+ const { value } = takeField(input, access.from)
814
+ read.add(access.from)
815
+ minted[name] = access.form === 'in'
816
+ ? await this.#inRef(caller, action, access.dimension, value, frame)
817
+ : await this.#onRef(caller, action, access.dimension, value, frame)
818
+ }
819
+ const rest = Object.freeze(Object.fromEntries(Object.entries(input).filter(([field]) => !read.has(field))))
820
+ return { context: { capabilities: Object.freeze(minted) }, input: rest }
821
+ }
822
+
723
823
  /**
724
824
  * A link procedure acts on the object its key is bound to. The key's mount must be this endpoint's, and the chain
725
825
  * it was issued for must still be where the object lies: a link for a note moved since stops working.
@@ -749,233 +849,266 @@ export class ContractRuntime {
749
849
  }
750
850
 
751
851
  /**
752
- * A form appends one row of its declared fields to its inbox and answers a constant; it reads nothing. In order: the
753
- * client's request bucket (before the body is read), the input, the honeypot (a filled one is answered as accepted
754
- * and writes nothing), the challenge, then the form's bucket of accepted submissions with the row, in one
755
- * transaction, so nothing refused or failed can empty it. Only after that commit does `then` kick its job, which the
756
- * answer neither waits for nor depends on.
852
+ * The token verifies for this host and this action, and was not accepted before unless it is the `repeatable` one: a
853
+ * failure is 400, as a 0.5 form answered, an unreachable provider 503.
757
854
  */
758
- async #form(served: Extract<ServedEndpoint, { kind: 'form' }>, request: Request, exec: RequestExecutionContext | undefined): Promise<Response> {
759
- admitContentType(served.row, request)
760
- const { admission } = served
761
- const admissions = this.#framework().forms
762
- if (!(await admissions.take(served.form, clientBucket(request), admission.perClient))) {
763
- throw formRateLimited(served.row)
764
- }
765
- // A form with files whose every file is optional also takes the JSON body of a page that sends none.
766
- if (admission.files !== null && mediaType(request.headers.get('Content-Type')) === FORM_MULTIPART_TYPE) {
767
- return await this.#multipartForm(served, admission.files, request, exec)
768
- }
769
- const body = await readBoundedBody(request, maxBytesOf(served.row))
770
- const submission = submissionOf(strictInput(served.row, served.input, parseJson(body)), admission.fields)
771
- if (submission.trapped) {
772
- return jsonResponse({ result: { received: true } }, 200)
773
- }
774
- if (admission.challenge !== null) {
775
- await this.#challenge(served.form, admission.challenge, submission.token, request, admission.repeatableToken)
855
+ async #challenge(action: string, verifier: ChallengeVerifier, token: string | null, request: Request, repeatable: string | null): Promise<void> {
856
+ const verdict = token === null ? null : await verifier.verify({ token, hostname: new URL(request.url).hostname, action })
857
+ if (verdict?.outcome === 'unavailable') {
858
+ throw new ServerFault('challenge_unavailable', `the ${verifier.provider} challenge of '${action}' could not be verified`, 503)
776
859
  }
777
- // One transaction: a row that fails to insert gives its place in the form's bucket back.
778
- if (!(await this.#framework().appendToInbox(served.form, submission.row, { bucket: ACCEPTED_BUCKET, limit: admission.perForm }))) {
779
- throw formRateLimited(served.row)
860
+ if (verdict?.outcome === 'misconfigured') {
861
+ throw new ServerFault('challenge_misconfigured', `the ${verifier.provider} challenge of '${action}' refused the application's secret`, 503)
780
862
  }
781
- if (served.then !== null) {
782
- // Without an execution context the kick runs detached; it never rejects.
783
- const kicked = this.#parts.kick(served.then)
784
- exec?.waitUntil(kicked)
863
+ const refused = verdict?.outcome !== 'passed'
864
+ || (token !== repeatable
865
+ && !(await this.#framework().admissions.recordChallenge(action, sha256Hex(verdict.replayKey), verdict.recordMs)))
866
+ if (refused) {
867
+ throw new HttpError(400, 'challenge_failed', 'The challenge did not verify; solve it again')
785
868
  }
786
- return jsonResponse({ result: { received: true } }, 200)
787
869
  }
788
870
 
789
871
  /**
790
- * A form that takes files reads its `multipart/form-data` body as it streams, in the order of `#form`: its first part
791
- * is the input as JSON (within the form's `maxBytes`), so the input, the honeypot and the challenge are decided before
792
- * a byte of any file is read. Each file part then lands in its collection under a pending row of its own, owned by
793
- * nobody, and the inbox row that names each file's id makes them held in its own transaction. A submission that does
794
- * not commit deletes every file it received. The whole body arrives within the form's `timeoutMs`.
872
+ * A public procedure, in the order of ADR-0063's public request: the in-flight share (503), then every bucket in the
873
+ * order of `bucketsOf` (429), each charged before the body is read and kept whatever follows; the bounded body and the
874
+ * strict input (413, 400); the workload token, before a challenge spends its token (503); the challenge (400); then
875
+ * the handler in one transaction, the output and the commit. The handler's time starts after the last body byte.
795
876
  */
796
- async #multipartForm(
797
- served: Extract<ServedEndpoint, { kind: 'form' }>,
798
- files: FormFileAdmission,
877
+ async #public(
878
+ served: Extract<ServedEndpoint, { kind: 'public' }>,
799
879
  request: Request,
800
880
  exec: RequestExecutionContext | undefined,
801
- ): Promise<Response> {
802
- const { admission } = served
803
- const boundary = multipartBoundary(request.headers.get('Content-Type'))
804
- if (boundary === null) {
805
- throw new HttpError(400, 'admission', `'${served.row.id}' takes multipart/form-data with a boundary`)
806
- }
807
- // Each part is bounded on its own as it streams; a declared length past all of them together is refused unread.
808
- const declaredFiles = [...files.fields.values()]
809
- const limit = maxBytesOf(served.row) + declaredFiles.reduce((sum, field) => sum + files.collections.maxBytesOf(field.collection), 0)
810
- + (declaredFiles.length + 1) * MULTIPART_PART_OVERHEAD
811
- const declared = Number(request.headers.get('Content-Length') ?? '')
812
- if (Number.isFinite(declared) && declared > limit) {
813
- throw new HttpError(413, 'too_large', `The body is larger than the ${limit} bytes the form's input and files can hold`)
814
- }
815
- const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(files.timeoutMs)])
816
- const parts = multipartParts(request.body ?? new Blob([]).stream(), boundary, { maxHeaderBytes: FORM_FILE_LIMITS.maxPartHeaderBytes })
817
- const stop = () => {
818
- parts.cancel().catch(() => {})
819
- }
820
- deadline.addEventListener('abort', stop, { once: true })
821
- const received: ReceivedFormFile[] = []
822
- /**
823
- * `kept` once the inbox row may have committed: an append that threw may have committed before its answer was
824
- * lost, so its files are left to the sweep, which deletes a pending row's object after an hour and never a held one.
825
- */
826
- let kept = false
881
+ ): Promise<Response | FileDownload> {
882
+ admitContentType(served.row, request)
883
+ const release = this.#parts.publicShare.enter()
884
+ if (release === null) {
885
+ throw new HttpError(503, 'public_share_full', 'Too many anonymous requests at once; try again shortly')
886
+ }
887
+ let multipart: MultipartInput | null = null
827
888
  try {
828
- const submission = await this.#multipartInput(served, parts)
829
- if (submission.trapped) {
830
- return jsonResponse({ result: { received: true } }, 200)
889
+ const { admission } = served
890
+ await this.#admit(served.row, admission, request, null)
891
+ const body = await procedureBody({ row: served.row, input: served.input }, request, this.#parts.fileKinds)
892
+ multipart = body.multipart
893
+ // Signed tokens are verified as the input is parsed, before the workload token and the challenge.
894
+ const carried = new Set<string>()
895
+ const fields = this.#parts.tokens.verifyFields(served.row.input, body.multipart === null ? body.input : body.multipart.json, carried)
896
+ const query = this.#verifiedQuery(served.row, served.query, request, carried)
897
+ const authority = await served.credential.authority()
898
+ if (!authority.ok) {
899
+ throw new ServerFault('workload_unauthorized', `the key of public procedure '${served.row.id}' gives it no authority: ${authority.reason}`, 503)
831
900
  }
832
- if (admission.challenge === null) {
833
- // `boot` refuses a form with files and no challenge; reaching this is a composition bug.
834
- throw new ServerFault('contract_inconsistent', `'${served.row.id}' takes files without a challenge`)
901
+ if (admission.challenge !== null) {
902
+ const token = fields[CHALLENGE_FIELD]
903
+ await this.#challenge(
904
+ admission.challenge.action,
905
+ admission.challenge.verifier,
906
+ typeof token === 'string' ? token : null,
907
+ request,
908
+ admission.repeatableToken,
909
+ )
835
910
  }
836
- await this.#challenge(served.form, admission.challenge, submission.token, request, admission.repeatableToken)
837
- const columns = new Map<string, string | null>()
838
- for (let part = await parts.next(); part !== null; part = await parts.next()) {
839
- const field = files.fields.get(part.name)
840
- if (field === undefined || columns.has(part.name) || part.filename === null) {
841
- await part.body.cancel()
842
- throw new HttpError(
843
- 400,
844
- 'multipart',
845
- `'${served.row.id}': a part is no file field of the form, a field arrives twice, or a file has no filename`,
846
- )
847
- }
848
- const file = await this.#receiveFormFile(served, files, field.collection, part)
849
- if (file !== null) {
850
- received.push(file)
851
- }
852
- columns.set(part.name, file?.id ?? null)
853
- }
854
- const row: Record<string, unknown> = { ...submission.row }
855
- for (const [name, field] of files.fields) {
856
- const id = columns.get(name) ?? null
857
- if (id === null && field.required) {
858
- throw new HttpError(400, 'validation', `${name}: the form needs this file`, {
859
- issues: [{ path: name, code: 'required', message: 'a file is required' }],
860
- })
861
- }
862
- row[name] = id
863
- }
864
- const held = { files: received.map(({ id, size, sha256 }) => ({ id, size, sha256 })), holdMs: FORM_FILE_LIMITS.holdMs }
865
- kept = true
866
- const appended = await this.#framework().appendToInbox(served.form, row, { bucket: ACCEPTED_BUCKET, limit: admission.perForm }, held)
867
- if (!appended) {
868
- kept = false
869
- throw formRateLimited(served.row)
870
- }
871
- } catch (error) {
872
- throw multipartFailure(error, parts, deadline, request)
911
+ return await this.#publicInvocation(served, request, body, fields, query, carried, authority.perms, exec)
873
912
  } finally {
874
- deadline.removeEventListener('abort', stop)
875
- await parts.cancel()
876
- if (!kept) {
877
- await files.collections.releaseFormFiles(received)
878
- }
879
- }
880
- if (served.then !== null) {
881
- // Without an execution context the kick runs detached; it never rejects.
882
- const kicked = this.#parts.kick(served.then)
883
- exec?.waitUntil(kicked)
913
+ await multipart?.close()
914
+ release()
884
915
  }
885
- return jsonResponse({ result: { received: true } }, 200)
886
916
  }
887
917
 
888
918
  /**
889
- * One file of a submission, within the form's hourly byte budget: it reserves its collection's `maxBytes` before its
890
- * first byte is written (429 when they no longer fit) and gives back what it did not use. A file that failed keeps
891
- * its reservation: its bytes may have been written.
919
+ * A fresh ALTCHA challenge for this host: within the anonymous share and after the endpoint's buckets, as a public
920
+ * procedure is admitted. It reads no body and writes nothing but the buckets.
892
921
  */
893
- async #receiveFormFile(
894
- served: Extract<ServedEndpoint, { kind: 'form' }>,
895
- files: FormFileAdmission,
896
- collection: string,
897
- part: { readonly filename: string | null; readonly contentType: string | null; readonly body: ReadableStream<Uint8Array> },
898
- ): Promise<ReceivedFormFile | null> {
899
- const admissions = this.#framework().forms
900
- const reserved = files.collections.maxBytesOf(collection)
901
- if (!(await admissions.reserveBytes(served.form, reserved, files.bytesPerHour))) {
902
- await part.body.cancel()
903
- throw new HttpError(429, 'rate_limited', `'${served.row.id}' takes no more files this hour; try again later`)
904
- }
905
- const file = await files.collections.receiveFormFile(served.form, collection, part)
906
- await admissions.releaseBytes(served.form, reserved - (file?.size ?? 0))
907
- return file
908
- }
909
-
910
- /** The first part of a multipart form: its input, as JSON within the form's `maxBytes`, parsed strictly. */
911
- async #multipartInput(served: Extract<ServedEndpoint, { kind: 'form' }>, parts: MultipartReader): Promise<Submission> {
912
- const first = await parts.next()
913
- if (first === null || first.name !== FORM_INPUT_PART || first.filename !== null) {
914
- await first?.body.cancel()
915
- throw new HttpError(400, 'multipart', `'${served.row.id}': the first part is the input as JSON, named '${FORM_INPUT_PART}', without a filename`)
922
+ async #issueChallenge(served: Extract<ServedEndpoint, { kind: 'challenge' }>, request: Request): Promise<Response> {
923
+ const release = this.#parts.publicShare.enter()
924
+ if (release === null) {
925
+ throw new HttpError(503, 'public_share_full', 'Too many anonymous requests at once; try again shortly')
916
926
  }
917
- const limit = maxBytesOf(served.row)
918
- const bytes = await readBoundedBytes(first.body, { maxBytes: limit })
919
- if (bytes === null) {
920
- throw new HttpError(413, 'too_large', `The input is larger than ${limit} bytes`)
927
+ try {
928
+ await this.#admit(served.row, served.admission, request, null)
929
+ return jsonResponse(await served.issuer.issue(new URL(request.url).hostname), 200)
930
+ } finally {
931
+ release()
921
932
  }
922
- return submissionOf(strictInput(served.row, served.input, parseJson(bytes)), served.admission.fields)
923
933
  }
924
934
 
925
935
  /**
926
- * The token verifies for this host and this form, and was not accepted before unless it is the `repeatable` one: a
927
- * failure is 400, an unreachable provider 503.
936
+ * Sends each job once, after the commit: in the background, never awaited and never rejecting. Without an execution
937
+ * context a kick runs detached.
928
938
  */
929
- async #challenge(form: string, verifier: ChallengeVerifier, token: string | null, request: Request, repeatable: string | null): Promise<void> {
930
- const outcome = token === null ? 'failed' : await verifier.verify({ token, hostname: new URL(request.url).hostname, action: form })
931
- if (outcome === 'unavailable') {
932
- throw new ServerFault('challenge_unavailable', `the ${verifier.provider} challenge of '${form}' could not be verified`, 503)
939
+ #kickAll(jobs: readonly string[], exec: RequestExecutionContext | undefined): void {
940
+ for (const job of new Set(jobs)) {
941
+ const kicked = this.#parts.kick(job)
942
+ exec?.waitUntil(kicked)
933
943
  }
934
- if (outcome === 'misconfigured') {
935
- throw new ServerFault('challenge_misconfigured', `the ${verifier.provider} challenge of '${form}' refused the application's secret`, 503)
944
+ }
945
+
946
+ /**
947
+ * Takes one from each bucket of the endpoint, in order, before the body is read; a full one is a 429, and a take
948
+ * stays taken whatever the request does next. `caller` is the principal a `perCaller` bucket counts. A
949
+ * `bytesPerHour` budget is charged the body's declared length, at most the endpoint's body limit, or that limit when
950
+ * the body declares none.
951
+ */
952
+ async #admit(row: EndpointRow, admission: ProcedureAdmission, request: Request, caller: string | null): Promise<void> {
953
+ const endpoint = row.id
954
+ for (const { bucket, limit } of bucketsOf(admission.rate, request, caller)) {
955
+ if (!(await this.#framework().admissions.take(endpoint, bucket, limit))) {
956
+ throw new HttpError(429, 'rate_limited', `'${endpoint}' admits no more calls this minute; try again later`)
957
+ }
936
958
  }
937
- const refused = token === null || outcome === 'failed'
938
- || (token !== repeatable
939
- && !(await this.#framework().forms.recordChallenge(form, sha256Hex(token), CHALLENGE_LIMITS.maxAgeMs + CHALLENGE_LIMITS.maxSkewMs)))
940
- if (refused) {
941
- throw new HttpError(400, 'challenge_failed', 'The challenge did not verify; solve it again')
959
+ const bytesPerHour = admission.rate.bytesPerHour
960
+ if (bytesPerHour === null) {
961
+ return
962
+ }
963
+ const declared = Number(request.headers.get('Content-Length') ?? '')
964
+ const charged = Math.min(Number.isSafeInteger(declared) && declared > 0 ? declared : maxBytesOf(row), maxBytesOf(row))
965
+ if (charged > 0 && !(await this.#framework().admissions.reserveBytes(endpoint, charged, bytesPerHour))) {
966
+ throw new HttpError(429, 'rate_limited', `'${endpoint}' admits no more bytes this hour; try again later`)
967
+ }
968
+ }
969
+
970
+ async #publicInvocation(
971
+ served: Extract<ServedEndpoint, { kind: 'public' }>,
972
+ request: Request,
973
+ body: Awaited<ReturnType<typeof procedureBody>>,
974
+ fields: Readonly<Record<string, unknown>>,
975
+ query: Readonly<Record<string, unknown>> | undefined,
976
+ carried: Set<string>,
977
+ perms: readonly PermissionEntry[],
978
+ exec: RequestExecutionContext | undefined,
979
+ ): Promise<Response | FileDownload> {
980
+ const kicks = kickRecorder(`'${served.row.id}'`, served.site.declaration.kicks)
981
+ const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.site.kind === 'query', endpoint: served.row.id })
982
+ const work = new FileWork(frame, frame.id)
983
+ const invocation = new Invocation(frame, this.#data(), served.site.kind === 'query')
984
+ const subject = publicSubject(served.row.id)
985
+ const app = (action: string): AppCapability<string> => {
986
+ if (frame.ended) {
987
+ throw new CapabilityRefusedError('ended', `request ${frame.id} has ended; it mints no capability`)
988
+ }
989
+ if (!served.actions.includes(action)) {
990
+ throw new CapabilityRefusedError('undeclared_action', `public procedure '${served.row.id}' does not declare this action`)
991
+ }
992
+ if (!permits([...perms], action)) {
993
+ throw new CapabilityRefusedError('undeclared_action', `the token IAM signed for the key of '${served.row.id}' does not carry this action`)
994
+ }
995
+ return this.#parts.mint.app(frame, action, subject)
996
+ }
997
+ let outcome: 'open' | 'committing' = 'open'
998
+ try {
999
+ // The file parts come after the challenge, so a refused visitor stores nothing.
1000
+ const input = body.multipart === null
1001
+ ? fields
1002
+ : this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
1003
+ // The handler's time starts after the last body byte, so a slow upload does not eat it.
1004
+ const signal = handlerSignal(request, served.admission.timeoutMs)
1005
+ let result: unknown
1006
+ try {
1007
+ result = await runInRequest(frame, async () => {
1008
+ const handles = invocation.handles(
1009
+ served.site.kind,
1010
+ served.site.declaration,
1011
+ subject,
1012
+ { carried },
1013
+ this.#parts.fileKinds?.binding(work),
1014
+ )
1015
+ const where = `'${served.row.id}'`
1016
+ const ctx = Object.freeze({
1017
+ principal: Object.freeze({ kind: 'public', endpoint: served.row.id }),
1018
+ requestId: frame.id,
1019
+ signal,
1020
+ client: requestClient(request, this.#parts.clientVouched),
1021
+ url: this.#parts.url,
1022
+ app,
1023
+ db: Object.freeze({ app: (action: string) => handles.app(app(action)) }),
1024
+ ...this.#signing(`'${served.row.id}'`, served.site.declaration.signs),
1025
+ files: storedFilesHandle(this.#parts.fileKinds, work, {
1026
+ where,
1027
+ check: boundTo(frame, () => false, 'ctx.files'),
1028
+ writes: served.site.kind !== 'query',
1029
+ }),
1030
+ kick: kicks.kick,
1031
+ })
1032
+ const args = { input, ctx, ...(query === undefined ? {} : { query }) }
1033
+ return await runAsSite(`'${served.row.id}'`, served.site.declaration.egress, async () => await served.handler(args))
1034
+ })
1035
+ } finally {
1036
+ frame.end()
1037
+ }
1038
+ const output = served.row.output
1039
+ const answer = output !== null && isFileShape(output)
1040
+ ? await this.#fileKinds(served.row.id).download(work, served.row.id, output.kind, result)
1041
+ : outputResponse(served.row, served.output, result, request, carried)
1042
+ outcome = 'committing'
1043
+ await commitAnswer(invocation, signal, answer)
1044
+ this.#kickAll(kicks.kicked(), exec)
1045
+ return answer
1046
+ } finally {
1047
+ await invocation.rollback()
1048
+ if (outcome === 'open') {
1049
+ await this.#parts.fileKinds?.release(work)
1050
+ }
942
1051
  }
943
1052
  }
944
1053
 
945
1054
  /**
946
- * A GitHub delivery, in order: the binding the path names, the signature over the raw body with that binding's
947
- * secret, the account the payload names, the input, then the delivery key — the hash of the binding and the
1055
+ * A webhook delivery, in order: the binding the path names, the signature over the raw body with that binding's
1056
+ * secret, the account a GitHub payload names, the input, then the delivery key — the hash of the binding and the
948
1057
  * signed body, so a replay is ignored whatever its unsigned headers say. The handler gets a `Ref` of the bound
949
- * value for each declared action and the key, and runs at least once: a failed run is forgotten and redelivered.
1058
+ * value for each declared action, or mints application-wide ones when the webhook is bound to `'app'`, and the
1059
+ * key; it runs at least once: a failed run is forgotten and redelivered.
950
1060
  */
951
- async #webhook(served: Extract<ServedEndpoint, { kind: 'webhook' }>, request: Request, params: Readonly<Record<string, string>>): Promise<Response> {
1061
+ async #webhook(
1062
+ served: Extract<ServedEndpoint, { kind: 'webhook' }>,
1063
+ request: Request,
1064
+ params: Readonly<Record<string, string>>,
1065
+ exec: RequestExecutionContext | undefined,
1066
+ ): Promise<Response> {
952
1067
  admitContentType(served.row, request)
953
- const body = await readBoundedBody(request, maxBytesOf(served.row))
1068
+ const limit = maxBytesOf(served.row)
1069
+ const body = await readBoundedBody(request, limit, AbortSignal.timeout(bodyDeadlineMs(limit)))
954
1070
  const tables = this.#framework().webhooks
955
1071
  const bindingId = params[WEBHOOK_BINDING_PARAMETER] ?? ''
956
1072
  const binding = BINDING_ID.test(bindingId) ? await tables.binding(served.webhook, bindingId) : null
957
1073
  if (binding === null) {
958
1074
  throw new NotFoundError('Not found')
959
1075
  }
960
- if (!(await verifyGithubSignature(request.headers.get('X-Hub-Signature-256'), binding.secret, body))) {
1076
+ if (served.scheme === 'twilio' && this.#parts.origin === null) {
1077
+ throw new ServerFault(
1078
+ 'webhook_unverifiable',
1079
+ `webhook '${served.webhook}' checks Twilio's signature over its public URL, and the environment has no FABRIKA_APP_ORIGIN`,
1080
+ )
1081
+ }
1082
+ const target = rawRequestTarget(request.url)
1083
+ const url = this.#parts.origin === null ? null : `${this.#parts.origin}${target.pathname}${target.search}`
1084
+ if (!(await verifyDelivery(served.scheme, { headers: request.headers, body, url }, binding.secret, Date.now()))) {
961
1085
  throw new UnauthorizedError('The delivery signature does not verify with this binding')
962
1086
  }
963
- const payload = parseJson(body)
964
- if (!githubAccounts(payload).includes(binding.account)) {
1087
+ const payload = mediaType(request.headers.get('Content-Type')) === FORM_URLENCODED_TYPE
1088
+ ? paramsObject(new URLSearchParams(utf8Body(body)), served.row.input)
1089
+ : parseJson(body)
1090
+ // Slack confirms a new events URL by asking it to echo a challenge, signed like every delivery; no handler runs.
1091
+ const challenge = served.scheme === 'slack' ? slackChallenge(payload) : null
1092
+ if (challenge !== null) {
1093
+ return jsonResponse({ challenge }, 200)
1094
+ }
1095
+ // Only GitHub names the account in its payload; another provider's binding is its own secret.
1096
+ if (served.scheme === 'github-sha256' && !githubAccounts(payload).includes(binding.account)) {
965
1097
  throw new ForbiddenError('The delivery names another account than its binding accepts')
966
1098
  }
967
1099
  const input = strictInput(served.row, served.input, stripped(served, payload))
968
- if (binding.scope.type !== served.dimension) {
1100
+ const bound = served.boundTo.kind === 'app' ? APP_BINDING_SCOPE.type : served.boundTo.dimension
1101
+ if (binding.scope.type !== bound) {
969
1102
  throw new ServerFault(
970
1103
  'webhook_binding_mismatch',
971
- `binding '${binding.id}' reaches a '${binding.scope.type}', and '${served.webhook}' is bound to '${served.dimension}'`,
1104
+ `binding '${binding.id}' reaches a '${binding.scope.type}', and '${served.webhook}' is bound to '${bound}'`,
972
1105
  )
973
1106
  }
974
- const chain = await this.#chain(binding.scope.type, binding.scope.value)
1107
+ const chain = served.boundTo.kind === 'app' ? new Map<string, string>() : await this.#chain(binding.scope.type, binding.scope.value)
975
1108
  if (chain === null) {
976
1109
  throw new NotFoundError('The bound value no longer exists')
977
1110
  }
978
- // Asked before the delivery is recorded: a delivery that cannot run is not one GitHub must not redeliver.
1111
+ // Asked before the delivery is claimed: a delivery that cannot run is one the provider must redeliver.
979
1112
  const authority = await served.credential.authority()
980
1113
  if (!authority.ok) {
981
1114
  throw new ServerFault('workload_unauthorized', `the key of webhook '${served.webhook}' gives it no authority: ${authority.reason}`, 503)
@@ -985,49 +1118,121 @@ export class ContractRuntime {
985
1118
  throw new ServerFault('workload_unauthorized', `the token IAM signed for the key of webhook '${served.webhook}' does not carry '${unheld}'`, 503)
986
1119
  }
987
1120
  const key = await deliveryKey(binding.id, body)
1121
+ const budgetMs = served.timeoutMs
988
1122
  const now = Date.now()
989
- if (!(await tables.recordDelivery(binding.id, key, now, now - WEBHOOK_REPLAY_RETENTION_MS))) {
1123
+ const claim = await tables.claimDelivery(binding.id, key, now, budgetMs + WEBHOOK_CLAIM_GRACE_MS, now - WEBHOOK_REPLAY_RETENTION_MS)
1124
+ if (claim === null) {
990
1125
  return jsonResponse({ result: { received: true } }, 200)
991
1126
  }
992
1127
  const frame = new RequestFrame(crypto.randomUUID())
1128
+ const work = new FileWork(frame, frame.id)
993
1129
  const site = served.site
994
- // A staged-kit delivery is one transaction; the delivery record above stays outside it, so a failure forgets it.
1130
+ // The claim stays outside the delivery's transaction, and its completion commits inside it, with the work.
995
1131
  const invocation = site === undefined ? null : new Invocation(frame, this.#data(), false)
1132
+ const kicks = kickRecorder(`webhook '${served.webhook}'`, site?.kicks ?? [])
1133
+ const signal = handlerSignal(request, budgetMs)
1134
+ let committing = false
996
1135
  try {
997
1136
  try {
998
1137
  await runInRequest(frame, async () => {
999
1138
  const subject = webhookSubject(binding.id)
1000
- const refs = Object.freeze(
1001
- Object.fromEntries(
1002
- served.actions.map((action) => [action, this.#parts.mint.ref(frame, served.dimension, action, subject, binding.scope.value, chain)]),
1003
- ),
1004
- )
1139
+ const reach = this.#webhookReach(served, frame, subject, binding.scope.value, chain)
1005
1140
  const principal: WebhookPrincipal = Object.freeze({ kind: 'webhook', bindingId: binding.id })
1006
1141
  const delivery = Object.freeze({ key })
1007
- const data = site === undefined || invocation === null
1142
+ const handles = site === undefined || invocation === null
1143
+ ? null
1144
+ : invocation.handles('mutation', site, subject, 'declared', this.#parts.fileKinds?.binding(work))
1145
+ const data = site === undefined || invocation === null || handles === null
1008
1146
  ? {}
1009
1147
  : {
1010
- db: siteData(invocation.handles('mutation', site, subject)),
1148
+ db: siteData(handles, (action) => handles.app(reach.app(action))),
1011
1149
  sql: site.sql === null ? undeclaredSql(served.row.id, 'mutation') : invocation.sql(site.sql),
1012
1150
  }
1013
- const signal = handlerSignal(request)
1014
- await served.handler({ input, ctx: Object.freeze({ principal, requestId: frame.id, signal, refs, delivery, ...data }) })
1151
+ const client = requestClient(request, this.#parts.clientVouched)
1152
+ const args = {
1153
+ input,
1154
+ ctx: Object.freeze({
1155
+ principal,
1156
+ requestId: frame.id,
1157
+ signal,
1158
+ client,
1159
+ url: this.#parts.url,
1160
+ ...reach.context,
1161
+ delivery,
1162
+ files: storedFilesHandle(this.#parts.fileKinds, work, {
1163
+ where: `webhook '${served.webhook}'`,
1164
+ check: boundTo(frame, () => false, 'ctx.files'),
1165
+ writes: true,
1166
+ }),
1167
+ ...this.#signing(`webhook '${served.webhook}'`, site?.signs ?? []),
1168
+ kick: kicks.kick,
1169
+ ...data,
1170
+ }),
1171
+ }
1172
+ if (site === undefined) {
1173
+ await served.handler(args)
1174
+ } else {
1175
+ await runAsSite(`webhook '${served.webhook}'`, site.egress, async () => await served.handler(args))
1176
+ }
1015
1177
  })
1016
1178
  } finally {
1017
1179
  frame.end()
1018
1180
  }
1019
- await invocation?.commit(null)
1181
+ const using = invocation === null ? undefined : await invocation.frameworkSite('the delivery claim').session()
1182
+ if (!(await tables.completeDelivery(claim, using))) {
1183
+ throw new ServerFault('webhook_claim_lost', `the claim of a delivery to '${served.webhook}' expired and another copy took it over`)
1184
+ }
1185
+ committing = true
1186
+ await invocation?.commit(signal)
1187
+ this.#kickAll(kicks.kicked(), exec)
1020
1188
  } catch (error) {
1021
1189
  await invocation?.rollback()
1022
- // The provider redelivers a failed delivery; forgetting its key lets the redelivery run. The unsigned
1190
+ // Only a known rollback deletes what the delivery staged; after a commit that may have happened, the sweep decides.
1191
+ if (!committing) {
1192
+ await this.#parts.fileKinds?.release(work)
1193
+ }
1194
+ // The provider redelivers a failed delivery; giving the claim back lets the redelivery run at once. The unsigned
1023
1195
  // delivery header is the caller's word, so the log names only the endpoint.
1024
1196
  console.error(`fabrika: ${served.row.id}: delivery_failed; the provider's redelivery runs it again`)
1025
- await tables.forgetDelivery(binding.id, key)
1197
+ await tables.releaseDelivery(claim)
1026
1198
  throw error
1027
1199
  }
1028
1200
  return jsonResponse({ result: { received: true } }, 200)
1029
1201
  }
1030
1202
 
1203
+ /**
1204
+ * What a delivery's handler acts with: a `Ref` of the bound value for each declared action, or, for a webhook bound to
1205
+ * `'app'`, `ctx.app(<action>)`, which mints a declared action the webhook's token carries; `ctx.db.app` binds its
1206
+ * handles to what `app` mints, and a webhook bound to a dimension refuses both.
1207
+ */
1208
+ #webhookReach(
1209
+ served: Extract<ServedEndpoint, { kind: 'webhook' }>,
1210
+ frame: RequestFrame,
1211
+ subject: string,
1212
+ value: string,
1213
+ chain: ReadonlyMap<string, string>,
1214
+ ): { readonly context: Readonly<Record<string, unknown>>; readonly app: (action: string) => AppCapability<string> } {
1215
+ const boundTo = served.boundTo
1216
+ if (boundTo.kind === 'dimension') {
1217
+ const refs = Object.freeze(
1218
+ Object.fromEntries(served.actions.map((action) => [action, this.#parts.mint.ref(frame, boundTo.dimension, action, subject, value, chain)])),
1219
+ )
1220
+ const app = boundWebhookApp(served.webhook)
1221
+ return { context: { refs, app }, app }
1222
+ }
1223
+ // The delivery checked once that the token carries every declared action, before it was recorded.
1224
+ const app = (action: string): AppCapability<string> => {
1225
+ if (frame.ended) {
1226
+ throw new CapabilityRefusedError('ended', `delivery ${frame.id} has ended; it mints no capability`)
1227
+ }
1228
+ if (!served.actions.includes(action)) {
1229
+ throw new CapabilityRefusedError('undeclared_action', `webhook '${served.webhook}' does not declare this action`)
1230
+ }
1231
+ return this.#parts.mint.app(frame, action, subject)
1232
+ }
1233
+ return { context: { app }, app }
1234
+ }
1235
+
1031
1236
  #framework(): FrameworkTables {
1032
1237
  const framework = this.#parts.framework
1033
1238
  if (framework === null) {
@@ -1061,9 +1266,10 @@ function siteContext(
1061
1266
  invocation: Invocation,
1062
1267
  principal: string,
1063
1268
  access: Readonly<Record<string, unknown>>,
1269
+ files: FileBinding | undefined,
1064
1270
  ): Readonly<Record<string, unknown>> {
1065
- const form = row.requirement.kind === 'member' ? 'member' : row.requirement.access.form
1066
- const db = endpointData(invocation.handles(site.kind, site.declaration, principal), form, {
1271
+ const form = row.requirement.kind === 'member' ? 'member' : row.requirement.kind === 'capabilities' ? 'capabilities' : row.requirement.access.form
1272
+ const db = endpointData(invocation.handles(site.kind, site.declaration, principal, 'declared', files), form, {
1067
1273
  target: access['target'],
1068
1274
  within: access['within'],
1069
1275
  created: access['created'],
@@ -1078,17 +1284,37 @@ function holdsOnChain(perms: PermissionEntry[], action: string, dimension: strin
1078
1284
  return scopes.some(([type, scope]) => permits(perms, action, { type, value: scope }))
1079
1285
  }
1080
1286
 
1081
- function projectOutput(served: Extract<ServedEndpoint, { kind: 'procedure' }>, result: unknown): unknown {
1287
+ /**
1288
+ * The output, projected and validated whole: JSON, or a redirect to its validated target. `carried` are the texts the
1289
+ * request's verified tokens carry: a URL among them is a target the application signed.
1290
+ */
1291
+ function outputResponse(row: EndpointRow, schema: Schema, result: unknown, request: Request, carried: ReadonlySet<string>): Response {
1292
+ const output = z.safeParse(schema, projectSchema(row.id, schema, result))
1293
+ if (!output.success) {
1294
+ throw new ServerFault('output_refused', `'${row.id}' returned a value its output schema refuses`)
1295
+ }
1296
+ if (row.output !== null && isRedirectShape(row.output)) {
1297
+ return redirectResponse(row, row.output, output.data, request, carried)
1298
+ }
1299
+ return jsonResponse({ result: output.data }, 200)
1300
+ }
1301
+
1302
+ function projectSchema(endpoint: string, output: Schema, result: unknown): unknown {
1082
1303
  try {
1083
- return project(served.output, result)
1304
+ return project(output, result)
1084
1305
  } catch (error) {
1085
1306
  if (error instanceof ProjectionRefusedError) {
1086
- throw new ServerFault('output_refused', `'${served.row.id}' returned a value no branch of its output union accepts`)
1307
+ throw new ServerFault('output_refused', `'${endpoint}' returned a value no branch of its output union accepts`)
1087
1308
  }
1088
1309
  throw error
1089
1310
  }
1090
1311
  }
1091
1312
 
1313
+ /** The principal a public procedure's capabilities are granted to: its workload, never a visitor. */
1314
+ function publicSubject(endpoint: string): string {
1315
+ return `public:${endpoint}`
1316
+ }
1317
+
1092
1318
  /**
1093
1319
  * Admission. A request that carries no `Authorization` header is authenticated by the session cookie the proxy
1094
1320
  * redeemed; when it is not a `GET`, it must come from this origin. A cross-site page cannot add the header: it is
@@ -1102,7 +1328,7 @@ function admit(row: CredentialedEndpoint, request: Request): void {
1102
1328
  }
1103
1329
 
1104
1330
  /** What one part adds to a multipart body beside its bytes: its delimiter and its headers, at most. */
1105
- const MULTIPART_PART_OVERHEAD = FORM_FILE_LIMITS.maxPartHeaderBytes + 128
1331
+ const MULTIPART_PART_OVERHEAD = PROCEDURE_LIMITS.maxPartHeaderBytes + 128
1106
1332
 
1107
1333
  /**
1108
1334
  * What a failed multipart submission answers: a body that broke the format 400, one that did not arrive in time 408,
@@ -1129,10 +1355,6 @@ function multipartFailure(error: unknown, parts: MultipartReader, deadline: Abor
1129
1355
  }
1130
1356
 
1131
1357
  /** The content types an endpoint admits; an anonymous endpoint has no session, so this is its whole admission. */
1132
- function formRateLimited(row: EndpointRow): HttpError {
1133
- return new HttpError(429, 'rate_limited', `'${row.id}' takes no more submissions right now; try again in a minute`)
1134
- }
1135
-
1136
1358
  function admitContentType(row: EndpointRow, request: Request): void {
1137
1359
  if (row.admission !== null) {
1138
1360
  const type = mediaType(request.headers.get('Content-Type'))
@@ -1153,12 +1375,29 @@ function maxBytesOf(row: EndpointRow): number {
1153
1375
 
1154
1376
  function parseJson(body: Uint8Array): unknown {
1155
1377
  try {
1156
- return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body))
1378
+ return JSON.parse(utf8Body(body))
1157
1379
  } catch {
1158
1380
  throw new BadRequestError('The body is not JSON')
1159
1381
  }
1160
1382
  }
1161
1383
 
1384
+ function utf8Body(body: Uint8Array): string {
1385
+ try {
1386
+ return new TextDecoder('utf-8', { fatal: true }).decode(body)
1387
+ } catch {
1388
+ throw new BadRequestError('The body is not UTF-8')
1389
+ }
1390
+ }
1391
+
1392
+ /** The challenge of Slack's `url_verification` request, which the URL answers as `{ challenge }`; `null` for any other. */
1393
+ function slackChallenge(payload: unknown): string | null {
1394
+ if (!isRecord(payload) || payload['type'] !== 'url_verification') {
1395
+ return null
1396
+ }
1397
+ const challenge = payload['challenge']
1398
+ return typeof challenge === 'string' ? challenge : null
1399
+ }
1400
+
1162
1401
  /** A webhook input keeps only the fields it declares: a provider sends many more, and none reaches the handler. */
1163
1402
  function stripped(served: Extract<ServedEndpoint, { kind: 'webhook' }>, payload: unknown): unknown {
1164
1403
  try {
@@ -1221,17 +1460,30 @@ function nulAt(value: unknown, path: readonly string[]): readonly string[] | nul
1221
1460
  }
1222
1461
 
1223
1462
  /** The strictly parsed input; a procedure without input takes an empty body or `{}`. */
1463
+ /**
1464
+ * A procedure's input: a GET's from the URL's flat parameters; a POST's from its body, JSON or a native form's
1465
+ * urlencoded fields, within the row's body limit and deadline.
1466
+ */
1224
1467
  async function readInput(
1225
- served: { readonly row: CredentialedEndpoint; readonly input: Schema | null },
1468
+ served: { readonly row: EndpointRow; readonly input: Schema | null },
1226
1469
  request: Request,
1227
1470
  ): Promise<Readonly<Record<string, unknown>>> {
1228
- const text = await request.text()
1229
1471
  let body: unknown = {}
1230
- if (text.trim() !== '') {
1231
- try {
1232
- body = JSON.parse(text)
1233
- } catch {
1234
- throw new BadRequestError('The body is not JSON')
1472
+ if (served.row.method === 'GET') {
1473
+ body = paramsObject(urlParams(request), served.row.input)
1474
+ } else {
1475
+ const limit = served.row.admission?.maxBytes ?? null
1476
+ const text = limit === null
1477
+ ? await request.text()
1478
+ : new TextDecoder().decode(await readBoundedBody(request, limit, AbortSignal.timeout(bodyDeadlineMs(limit))))
1479
+ if (mediaType(request.headers.get('Content-Type')) === FORM_URLENCODED_TYPE) {
1480
+ body = paramsObject(new URLSearchParams(text), served.row.input)
1481
+ } else if (text.trim() !== '') {
1482
+ try {
1483
+ body = JSON.parse(text)
1484
+ } catch {
1485
+ throw new BadRequestError('The body is not JSON')
1486
+ }
1235
1487
  }
1236
1488
  }
1237
1489
  if (served.input === null) {
@@ -1243,18 +1495,188 @@ async function readInput(
1243
1495
  return strictInput(served.row, served.input, body)
1244
1496
  }
1245
1497
 
1246
- /** A canonical path's segments all decode; a value that does not is no id of anything. */
1247
- function decodePathParameter(raw: string): string {
1248
- let decoded: string
1498
+ /**
1499
+ * A procedure's body: its input read whole (JSON, urlencoded, or a GET's URL parameters), or a multipart body whose JSON
1500
+ * part was read and whose file parts follow. Only a procedure with file fields reads multipart.
1501
+ */
1502
+ async function procedureBody(
1503
+ served: { readonly row: EndpointRow; readonly input: Schema | null },
1504
+ request: Request,
1505
+ kinds: StoredFileKinds | null,
1506
+ ): Promise<
1507
+ { readonly input: Readonly<Record<string, unknown>>; readonly multipart: null } | { readonly input: null; readonly multipart: MultipartInput }
1508
+ > {
1509
+ const fields = fileFieldsOf(served.row.input)
1510
+ if (served.row.method === 'POST' && fields.size > 0 && mediaType(request.headers.get('Content-Type')) === FORM_MULTIPART_TYPE) {
1511
+ return { input: null, multipart: await openMultipart(served.row, fields, request, kinds) }
1512
+ }
1513
+ return { input: await readInput(served, request), multipart: null }
1514
+ }
1515
+
1516
+ /** A multipart body being read: its JSON input part, read first, and its file parts, read by `files`. */
1517
+ interface MultipartInput {
1518
+ /** The input part's fields: no file field among them; the challenge reads its token from here. */
1519
+ readonly json: Readonly<Record<string, unknown>>
1520
+ /** Reads every file part as the body streams, staging each for `work`: the file fields' references by name. */
1521
+ files(work: FileWork): Promise<Readonly<Record<string, unknown>>>
1522
+ /** Ends the read and the body deadline, whatever happened. */
1523
+ close(): Promise<void>
1524
+ }
1525
+
1526
+ /**
1527
+ * Opens a procedure's `multipart/form-data` body: its first part is the input as JSON (named `input`, at most the
1528
+ * procedure's `maxBytes`), and each later part a file of a file field, under the field's name. The whole body arrives
1529
+ * within the deadline its `maxBytes` derives and holds at most `maxBytes` and each part's overhead: a declared length
1530
+ * past that is refused unread, and a body without one is cut off once it passes it.
1531
+ */
1532
+ async function openMultipart(
1533
+ row: EndpointRow,
1534
+ fields: ReturnType<typeof fileFieldsOf>,
1535
+ request: Request,
1536
+ kinds: StoredFileKinds | null,
1537
+ ): Promise<MultipartInput> {
1538
+ const boundary = multipartBoundary(request.headers.get('Content-Type'))
1539
+ if (boundary === null) {
1540
+ throw new HttpError(400, 'admission', `'${row.id}' takes multipart/form-data with a boundary`)
1541
+ }
1542
+ const maxBytes = maxBytesOf(row)
1543
+ const files = [...fields.values()].reduce((sum, field) => sum + field.max, 0)
1544
+ const tooLarge = () => new HttpError(413, 'too_large', `The body is larger than the ${maxBytes} bytes '${row.id}' admits`)
1545
+ const bodyBytes = maxBytes + (files + 1) * MULTIPART_PART_OVERHEAD
1546
+ const declared = Number(request.headers.get('Content-Length') ?? '')
1547
+ if (Number.isFinite(declared) && declared > bodyBytes) {
1548
+ throw tooLarge()
1549
+ }
1550
+ const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(bodyDeadlineMs(maxBytes))])
1551
+ const body = boundedStream(request.body ?? new Blob([]).stream(), bodyBytes)
1552
+ const parts = multipartParts(body.stream, boundary, { maxHeaderBytes: PROCEDURE_LIMITS.maxPartHeaderBytes })
1553
+ const failure = (error: unknown) => (body.exceeded() ? tooLarge() : multipartFailure(error, parts, deadline, request))
1554
+ const stop = () => {
1555
+ parts.cancel().catch(() => {})
1556
+ }
1557
+ deadline.addEventListener('abort', stop, { once: true })
1558
+ const close = async () => {
1559
+ deadline.removeEventListener('abort', stop)
1560
+ await parts.cancel()
1561
+ }
1562
+ let json: Readonly<Record<string, unknown>>
1249
1563
  try {
1250
- decoded = decodeURIComponent(raw)
1251
- } catch {
1252
- throw new BadRequestError('A path parameter does not decode')
1564
+ const first = await parts.next()
1565
+ if (first === null || first.name !== FILE_INPUT_PART || first.filename !== null) {
1566
+ await first?.body.cancel()
1567
+ throw new HttpError(400, 'multipart', `'${row.id}': the first part is the input as JSON, named '${FILE_INPUT_PART}', without a filename`)
1568
+ }
1569
+ const bytes = await readBoundedBytes(first.body, { maxBytes })
1570
+ if (bytes === null) {
1571
+ throw tooLarge()
1572
+ }
1573
+ const parsed = parseJson(bytes)
1574
+ if (!isRecord(parsed) || Object.keys(parsed).some((name) => fields.has(name))) {
1575
+ throw new HttpError(400, 'multipart', `'${row.id}': the input part is an object, and each file field arrives as a part of its own`)
1576
+ }
1577
+ json = parsed
1578
+ } catch (error) {
1579
+ const refused = failure(error)
1580
+ await close()
1581
+ throw refused
1582
+ }
1583
+ return {
1584
+ json,
1585
+ close,
1586
+ files: async (work) => {
1587
+ if (kinds === null) {
1588
+ throw new ServerFault('facility_unbound', `'${row.id}' takes files, and boot bound no file kinds`)
1589
+ }
1590
+ const values: Record<string, unknown> = {}
1591
+ const counts = new Map<string, number>()
1592
+ try {
1593
+ for (let part = await parts.next(); part !== null; part = await parts.next()) {
1594
+ const field = fields.get(part.name)
1595
+ const count = counts.get(part.name) ?? 0
1596
+ if (field === undefined || count >= field.max || part.filename === null) {
1597
+ await part.body.cancel()
1598
+ throw new HttpError(
1599
+ 400,
1600
+ 'multipart',
1601
+ `'${row.id}': a part is no file field of the input, a field carries more files than it admits, or a file has no filename`,
1602
+ )
1603
+ }
1604
+ counts.set(part.name, count + 1)
1605
+ const file = await kinds.receive(work, field.kind, part)
1606
+ if (file === null) {
1607
+ continue
1608
+ }
1609
+ const taken = values[part.name]
1610
+ values[part.name] = field.many ? [...(Array.isArray(taken) ? taken : []), file] : file
1611
+ }
1612
+ } catch (error) {
1613
+ throw failure(error)
1614
+ }
1615
+ return values
1616
+ },
1617
+ }
1618
+ }
1619
+
1620
+ /**
1621
+ * Commits the invocation of an answer that is ready: a file's object stream was opened before the commit, so a failed
1622
+ * commit cancels it and nothing of it is sent.
1623
+ */
1624
+ async function commitAnswer(invocation: Invocation | null, signal: AbortSignal, answer: Response | FileDownload): Promise<void> {
1625
+ try {
1626
+ await invocation?.commit(signal)
1627
+ } catch (error) {
1628
+ if (answer instanceof FileDownload) {
1629
+ await answer.body.cancel()
1630
+ }
1631
+ throw error
1253
1632
  }
1254
- if (decoded.includes('\u0000')) {
1255
- throw new BadRequestError('A path parameter may not contain U+0000')
1633
+ }
1634
+
1635
+ /** A POST's `query`: the URL's flat parameters, parsed strictly by the declared schema; `undefined` without one. */
1636
+ function readQuery(
1637
+ row: EndpointRow,
1638
+ query: Schema | undefined,
1639
+ shape: SchemaShape | undefined,
1640
+ request: Request,
1641
+ ): Readonly<Record<string, unknown>> | undefined {
1642
+ return query === undefined ? undefined : strictInput(row, query, paramsObject(urlParams(request), shape ?? null))
1643
+ }
1644
+
1645
+ /**
1646
+ * A redirect's answer: 303 after a POST, 302 after a GET, to a path of the application's own origin, a URL of an origin
1647
+ * the output lists, or an http(s) URL a verified token of this request carries as it is. Any other target fails the
1648
+ * invocation, so the procedure is never an open redirect.
1649
+ */
1650
+ function redirectResponse(row: EndpointRow, shape: RedirectShape, output: unknown, request: Request, carried: ReadonlySet<string>): Response {
1651
+ const to = isRecord(output) && typeof output['to'] === 'string' ? output['to'] : ''
1652
+ const own = to.startsWith('/') && !to.startsWith('//') && !to.includes('\\')
1653
+ let listed = false
1654
+ if (!own) {
1655
+ try {
1656
+ const url = new URL(to)
1657
+ listed = (url.protocol === 'https:' && shape.origins.includes(url.origin))
1658
+ || ((url.protocol === 'https:' || url.protocol === 'http:') && carried.has(to))
1659
+ } catch {
1660
+ listed = false
1661
+ }
1256
1662
  }
1257
- return decoded
1663
+ if (!own && !listed) {
1664
+ throw new ServerFault(
1665
+ 'redirect_refused',
1666
+ `'${row.id}' redirected to a target that is neither its own origin's path, a listed origin's URL nor a URL a verified token carries`,
1667
+ )
1668
+ }
1669
+ // The typed client reads the target and decides whether to navigate; a browser's form or link follows the redirect.
1670
+ if (asksForJson(request)) {
1671
+ return jsonResponse({ result: { to } }, 200)
1672
+ }
1673
+ return new Response(null, { status: request.method === 'GET' ? 302 : 303, headers: { Location: to } })
1674
+ }
1675
+
1676
+ /** A request whose `Accept` names JSON and not HTML: the typed client's, never a browser navigation's. */
1677
+ function asksForJson(request: Request): boolean {
1678
+ const accept = (request.headers.get('Accept') ?? '').toLowerCase()
1679
+ return accept.includes('application/json') && !accept.includes('text/html')
1258
1680
  }
1259
1681
 
1260
1682
  /** Removes the `from` field: the only copy of that id the handler gets is the checked capability. */