@fabrika/app 0.8.2 → 0.9.0-cloudflare.2

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 (201) hide show
  1. package/package.json +40 -24
  2. package/skill/SKILL.md +29 -3
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/dev.md +2 -1
  5. package/skill/references/endpoint.md +12 -3
  6. package/skill/references/errors.md +39 -35
  7. package/skill/references/files.md +4 -2
  8. package/skill/references/jobs.md +9 -0
  9. package/skill/references/table-and-migration.md +19 -2
  10. package/skill/references/tests.md +33 -1
  11. package/skill/references/webhooks-links-connectors.md +1 -1
  12. package/src/build-recipe.ts +198 -0
  13. package/src/build.ts +60 -4
  14. package/src/bun.ts +33 -8
  15. package/src/cloudflare/application.ts +352 -0
  16. package/src/cloudflare/bindings.ts +159 -0
  17. package/src/cloudflare/names.ts +16 -0
  18. package/src/cloudflare/ops.ts +246 -0
  19. package/src/cloudflare/r2-files.ts +172 -0
  20. package/src/cloudflare.ts +99 -146
  21. package/src/contract/app-id.ts +5 -0
  22. package/src/contract/artifact-schema.ts +8 -1
  23. package/src/contract/build.ts +68 -11
  24. package/src/contract/compile.ts +1 -0
  25. package/src/contract/context.ts +9 -0
  26. package/src/contract/declaration.ts +2 -0
  27. package/src/contract/define.ts +28 -2
  28. package/src/contract/group.ts +10 -1
  29. package/src/contract/kit.ts +6 -2
  30. package/src/contract/platform.ts +3 -2
  31. package/src/contract/vocabulary.ts +26 -8
  32. package/src/dev/changes.ts +35 -0
  33. package/src/dev/contract-digest.ts +17 -0
  34. package/src/dev/index.ts +11 -0
  35. package/src/dev/launcher.ts +713 -0
  36. package/src/dev/output.ts +72 -0
  37. package/src/dev/wire.ts +136 -0
  38. package/src/disk-assets.ts +8 -3
  39. package/src/files/archive.ts +2 -2
  40. package/src/files/job-names.ts +12 -0
  41. package/src/files/kinds.ts +19 -7
  42. package/src/files/scan.ts +3 -4
  43. package/src/files/streams.ts +3 -1
  44. package/src/implement/anonymous.ts +11 -1
  45. package/src/implement/boot.ts +70 -7
  46. package/src/implement/bounded.ts +21 -2
  47. package/src/implement/capability.ts +18 -3
  48. package/src/implement/dispatch.ts +251 -147
  49. package/src/implement/egress.ts +341 -8
  50. package/src/implement/failures.ts +17 -1
  51. package/src/implement/faults.ts +1 -0
  52. package/src/implement/index.ts +1 -0
  53. package/src/implement/invocation.ts +18 -9
  54. package/src/implement/principal.ts +25 -2
  55. package/src/implement/terminals.ts +93 -0
  56. package/src/implement/workload.ts +24 -13
  57. package/src/jobs/report.ts +5 -1
  58. package/src/jobs/runner.ts +40 -19
  59. package/src/jobs/schedule.ts +7 -1
  60. package/src/jobs/scrub.ts +14 -1
  61. package/src/spa-fallback.ts +12 -0
  62. package/src/testing/endpoint-access.ts +307 -152
  63. package/src/testing/isolation.ts +8 -1
  64. package/src/testing/kit.ts +20 -2
  65. package/src/testing/proxy.ts +5 -5
  66. package/types/src/agent-skill.d.ts +29 -0
  67. package/types/src/build-recipe.d.ts +70 -0
  68. package/types/src/build.d.ts +29 -0
  69. package/types/src/bun.d.ts +66 -0
  70. package/types/src/client/caller.d.ts +19 -0
  71. package/types/src/client/contract.d.ts +131 -0
  72. package/types/src/client/error-reporting.d.ts +66 -0
  73. package/types/src/client/index.d.ts +4 -0
  74. package/types/src/client/rpc.d.ts +14 -0
  75. package/types/src/client/share.d.ts +2 -0
  76. package/types/src/client/transport.d.ts +42 -0
  77. package/types/src/cloudflare/application.d.ts +42 -0
  78. package/types/src/cloudflare/bindings.d.ts +33 -0
  79. package/types/src/cloudflare/names.d.ts +11 -0
  80. package/types/src/cloudflare/ops.d.ts +46 -0
  81. package/types/src/cloudflare/r2-files.d.ts +9 -0
  82. package/types/src/cloudflare.d.ts +42 -0
  83. package/types/src/contract/app-id.d.ts +2 -0
  84. package/types/src/contract/artifact-schema.d.ts +8 -0
  85. package/types/src/contract/attachment.d.ts +41 -0
  86. package/types/src/contract/build.d.ts +12 -0
  87. package/types/src/contract/compile.d.ts +11 -0
  88. package/types/src/contract/connect.d.ts +51 -0
  89. package/types/src/contract/context.d.ts +510 -0
  90. package/types/src/contract/declaration.d.ts +307 -0
  91. package/types/src/contract/defaults.d.ts +208 -0
  92. package/types/src/contract/define.d.ts +376 -0
  93. package/types/src/contract/file.d.ts +44 -0
  94. package/types/src/contract/group.d.ts +282 -0
  95. package/types/src/contract/handled.d.ts +18 -0
  96. package/types/src/contract/index.d.ts +19 -0
  97. package/types/src/contract/kit.d.ts +211 -0
  98. package/types/src/contract/platform.d.ts +9 -0
  99. package/types/src/contract/problems.d.ts +14 -0
  100. package/types/src/contract/redirect.d.ts +25 -0
  101. package/types/src/contract/schema-from-shape.d.ts +10 -0
  102. package/types/src/contract/schema-shape.d.ts +5 -0
  103. package/types/src/contract/snapshot-source.d.ts +15 -0
  104. package/types/src/contract/token.d.ts +58 -0
  105. package/types/src/contract/vocabulary.d.ts +32 -0
  106. package/types/src/contract/webhook-binding.d.ts +24 -0
  107. package/types/src/contract/webhook-scheme.d.ts +23 -0
  108. package/types/src/dev/changes.d.ts +7 -0
  109. package/types/src/dev/contract-digest.d.ts +1 -0
  110. package/types/src/dev/index.d.ts +2 -0
  111. package/types/src/dev/launcher.d.ts +32 -0
  112. package/types/src/dev/output.d.ts +28 -0
  113. package/types/src/dev/wire.d.ts +126 -0
  114. package/types/src/disk-assets.d.ts +6 -0
  115. package/types/src/errors.d.ts +46 -0
  116. package/types/src/fetch.d.ts +7 -0
  117. package/types/src/files/archive.d.ts +26 -0
  118. package/types/src/files/clamscan.d.ts +32 -0
  119. package/types/src/files/handle.d.ts +106 -0
  120. package/types/src/files/job-names.d.ts +9 -0
  121. package/types/src/files/kinds.d.ts +101 -0
  122. package/types/src/files/scan.d.ts +78 -0
  123. package/types/src/files/sniff.d.ts +7 -0
  124. package/types/src/files/streams.d.ts +17 -0
  125. package/types/src/files/treatment.d.ts +18 -0
  126. package/types/src/from-row.d.ts +20 -0
  127. package/types/src/honeypot.d.ts +2 -0
  128. package/types/src/implement/anonymous.d.ts +28 -0
  129. package/types/src/implement/attachment.d.ts +14 -0
  130. package/types/src/implement/boot.d.ts +120 -0
  131. package/types/src/implement/bounded.d.ts +32 -0
  132. package/types/src/implement/browser-config.d.ts +19 -0
  133. package/types/src/implement/capability.d.ts +144 -0
  134. package/types/src/implement/challenge.d.ts +75 -0
  135. package/types/src/implement/client-address.d.ts +11 -0
  136. package/types/src/implement/connect.d.ts +67 -0
  137. package/types/src/implement/crashes.d.ts +31 -0
  138. package/types/src/implement/dispatch.d.ts +221 -0
  139. package/types/src/implement/egress.d.ts +49 -0
  140. package/types/src/implement/environment.d.ts +14 -0
  141. package/types/src/implement/failures.d.ts +39 -0
  142. package/types/src/implement/faults.d.ts +45 -0
  143. package/types/src/implement/headers.d.ts +7 -0
  144. package/types/src/implement/implement.d.ts +58 -0
  145. package/types/src/implement/index.d.ts +25 -0
  146. package/types/src/implement/invocation.d.ts +125 -0
  147. package/types/src/implement/membership.d.ts +201 -0
  148. package/types/src/implement/multipart.d.ts +36 -0
  149. package/types/src/implement/params.d.ts +7 -0
  150. package/types/src/implement/principal.d.ts +57 -0
  151. package/types/src/implement/project.d.ts +11 -0
  152. package/types/src/implement/public.d.ts +61 -0
  153. package/types/src/implement/serve.d.ts +25 -0
  154. package/types/src/implement/terminals.d.ts +33 -0
  155. package/types/src/implement/tokens.d.ts +49 -0
  156. package/types/src/implement/types.d.ts +243 -0
  157. package/types/src/implement/urls.d.ts +15 -0
  158. package/types/src/implement/verifiers.d.ts +19 -0
  159. package/types/src/implement/webhook.d.ts +55 -0
  160. package/types/src/implement/workload.d.ts +56 -0
  161. package/types/src/implement/xlsx.d.ts +6 -0
  162. package/types/src/index.d.ts +12 -0
  163. package/types/src/jobs/exception.d.ts +40 -0
  164. package/types/src/jobs/mail.d.ts +67 -0
  165. package/types/src/jobs/report.d.ts +56 -0
  166. package/types/src/jobs/runner.d.ts +154 -0
  167. package/types/src/jobs/schedule.d.ts +17 -0
  168. package/types/src/jobs/scrub.d.ts +12 -0
  169. package/types/src/jobs/types.d.ts +98 -0
  170. package/types/src/react/caller.d.ts +30 -0
  171. package/types/src/react/errors.d.ts +40 -0
  172. package/types/src/react/hooks.d.ts +110 -0
  173. package/types/src/react/index.d.ts +5 -0
  174. package/types/src/react/provider.d.ts +58 -0
  175. package/types/src/react/store.d.ts +68 -0
  176. package/types/src/react/testing.d.ts +28 -0
  177. package/types/src/react/types.d.ts +33 -0
  178. package/types/src/rpc/builder.d.ts +52 -0
  179. package/types/src/rpc/contract.d.ts +23 -0
  180. package/types/src/rpc/dispatcher.d.ts +11 -0
  181. package/types/src/rpc/types.d.ts +62 -0
  182. package/types/src/service/define.d.ts +42 -0
  183. package/types/src/service/index.d.ts +3 -0
  184. package/types/src/service/serve.d.ts +28 -0
  185. package/types/src/spa-fallback.d.ts +2 -0
  186. package/types/src/standard-schema.d.ts +55 -0
  187. package/types/src/testing/caller.d.ts +95 -0
  188. package/types/src/testing/challenge.d.ts +11 -0
  189. package/types/src/testing/dev.d.ts +97 -0
  190. package/types/src/testing/endpoint-access.d.ts +131 -0
  191. package/types/src/testing/index.d.ts +12 -0
  192. package/types/src/testing/isolation.d.ts +3 -0
  193. package/types/src/testing/kit.d.ts +187 -0
  194. package/types/src/testing/membership.d.ts +132 -0
  195. package/types/src/testing/policy-isolation.d.ts +113 -0
  196. package/types/src/testing/preload.d.ts +1 -0
  197. package/types/src/testing/proxy.d.ts +40 -0
  198. package/types/src/testing/runner.d.ts +8 -0
  199. package/types/src/testing/sample-file.d.ts +5 -0
  200. package/types/src/testing/status.d.ts +4 -0
  201. package/types/src/undumpable.d.ts +5 -0
