@fabrika/app 0.6.0 → 0.7.0

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