@@ -34,6 +34,9 @@ export const CAPABILITY_REFUSALS = {
34
34
  undeclared_binding: 'the site named a webhook it does not declare in binds',
35
35
  undeclared_signs: 'the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows',
36
36
  undeclared_egress: 'the site called an origin it does not declare in egress',
37
+ refused_egress:
38
+ "the site's public egress refused the host: an IP address, a local or internal name, a non-default port, a host of this installation, or a name that resolves to a private address",
39
+ oversized_egress: "a response to the site's public egress was larger than it reads",
37
40
  wrong_binding: 'a webhook binding was handed a capability of another kind or dimension than the webhook is bound to',
38
41
  missing_id: 'the job named an object without an id',
39
42
  wrong_capability: 'an operation was handed a capability of another dimension or action than it needs',
@@ -269,14 +272,18 @@ export class Within<D extends string, A extends string> implements WithinCapabil
269
272
  principal: string,
270
273
  values: readonly string[] | 'all',
271
274
  containers: readonly ContainerGrant[],
275
+ contained: readonly ContainerGrant[],
272
276
  ) {
273
277
  if (token !== MINT) {
274
278
  throw new CapabilityRefusedError('not_minted', 'a Within is minted by the framework only; declare the access form in fabrika.contract.ts')
275
279
  }
276
280
  const held = values === 'all' ? freeze([]) : containerGrantsOf(containers)
281
+ const inside = values === 'all' ? freeze([]) : containerGrantsOf(contained)
277
282
  let count = values === 'all' ? 0 : values.length
278
- for (let index = 0; index < held.length; index++) {
279
- count += held[index]?.values.length ?? 0
283
+ for (const group of [held, inside]) {
284
+ for (let index = 0; index < group.length; index++) {
285
+ count += group[index]?.values.length ?? 0
286
+ }
280
287
  }
281
288
  if (count > MAX_WITHIN_VALUES) {
282
289
  throw new CapabilityLimitError(
@@ -292,6 +299,7 @@ export class Within<D extends string, A extends string> implements WithinCapabil
292
299
  principal,
293
300
  values: values === 'all' ? 'all' : copyOf(values),
294
301
  containers: held,
302
+ contained: inside,
295
303
  })
296
304
  this.#frame = frame
297
305
  this.#issuer = issuer
@@ -315,6 +323,11 @@ export class Within<D extends string, A extends string> implements WithinCapabil
315
323
  return this.#grant.containers
316
324
  }
317
325
 
326
+ /** The dimensions inside this one in which the caller holds the action: `note → [n1]` for a workspace list. */
327
+ get contained(): readonly ContainerGrant[] {
328
+ return this.#grant.contained
329
+ }
330
+
318
331
  static {
319
332
  readWithin = (value) =>
320
333
  typeof value === 'object' && value !== null && #grant in value ? { grant: value.#grant, frame: value.#frame, issuer: value.#issuer } : undefined
@@ -434,6 +447,7 @@ export interface CapabilityMint {
434
447
  principal: string,
435
448
  values: readonly string[] | 'all',
436
449
  containers?: readonly ContainerGrant[],
450
+ contained?: readonly ContainerGrant[],
437
451
  ): Within<D, A>
438
452
  app<A extends string>(frame: RequestFrame, action: A, principal: string): AppCapability<A>
439
453
  }
@@ -469,8 +483,9 @@ export function createCapabilityIssuer(): CapabilityIssuer {
469
483
  principal: string,
470
484
  values: readonly string[] | 'all',
471
485
  containers: readonly ContainerGrant[] = [],
486
+ contained: readonly ContainerGrant[] = [],
472
487
  ): Within<D, A> {
473
- return new Within(MINT, issuer, frame, dimension, action, principal, values, containers)
488
+ return new Within(MINT, issuer, frame, dimension, action, principal, values, containers, contained)
474
489
  },
475
490
  app<A extends string>(frame: RequestFrame, action: A, principal: string): AppCapability<A> {
476
491
  return new AppCapability(MINT, issuer, frame, action, principal)
@@ -16,6 +16,7 @@ import {
16
16
  type EndpointRow,
17
17
  type ExportDescription,
18
18
  FORM_URLENCODED_TYPE,
19
+ type IamTerminalRpc,
19
20
  isAttachmentShape,
20
21
  isFileShape,
21
22
  isRedirectShape,
@@ -67,9 +68,9 @@ import {
67
68
  import type { RequestExecutionContext } from '../fetch'
68
69
  import { FileWork, type StoredFileKinds, storedFilesHandle } from '../files/kinds'
69
70
  import { FileDownload, fileResponse, withFileTreatment } from '../files/treatment'
70
- import { abortedBody, bodyDeadlineMs, bootstrapPath, bootstrapResponse, readBoundedBody, withBootstrap } from './anonymous'
71
+ import { abortedBody, bodyDeadlineMs, bootstrapPath, bootstrapResponse, readBodyInTime, withBootstrap } from './anonymous'
71
72
  import { attachmentResponse } from './attachment'
72
- import { boundedStream, handlerSignal, readBoundedBytes } from './bounded'
73
+ import { boundedStream, type Deadline, deadline, handlerSignal, readBoundedBytes } from './bounded'
73
74
  import type { BrowserConfig } from './browser-config'
74
75
  import {
75
76
  type AppCapability,
@@ -83,7 +84,7 @@ import {
83
84
  } from './capability'
84
85
  import type { ChallengeIssuer, ChallengeVerifier } from './challenge'
85
86
  import { connectContext, type ImportWire } from './connect'
86
- import { runAsSite } from './egress'
87
+ import { runAsSite, type SiteEgress, siteEgress } from './egress'
87
88
  import type { FailureReports } from './failures'
88
89
  import { ServerFault } from './faults'
89
90
  import { type HeaderPolicy, withHeaderPolicy } from './headers'
@@ -104,6 +105,7 @@ import { paramsObject, urlParams } from './params'
104
105
  import { pageCallerOf, presentedBy, type Principal } from './principal'
105
106
  import { project, ProjectionRefusedError } from './project'
106
107
  import { bucketsOf, DEFAULT_ADMISSION, type InFlightShare, type ProcedureAdmission, requestClient } from './public'
108
+ import { type TerminalIdentification, terminalIdentification } from './terminals'
107
109
  import type { SignedTokens } from './tokens'
108
110
  import type { AppUrl } from './urls'
109
111
  import { githubAccounts, verifyDelivery } from './verifiers'
@@ -165,7 +167,7 @@ export type ServedEndpoint =
165
167
  /** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
166
168
  readonly site?: ServedSite
167
169
  /** The origins a staged-kit site's `fetch` reaches while its handler runs; absent for a handler `implement` bound. */
168
- readonly egress?: readonly string[]
170
+ readonly egress?: SiteEgress
169
171
  /** Its buckets and time limit; without them (a generated endpoint) no bucket and the request budget. */
170
172
  readonly admission?: ProcedureAdmission
171
173
  /** A POST's `query`, parsed from the URL beside the body: its schema and its shape. */
@@ -241,6 +243,8 @@ export interface RuntimeParts {
241
243
  readonly assets: SpaAssets | null
242
244
  /** IAM's membership operations; `boot` refuses a contract that needs them when the adapter supplies none. */
243
245
  readonly membership: IamMembership | null
246
+ /** IAM's identification operations, where the contract declares a terminal; `boot` refuses one without them. */
247
+ readonly terminals: IamTerminalRpc | null
244
248
  /** The framework's own writes (admission buckets, webhook tables); `boot` refuses a webhook or a bucket without them. */
245
249
  readonly framework: FrameworkTables | null
246
250
  /** This runtime's capability mint, which `boot` alone holds, and the authority its stores are bound to. */
@@ -518,6 +522,22 @@ export class ContractRuntime {
518
522
  const admission = served.admission ?? DEFAULT_ADMISSION
519
523
  await this.#admit(served.row, admission, request, caller.subject)
520
524
  const body = await procedureBody(served, request, this.#parts.fileKinds)
525
+ try {
526
+ return await this.#procedureInvocation(served, request, exec, caller, admission, body)
527
+ } finally {
528
+ // Whatever refuses the request once the body is open, a multipart body's stream and deadline end here.
529
+ await body.multipart?.close()
530
+ }
531
+ }
532
+
533
+ async #procedureInvocation(
534
+ served: Extract<ServedEndpoint, { kind: 'procedure' }>,
535
+ request: Request,
536
+ exec: RequestExecutionContext | undefined,
537
+ caller: Caller,
538
+ admission: ProcedureAdmission,
539
+ body: Awaited<ReturnType<typeof procedureBody>>,
540
+ ): Promise<Response | FileDownload> {
521
541
  // Signed tokens are verified as the input is parsed; the texts they carry are what the request may redirect to.
522
542
  const carried = new Set<string>()
523
543
  const fields = this.#parts.tokens.verifyFields(served.row.input, body.multipart === null ? body.input : body.multipart.json, carried)
@@ -528,58 +548,64 @@ export class ContractRuntime {
528
548
  const invocation = site === undefined ? null : new Invocation(frame, this.#data(), site.kind === 'query' || served.readOnly === true)
529
549
  const kicks = kickRecorder(`'${served.row.id}'`, site?.declaration.kicks ?? [])
530
550
  let outcome: 'open' | 'committing' = 'open'
551
+ let budget: Deadline | null = null
531
552
  try {
532
553
  const input = body.multipart === null
533
554
  ? fields
534
555
  : this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
535
556
  // 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)
537
- let result: unknown
538
- try {
539
- result = await runInRequest(frame, async () => {
540
- const membership = this.#membershipRequest(served.row, caller, request, frame)
541
- const access = await this.#access(served.row, caller, input, frame, membership)
542
- const binding = this.#parts.fileKinds?.binding(work)
543
- const ctx = Object.freeze({
544
- principal: caller.principal,
545
- requestId: frame.id,
546
- signal,
547
- client: requestClient(request, this.#parts.clientVouched),
548
- url: this.#parts.url,
549
- ...access.context,
550
- ...this.#facilities(served.facilities, served.row, membership, access.context['target'], {
551
- credential: caller.token,
552
- requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id,
553
- }),
554
- ...(served.connect === undefined ? {} : { connect: connectContext(served.connect, frame) }),
555
- ...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context, binding)),
556
- // An export's consumer reaches its rows only, never the files of their owners.
557
- ...(site === undefined || invocation === null || served.readOnly === true
558
- ? {}
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,
557
+ budget = handlerSignal(request, admission.timeoutMs)
558
+ const { signal } = budget
559
+ const answer = await enclosedAnswer(invocation, this.#hold(served.row.id, exec), async () => {
560
+ let result: unknown
561
+ try {
562
+ result = await runInRequest(frame, async () => {
563
+ const membership = this.#membershipRequest(served.row, caller, request, frame)
564
+ const access = await this.#access(served.row, caller, input, frame, membership)
565
+ const binding = this.#parts.fileKinds?.binding(work)
566
+ const ctx = Object.freeze({
567
+ principal: caller.principal,
568
+ requestId: frame.id,
569
+ signal,
570
+ client: requestClient(request, this.#parts.clientVouched),
571
+ url: this.#parts.url,
572
+ ...access.context,
573
+ ...this.#facilities(served.facilities, served.row, membership, access.context['target'], {
574
+ credential: caller.token,
575
+ requestId: request.headers.get(REQUEST_ID_HEADER) ?? frame.id,
576
+ }),
577
+ ...this.#terminals(caller.token, request.headers.get(REQUEST_ID_HEADER) ?? frame.id),
578
+ ...(served.connect === undefined ? {} : { connect: connectContext(served.connect, frame) }),
579
+ ...(site === undefined || invocation === null ? {} : siteContext(served.row, site, invocation, caller.subject, access.context, binding)),
580
+ // An export's consumer reaches its rows only, never the files of their owners.
581
+ ...(site === undefined || invocation === null || served.readOnly === true
582
+ ? {}
583
+ : { files: this.#files(served.row.id, invocation, site.kind, work) }),
584
+ ...(served.binds === undefined || invocation === null
585
+ ? {}
586
+ : { webhooks: this.#webhookBindings(served.row.id, served.binds, invocation, caller.subject) }),
587
+ ...this.#signing(`'${served.row.id}'`, site?.declaration.signs ?? []),
588
+ kick: kicks.kick,
589
+ })
590
+ const args = { input: access.input, ctx, ...(query === undefined ? {} : { query }) }
591
+ const egress = served.egress
592
+ return egress === undefined
593
+ ? await served.handler(args)
594
+ : await runAsSite(`'${served.row.id}'`, egress, async () => await served.handler(args))
565
595
  })
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))
571
- })
572
- } finally {
573
- frame.end()
574
- }
575
- const answer = await this.#answerOf(served, result, request, work, carried)
576
- outcome = 'committing'
577
- await commitAnswer(invocation, signal, answer)
596
+ } finally {
597
+ frame.end()
598
+ }
599
+ const answer = await this.#answerOf(served, result, request, work, carried)
600
+ outcome = 'committing'
601
+ await commitAnswer(invocation, signal, answer)
602
+ return answer
603
+ })
578
604
  this.#kickAll(kicks.kicked(), exec)
579
605
  return answer
580
606
  } finally {
607
+ budget?.clear()
581
608
  await invocation?.rollback()
582
- await body.multipart?.close()
583
609
  // Only a known rollback deletes what the request staged; after a commit that may have happened, the sweep decides.
584
610
  if (outcome === 'open') {
585
611
  await this.#parts.fileKinds?.release(work)
@@ -671,6 +697,22 @@ export class ContractRuntime {
671
697
  return data
672
698
  }
673
699
 
700
+ /** Where an invocation of `site` reports a long hold of its transaction: `FailureReports`, its delivery kept alive like a failure's. */
701
+ #hold(site: string, exec: RequestExecutionContext | undefined): Hold {
702
+ return {
703
+ site,
704
+ held: (heldMs) => {
705
+ // The transaction already settled: a report that cannot be made, kept or sent never changes the answer.
706
+ try {
707
+ const delivery = this.#parts.failures.held(site, heldMs).catch(() => undefined)
708
+ exec?.waitUntil(delivery)
709
+ } catch {
710
+ // Dropped, like a delivery that fails.
711
+ }
712
+ },
713
+ }
714
+ }
715
+
674
716
  /** The caller's authority at IAM for this request, when the endpoint's access form names a dimension and an action. */
675
717
  #membershipRequest(row: CredentialedEndpoint, caller: Caller, request: Request, frame: RequestFrame): MembershipRequest | null {
676
718
  const iam = this.#parts.membership
@@ -687,6 +729,12 @@ export class ContractRuntime {
687
729
  }
688
730
  }
689
731
 
732
+ /** `ctx.terminals`, with the credential IAM takes the request's application from: the caller's, or the workload's. */
733
+ #terminals(credential: string, requestId: string): { readonly terminals?: TerminalIdentification } {
734
+ const rpc = this.#parts.terminals
735
+ return rpc === null ? {} : { terminals: terminalIdentification(rpc, { app: this.#parts.audience, credential, requestId }) }
736
+ }
737
+
690
738
  /** `ctx.members` and `ctx.links`, for exactly the endpoints the artifact gives them. */
691
739
  #facilities(
692
740
  facilities: Facilities,
@@ -755,17 +803,21 @@ export class ContractRuntime {
755
803
  }
756
804
  }
757
805
 
758
- /** The values of `dimension` in which the caller holds `action`, directly or through a container; all for a global grant. */
806
+ /**
807
+ * The values of `dimension` in which the caller holds `action`, directly or through a container, and its grants on the
808
+ * dimensions inside it, so a list returns every row a single-object read admits (S4); all for a global grant.
809
+ */
759
810
  #within(caller: Caller, action: string, dimension: string, frame: RequestFrame): Within<string, string> {
760
811
  const direct = scopedValues(caller.perms, action, dimension)
761
812
  if (direct === null) {
762
813
  return this.#parts.mint.within(frame, dimension, action, caller.subject, 'all')
763
814
  }
764
815
  const held = this.#heldContainers(caller.perms, action, dimension)
765
- if (direct.length === 0 && held.length === 0) {
816
+ const inside = this.#heldContained(caller.perms, action, dimension)
817
+ if (direct.length === 0 && held.length === 0 && inside.length === 0) {
766
818
  throw new ForbiddenError(`The caller holds '${action}' in no ${dimension}`)
767
819
  }
768
- return this.#parts.mint.within(frame, dimension, action, caller.subject, direct, held)
820
+ return this.#parts.mint.within(frame, dimension, action, caller.subject, direct, held, inside)
769
821
  }
770
822
 
771
823
  /** A scope value the caller acts in: without a grant on it or on a container of it, 403 whether it exists or not. */
@@ -908,7 +960,7 @@ export class ContractRuntime {
908
960
  admission.repeatableToken,
909
961
  )
910
962
  }
911
- return await this.#publicInvocation(served, request, body, fields, query, carried, authority.perms, exec)
963
+ return await this.#publicInvocation(served, request, body, fields, query, carried, authority, exec)
912
964
  } finally {
913
965
  await multipart?.close()
914
966
  release()
@@ -974,9 +1026,10 @@ export class ContractRuntime {
974
1026
  fields: Readonly<Record<string, unknown>>,
975
1027
  query: Readonly<Record<string, unknown>> | undefined,
976
1028
  carried: Set<string>,
977
- perms: readonly PermissionEntry[],
1029
+ authority: { readonly perms: readonly PermissionEntry[]; readonly token: string },
978
1030
  exec: RequestExecutionContext | undefined,
979
1031
  ): Promise<Response | FileDownload> {
1032
+ const { perms } = authority
980
1033
  const kicks = kickRecorder(`'${served.row.id}'`, served.site.declaration.kicks)
981
1034
  const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.site.kind === 'query', endpoint: served.row.id })
982
1035
  const work = new FileWork(frame, frame.id)
@@ -995,55 +1048,62 @@ export class ContractRuntime {
995
1048
  return this.#parts.mint.app(frame, action, subject)
996
1049
  }
997
1050
  let outcome: 'open' | 'committing' = 'open'
1051
+ let budget: Deadline | null = null
998
1052
  try {
999
1053
  // The file parts come after the challenge, so a refused visitor stores nothing.
1000
1054
  const input = body.multipart === null
1001
1055
  ? fields
1002
1056
  : this.#parts.tokens.verifyFields(served.row.input, await this.#fileInput(served, body.multipart, work), carried)
1003
1057
  // 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,
1058
+ budget = handlerSignal(request, served.admission.timeoutMs)
1059
+ const { signal } = budget
1060
+ const answer = await enclosedAnswer(invocation, this.#hold(served.row.id, exec), async () => {
1061
+ let result: unknown
1062
+ try {
1063
+ result = await runInRequest(frame, async () => {
1064
+ const handles = invocation.handles(
1065
+ served.site.kind,
1066
+ served.site.declaration,
1067
+ subject,
1068
+ { carried },
1069
+ this.#parts.fileKinds?.binding(work),
1070
+ )
1071
+ const where = `'${served.row.id}'`
1072
+ const ctx = Object.freeze({
1073
+ principal: Object.freeze({ kind: 'public', endpoint: served.row.id }),
1074
+ requestId: frame.id,
1075
+ signal,
1076
+ client: requestClient(request, this.#parts.clientVouched),
1077
+ url: this.#parts.url,
1078
+ app,
1079
+ db: Object.freeze({ app: (action: string) => handles.app(app(action)) }),
1080
+ ...this.#signing(`'${served.row.id}'`, served.site.declaration.signs),
1081
+ files: storedFilesHandle(this.#parts.fileKinds, work, {
1082
+ where,
1083
+ check: boundTo(frame, () => false, 'ctx.files'),
1084
+ writes: served.site.kind !== 'query',
1085
+ }),
1086
+ ...this.#terminals(authority.token, request.headers.get(REQUEST_ID_HEADER) ?? frame.id),
1087
+ kick: kicks.kick,
1088
+ })
1089
+ const args = { input, ctx, ...(query === undefined ? {} : { query }) }
1090
+ return await runAsSite(`'${served.row.id}'`, siteEgress(served.site.declaration.egress, null), async () => await served.handler(args))
1031
1091
  })
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)
1092
+ } finally {
1093
+ frame.end()
1094
+ }
1095
+ const output = served.row.output
1096
+ const answer = output !== null && isFileShape(output)
1097
+ ? await this.#fileKinds(served.row.id).download(work, served.row.id, output.kind, result)
1098
+ : outputResponse(served.row, served.output, result, request, carried)
1099
+ outcome = 'committing'
1100
+ await commitAnswer(invocation, signal, answer)
1101
+ return answer
1102
+ })
1044
1103
  this.#kickAll(kicks.kicked(), exec)
1045
1104
  return answer
1046
1105
  } finally {
1106
+ budget?.clear()
1047
1107
  await invocation.rollback()
1048
1108
  if (outcome === 'open') {
1049
1109
  await this.#parts.fileKinds?.release(work)
@@ -1066,7 +1126,7 @@ export class ContractRuntime {
1066
1126
  ): Promise<Response> {
1067
1127
  admitContentType(served.row, request)
1068
1128
  const limit = maxBytesOf(served.row)
1069
- const body = await readBoundedBody(request, limit, AbortSignal.timeout(bodyDeadlineMs(limit)))
1129
+ const body = await readBodyInTime(request, limit)
1070
1130
  const tables = this.#framework().webhooks
1071
1131
  const bindingId = params[WEBHOOK_BINDING_PARAMETER] ?? ''
1072
1132
  const binding = BINDING_ID.test(bindingId) ? await tables.binding(served.webhook, bindingId) : null
@@ -1130,60 +1190,63 @@ export class ContractRuntime {
1130
1190
  // The claim stays outside the delivery's transaction, and its completion commits inside it, with the work.
1131
1191
  const invocation = site === undefined ? null : new Invocation(frame, this.#data(), false)
1132
1192
  const kicks = kickRecorder(`webhook '${served.webhook}'`, site?.kicks ?? [])
1133
- const signal = handlerSignal(request, budgetMs)
1193
+ const budget = handlerSignal(request, budgetMs)
1194
+ const { signal } = budget
1134
1195
  let committing = false
1135
1196
  try {
1136
- try {
1137
- await runInRequest(frame, async () => {
1138
- const subject = webhookSubject(binding.id)
1139
- const reach = this.#webhookReach(served, frame, subject, binding.scope.value, chain)
1140
- const principal: WebhookPrincipal = Object.freeze({ kind: 'webhook', bindingId: binding.id })
1141
- const delivery = Object.freeze({ key })
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
1146
- ? {}
1147
- : {
1148
- db: siteData(handles, (action) => handles.app(reach.app(action))),
1149
- sql: site.sql === null ? undeclaredSql(served.row.id, 'mutation') : invocation.sql(site.sql),
1150
- }
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,
1197
+ await enclosed(invocation, this.#hold(served.row.id, exec), async () => {
1198
+ try {
1199
+ await runInRequest(frame, async () => {
1200
+ const subject = webhookSubject(binding.id)
1201
+ const reach = this.#webhookReach(served, frame, subject, binding.scope.value, chain)
1202
+ const principal: WebhookPrincipal = Object.freeze({ kind: 'webhook', bindingId: binding.id })
1203
+ const delivery = Object.freeze({ key })
1204
+ const handles = site === undefined || invocation === null
1205
+ ? null
1206
+ : invocation.handles('mutation', site, subject, 'declared', this.#parts.fileKinds?.binding(work))
1207
+ const data = site === undefined || invocation === null || handles === null
1208
+ ? {}
1209
+ : {
1210
+ db: siteData(handles, (action) => handles.app(reach.app(action))),
1211
+ sql: site.sql === null ? undeclaredSql(served.row.id, 'mutation') : invocation.sql(site.sql),
1212
+ }
1213
+ const client = requestClient(request, this.#parts.clientVouched)
1214
+ const args = {
1215
+ input,
1216
+ ctx: Object.freeze({
1217
+ principal,
1218
+ requestId: frame.id,
1219
+ signal,
1220
+ client,
1221
+ url: this.#parts.url,
1222
+ ...reach.context,
1223
+ delivery,
1224
+ files: storedFilesHandle(this.#parts.fileKinds, work, {
1225
+ where: `webhook '${served.webhook}'`,
1226
+ check: boundTo(frame, () => false, 'ctx.files'),
1227
+ writes: true,
1228
+ }),
1229
+ ...this.#signing(`webhook '${served.webhook}'`, site?.signs ?? []),
1230
+ kick: kicks.kick,
1231
+ ...data,
1166
1232
  }),
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
- }
1177
- })
1178
- } finally {
1179
- frame.end()
1180
- }
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)
1233
+ }
1234
+ if (site === undefined) {
1235
+ await served.handler(args)
1236
+ } else {
1237
+ await runAsSite(`webhook '${served.webhook}'`, siteEgress(site.egress, null), async () => await served.handler(args))
1238
+ }
1239
+ })
1240
+ } finally {
1241
+ frame.end()
1242
+ }
1243
+ const using = invocation === null ? undefined : await invocation.frameworkSite('the delivery claim').session()
1244
+ if (!(await tables.completeDelivery(claim, using))) {
1245
+ throw new ServerFault('webhook_claim_lost', `the claim of a delivery to '${served.webhook}' expired and another copy took it over`)
1246
+ }
1247
+ committing = true
1248
+ await invocation?.commit(signal)
1249
+ })
1187
1250
  this.#kickAll(kicks.kicked(), exec)
1188
1251
  } catch (error) {
1189
1252
  await invocation?.rollback()
@@ -1196,6 +1259,8 @@ export class ContractRuntime {
1196
1259
  console.error(`fabrika: ${served.row.id}: delivery_failed; the provider's redelivery runs it again`)
1197
1260
  await tables.releaseDelivery(claim)
1198
1261
  throw error
1262
+ } finally {
1263
+ budget.clear()
1199
1264
  }
1200
1265
  return jsonResponse({ result: { received: true } }, 200)
1201
1266
  }
@@ -1257,6 +1322,14 @@ export class ContractRuntime {
1257
1322
  return values.length === 0 ? [] : [{ dimension: container, values }]
1258
1323
  })
1259
1324
  }
1325
+
1326
+ /** The caller's grants of `action` on every dimension that lies inside `dimension`. */
1327
+ #heldContained(perms: PermissionEntry[], action: string, dimension: string): ContainerGrant[] {
1328
+ return [...this.#parts.containers].flatMap(([inner, containers]) => {
1329
+ const values = containers.includes(dimension) ? scopedValues(perms, action, inner) ?? [] : []
1330
+ return values.length === 0 ? [] : [{ dimension: inner, values }]
1331
+ })
1332
+ }
1260
1333
  }
1261
1334
 
1262
1335
  /** `ctx.db` (by the access form) and `ctx.sql` of a staged-kit endpoint: the declared tag, or one that refuses. */
@@ -1475,7 +1548,7 @@ async function readInput(
1475
1548
  const limit = served.row.admission?.maxBytes ?? null
1476
1549
  const text = limit === null
1477
1550
  ? await request.text()
1478
- : new TextDecoder().decode(await readBoundedBody(request, limit, AbortSignal.timeout(bodyDeadlineMs(limit))))
1551
+ : new TextDecoder().decode(await readBodyInTime(request, limit))
1479
1552
  if (mediaType(request.headers.get('Content-Type')) === FORM_URLENCODED_TYPE) {
1480
1553
  body = paramsObject(new URLSearchParams(text), served.row.input)
1481
1554
  } else if (text.trim() !== '') {
@@ -1547,16 +1620,17 @@ async function openMultipart(
1547
1620
  if (Number.isFinite(declared) && declared > bodyBytes) {
1548
1621
  throw tooLarge()
1549
1622
  }
1550
- const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(bodyDeadlineMs(maxBytes))])
1623
+ const timer = deadline(bodyDeadlineMs(maxBytes), request.signal)
1551
1624
  const body = boundedStream(request.body ?? new Blob([]).stream(), bodyBytes)
1552
1625
  const parts = multipartParts(body.stream, boundary, { maxHeaderBytes: PROCEDURE_LIMITS.maxPartHeaderBytes })
1553
- const failure = (error: unknown) => (body.exceeded() ? tooLarge() : multipartFailure(error, parts, deadline, request))
1626
+ const failure = (error: unknown) => (body.exceeded() ? tooLarge() : multipartFailure(error, parts, timer.signal, request))
1554
1627
  const stop = () => {
1555
1628
  parts.cancel().catch(() => {})
1556
1629
  }
1557
- deadline.addEventListener('abort', stop, { once: true })
1630
+ timer.signal.addEventListener('abort', stop, { once: true })
1558
1631
  const close = async () => {
1559
- deadline.removeEventListener('abort', stop)
1632
+ timer.signal.removeEventListener('abort', stop)
1633
+ timer.clear()
1560
1634
  await parts.cancel()
1561
1635
  }
1562
1636
  let json: Readonly<Record<string, unknown>>
@@ -1617,6 +1691,36 @@ async function openMultipart(
1617
1691
  }
1618
1692
  }
1619
1693
 
1694
+ /** The site an invocation's long hold is logged under, and where it is reported. */
1695
+ interface Hold {
1696
+ readonly site: string
1697
+ readonly held: (heldMs: number) => void
1698
+ }
1699
+
1700
+ /** Runs `work` in the invocation's transaction (`Invocation.enclose`), or as is without one. */
1701
+ function enclosed<T>(invocation: Invocation | null, hold: Hold, work: () => Promise<T>): Promise<T> {
1702
+ return invocation === null ? work() : invocation.enclose(work, hold.site, hold.held)
1703
+ }
1704
+
1705
+ /**
1706
+ * An answer's work, from the handler to its commit, in the invocation's transaction. Where the transaction encloses
1707
+ * the work (SQLite) its COMMIT runs after `commitAnswer`, so a failed one cancels a file's object stream here.
1708
+ */
1709
+ async function enclosedAnswer<A extends Response | FileDownload>(invocation: Invocation | null, hold: Hold, work: () => Promise<A>): Promise<A> {
1710
+ let answer: A | undefined
1711
+ try {
1712
+ return await enclosed(invocation, hold, async () => {
1713
+ answer = await work()
1714
+ return answer
1715
+ })
1716
+ } catch (error) {
1717
+ if (answer instanceof FileDownload) {
1718
+ await answer.body.cancel()
1719
+ }
1720
+ throw error
1721
+ }
1722
+ }
1723
+
1620
1724
  /**
1621
1725
  * Commits the invocation of an answer that is ready: a file's object stream was opened before the commit, so a failed
1622
1726
  * commit cancels it and nothing of it is sent.