@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
@@ -1,21 +1,34 @@
1
1
  // What the anonymous kinds need beyond the request pipeline: a body read under its limit (the proxy never sees a
2
- // body, so the framework enforces it), the share SPA's fragment bootstrap and GitHub's delivery signature. A form's
3
- // admission is `form-admission.ts`.
2
+ // body, so the framework enforces it) and the share SPA's fragment bootstrap. A form's admission is
3
+ // `form-admission.ts`, a webhook's signature `verifiers.ts`.
4
4
 
5
5
  import { LINK_BOOTSTRAP_FILE, SHARE_KEY_GLOBAL } from '../contract/defaults'
6
6
  import { HttpError } from '../errors'
7
7
  import { readBoundedBytes } from './bounded'
8
8
 
9
+ /** The slowest body a procedure waits for, in bytes per second: its deadline is `maxBytes` at this rate, 30 s at least. */
10
+ export const BODY_RATE_FLOOR = 16 * 1024
11
+ const MIN_BODY_DEADLINE_MS = 30_000
12
+
13
+ /** How long a body of at most `maxBytes` may take to arrive. */
14
+ export function bodyDeadlineMs(maxBytes: number): number {
15
+ return Math.max(MIN_BODY_DEADLINE_MS, Math.ceil((maxBytes / BODY_RATE_FLOOR) * 1000))
16
+ }
17
+
9
18
  /**
10
19
  * The body as bytes, read until `maxBytes`; a longer one stops the read and is a 413. A body whose stream fails before
11
20
  * its end (the client closed the connection) is the sender's 400 `aborted`, never a failure of the application.
12
21
  */
13
- export async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
22
+ export async function readBoundedBody(request: Request, maxBytes: number, deadline?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
14
23
  let body: Uint8Array<ArrayBuffer> | null
15
24
  try {
16
- body = await readBoundedBytes(request.body, { maxBytes, declaredLength: request.headers.get('Content-Length') })
25
+ body = await readBoundedBytes(request.body, {
26
+ maxBytes,
27
+ declaredLength: request.headers.get('Content-Length'),
28
+ ...(deadline === undefined ? {} : { signal: deadline }),
29
+ })
17
30
  } catch {
18
- throw abortedBody()
31
+ throw deadline?.aborted === true ? new HttpError(408, 'body_timeout', 'The body did not arrive within its deadline') : abortedBody()
19
32
  }
20
33
  if (body === null) {
21
34
  throw new HttpError(413, 'too_large', `The body is larger than ${maxBytes} bytes`)
@@ -76,40 +89,3 @@ export async function withBootstrap(response: Response, prefix: string): Promise
76
89
  headers.delete('Content-Length')
77
90
  return new Response(injected, { status: response.status, statusText: response.statusText, headers })
78
91
  }
79
-
80
- const SIGNATURE_PREFIX = 'sha256='
81
- const HEX = /^[0-9a-f]{64}$/
82
-
83
- /** Whether `X-Hub-Signature-256` is GitHub's HMAC-SHA256 of the raw body under `secret`, compared in constant time. */
84
- export async function verifyGithubSignature(header: string | null, secret: string, body: Uint8Array<ArrayBuffer>): Promise<boolean> {
85
- if (header === null || !header.startsWith(SIGNATURE_PREFIX)) {
86
- return false
87
- }
88
- const hex = header.slice(SIGNATURE_PREFIX.length).toLowerCase()
89
- if (!HEX.test(hex)) {
90
- return false
91
- }
92
- const signature = new Uint8Array(32)
93
- for (let index = 0; index < 32; index++) {
94
- signature[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16)
95
- }
96
- const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
97
- return crypto.subtle.verify('HMAC', key, signature, body)
98
- }
99
-
100
- /** The GitHub account a payload names, as a binding records it: `installation:<id>` or `repository:<id>`. */
101
- export function githubAccounts(payload: unknown): readonly string[] {
102
- const accounts: string[] = []
103
- for (const kind of ['installation', 'repository'] as const) {
104
- const holder = ownField(payload, kind)
105
- const id = ownField(holder, 'id')
106
- if (typeof id === 'number' && Number.isSafeInteger(id) && id > 0) {
107
- accounts.push(`${kind}:${id}`)
108
- }
109
- }
110
- return accounts
111
- }
112
-
113
- function ownField(value: unknown, name: string): unknown {
114
- return typeof value === 'object' && value !== null && Object.hasOwn(value, name) ? Reflect.get(value, name) : undefined
115
- }
@@ -16,7 +16,6 @@ import {
16
16
  FABRIKA_CONTRACT_DIGEST,
17
17
  FABRIKA_IAM_APP_ID,
18
18
  FABRIKA_PROXY_SECRET,
19
- type FormMountArtifact,
20
19
  type IamMembershipRpc,
21
20
  isLoopbackHost,
22
21
  type Jwks,
@@ -24,15 +23,7 @@ import {
24
23
  } from '@fabrika/auth-core'
25
24
  import type { Database } from '@fabrika/db'
26
25
  import type { CapabilityAuthority } from '@fabrika/db/capability'
27
- import {
28
- bindScopedDatabase,
29
- type DataAccess,
30
- dataAccessOf,
31
- type FrameworkTables,
32
- frameworkTablesOf,
33
- isScopedDatabase,
34
- type WebhookTables,
35
- } from '@fabrika/db/framework'
26
+ import { bindScopedDatabase, type DataAccess, dataAccessOf, type FrameworkTables, frameworkTablesOf, isScopedDatabase } from '@fabrika/db/framework'
36
27
  import type { DataKind } from '@fabrika/db/tables'
37
28
  import type { EmailProviderRegistry } from '@fabrika/email'
38
29
  import { builtinEmailProviders } from '@fabrika/email/providers'
@@ -41,11 +32,12 @@ import type { FileObjectStore } from '@fabrika/platform'
41
32
  import { compileContract } from '../contract/compile'
42
33
  import { exportDescription } from '../contract/connect'
43
34
  import { isProcedure, type ProcedureDeclaration, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
44
- import { exportBase, FORM_CHALLENGE_FIELD, FORM_FILE_LIMITS, FORM_LIMITS, spaPrefixRowId } from '../contract/defaults'
45
- import type { Schema } from '../contract/define'
35
+ import { exportBase, spaPrefixRowId } from '../contract/defaults'
36
+ import type { AnyContract, Schema } from '../contract/define'
46
37
  import { kitOriginOf } from '../contract/handled'
38
+ import { schemaFromShape } from '../contract/schema-from-shape'
47
39
  import type { RequestExecutionContext } from '../fetch'
48
- import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS, type FileCollections, fileCollections } from '../files/collections'
40
+ import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS, storedFileKinds } from '../files/kinds'
49
41
  import { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE, type FileScanner, SCAN_LIMITS } from '../files/scan'
50
42
  import { type MailStandIn, resolveMailTransports } from '../jobs/mail'
51
43
  import { logReporter, type OperationsReporter, operationsReporter, operationsSettings, type ReportSource } from '../jobs/report'
@@ -53,28 +45,30 @@ import { bindJobs, type ContractJobs, type HandlerErrorSink } from '../jobs/runn
53
45
  import { environmentScrubber } from '../jobs/scrub'
54
46
  import { browserConfigs } from './browser-config'
55
47
  import { createCapabilityIssuer, requestIsReadOnly } from './capability'
56
- import { type ChallengeFetch, type ChallengeVerifier, turnstileVerifier } from './challenge'
48
+ import type { ChallengeFetch } from './challenge'
57
49
  import { type ConnectFetch, wireImports } from './connect'
58
50
  import {
59
51
  ContractRuntime,
60
52
  type Facilities,
61
- type FormAdmission,
62
53
  NO_FACILITIES,
54
+ type ProcedureQuery,
63
55
  proxySecretDigest,
64
56
  type ServedEndpoint,
65
57
  type ServedSite,
66
58
  type SpaAssets,
67
59
  } from './dispatch'
60
+ import { installEgressGuard, platformCall, platformFacility } from './egress'
68
61
  import type { CapturedEnvironment } from './environment'
69
62
  import { FailureReports } from './failures'
70
- import { ServerFault } from './faults'
71
- import type { SubmissionFields } from './form-admission'
72
63
  import { applicationPolicy } from './headers'
73
64
  import type { ContractDatabase, ContractImplementation, ImplementationDatabase } from './implement'
74
65
  import { approvedDeclaration, approvedReach, isSiteHandler, type SiteDeclaration } from './invocation'
75
66
  import type { ReadDirectory } from './membership'
76
- import { bindingHandlers, webhookBindInput, webhookBindOutput, webhookUnbindInput, webhookUnbindOutput } from './webhook'
77
- import { type WorkloadCredential, workloadCredentials, type WorkloadTokenSource } from './workload'
67
+ import { type AdmissionOptions, InFlightShare, type ProcedureAdmission, procedureAdmission, publicShareOf } from './public'
68
+ import { readTokenKeys, type RuntimePurpose, SignedTokens } from './tokens'
69
+ import { appUrl, readAppOrigin } from './urls'
70
+ import type { BindableWebhook } from './webhook'
71
+ import { type WorkloadCredential, workloadCredentials, workloadSite, type WorkloadTokenSource } from './workload'
78
72
 
79
73
  /** Where the token verifier gets IAM's public keys: the IAM binding on Cloudflare, the issuer's JWKS elsewhere. */
80
74
  export interface IamKeySource {
@@ -95,10 +89,10 @@ export interface BootOptions {
95
89
  * authenticated hop (ADR-0022 rule 1). A secret that is set is checked either way.
96
90
  */
97
91
  readonly requireProxySecret?: boolean
98
- /** The object storage of the file collections, which the Bun adapter opens. Without it, a contract with files does not boot. */
92
+ /** The object storage of the stored files, which the Bun adapter opens. Without it, a contract with file kinds does not boot. */
99
93
  readonly files?: FileObjectStore
100
94
  /**
101
- * The malware scanner of the scan job of `scan: true` collections, which the Bun adapter supplies to a job run
95
+ * The malware scanner of the scan job of `scan: true` file kinds, which the Bun adapter supplies to a job run
102
96
  * (`clamscanScanner`). Without it the scan job leaves every quarantined file quarantined and reports.
103
97
  */
104
98
  readonly scanner?: FileScanner
@@ -119,15 +113,13 @@ export interface BootOptions {
119
113
  readonly workloadTokens?: WorkloadTokenSource
120
114
  /** What posts a report to Operations' ingest; `fetch` by default. */
121
115
  readonly operationsFetch?: (request: Request) => Promise<Response>
122
- /** What asks a form's challenge provider to verify a token; `fetch` by default. */
116
+ /** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
123
117
  readonly challengeFetch?: ChallengeFetch
124
118
  /**
125
- * The developer's own machine only (`fabrika app dev`): the token a form's challenge admits more than once, which
126
- * Turnstile's always-passing test widget hands out for every submission. Every other token stays single use.
119
+ * The developer's own machine only (`fabrika app dev`): the token a challenge admits more than once, which
120
+ * Turnstile's always-passing test widget hands out for every call. Every other token stays single use.
127
121
  */
128
122
  readonly repeatableChallengeToken?: string
129
- /** How long a form's multipart body may take to arrive; `FORM_FILE_LIMITS.timeoutMs` unless a framework test shortens it. */
130
- readonly formFileTimeoutMs?: number
131
123
  /**
132
124
  * The developer's own machine only (`fabrika app dev`): what a job's handler threw, which every log of a deployed
133
125
  * application withholds because it may carry a credential.
@@ -145,8 +137,19 @@ export interface BootOptions {
145
137
  * `FABRIKA_EMAIL_*` nor `serve`'s `mail`.
146
138
  */
147
139
  readonly mailStandIn?: MailStandIn
140
+ /** The connections the application's pool holds: a quarter of them, at least one, is the public procedures' share. */
141
+ readonly connections?: number
142
+ /**
143
+ * `'vouched'` where the proxy's `X-Fabrika-Client-Ip` is the visitor's own address, so `ctx.client.ip` reads it. A
144
+ * runtime behind a balancer whose range the platform does not trust (Zerops, until it does) leaves it unset: the ip is
145
+ * then `null`.
146
+ */
147
+ readonly clientAddress?: 'vouched'
148
148
  }
149
149
 
150
+ /** The pool size a runtime adapter that names none is assumed to have: the Bun adapter's default. */
151
+ const DEFAULT_CONNECTIONS = 10
152
+
150
153
  export interface ContractApp {
151
154
  /** `exec` keeps a failure's report to Operations alive past the response (a Worker's `waitUntil`). */
152
155
  fetch(request: Request, exec?: RequestExecutionContext): Promise<Response>
@@ -270,9 +273,6 @@ function unservable(
270
273
  // Every link request re-reads where the object lies; without the table that records it, nothing could.
271
274
  problems.push(`link mount '${name}' shares a '${mount.dimension}', but no table in src/db/tables.ts owns that dimension to locate it`)
272
275
  }
273
- if (mount.kind === 'webhook' && mount.boundTo.kind === 'app') {
274
- problems.push(`webhook '${name}' is bound to the whole application; this version serves a webhook bound to a dimension (boundTo: '<dimension>')`)
275
- }
276
276
  }
277
277
  for (const row of artifact.endpoints) {
278
278
  if (row.requirement?.kind !== 'action') {
@@ -422,6 +422,11 @@ function boundHandler(
422
422
  return null
423
423
  }
424
424
 
425
+ /** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
426
+ function egressOf(bound: BoundHandler): { readonly egress?: readonly string[] } {
427
+ return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
428
+ }
429
+
425
430
  /** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
426
431
  function siteFacilities(facilities: Facilities, declaration: SiteDeclaration | null): Facilities {
427
432
  if (declaration === null) {
@@ -504,24 +509,17 @@ function asksIam(served: ServedEndpoint): boolean {
504
509
  return creates || served.facilities.members === 'manage' || served.facilities.links.length > 0
505
510
  }
506
511
 
507
- /** A form and a webhook write the framework's own tables, which only `@fabrika/db`'s `scoped(tables)` reaches. */
512
+ /** A webhook and a procedure's buckets write the framework's own tables, which only `@fabrika/db`'s `scoped(tables)` reaches. */
508
513
  function frameworkProblems(artifact: ContractArtifact, framework: FrameworkTables | null, problems: string[]): void {
514
+ const counted = Object.entries(artifact.procedures ?? {}).find(([, rules]) => Object.values(rules.rate).some((bucket) => bucket !== null))
515
+ if (counted !== undefined && framework === null) {
516
+ problems.push(
517
+ `procedure '${counted[0]}' counts its calls in buckets, which live in @fabrika/db: pass serve(contract, { database: scoped(tables) })`,
518
+ )
519
+ }
509
520
  for (const [name, mount] of Object.entries(artifact.mounts)) {
510
- if (mount.kind !== 'form' && mount.kind !== 'webhook') {
511
- continue
512
- }
513
- if (framework === null) {
514
- problems.push(`mount '${name}' is a ${mount.kind}, which writes through @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
515
- continue
516
- }
517
- if (mount.kind === 'form') {
518
- const input = artifact.endpoints.find((row) => row.id === name)?.input
519
- const submitted = submissionFieldsOf(mount)
520
- const fields = (input?.type === 'object' ? Object.keys(input.fields) : []).filter((field) =>
521
- field !== submitted.honeypot && !(submitted.challenge && field === FORM_CHALLENGE_FIELD)
522
- )
523
- // A file field's column receives the id of the file the submission carried.
524
- problems.push(...framework.inboxProblems(name, [...fields, ...Object.keys(mount.files ?? {})]))
521
+ if (mount.kind === 'webhook' && framework === null) {
522
+ problems.push(`mount '${name}' is a webhook, which writes through @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
525
523
  }
526
524
  }
527
525
  }
@@ -530,108 +528,111 @@ function isCredentialed(row: EndpointRow | undefined): row is CredentialedEndpoi
530
528
  return row !== undefined && row.requirement !== null
531
529
  }
532
530
 
533
- function submissionFieldsOf(mount: FormMountArtifact): SubmissionFields {
534
- return { honeypot: mount.honeypot ?? null, challenge: mount.challenge !== undefined }
531
+ /** A POST procedure's `http.query`, as the field its served endpoint carries: parsed by the approved shape. */
532
+ function queryOf(id: string, artifact: ContractArtifact): { readonly query?: ProcedureQuery } {
533
+ const shape = artifact.procedures !== undefined && Object.hasOwn(artifact.procedures, id) ? artifact.procedures[id]?.query : undefined
534
+ return shape === undefined ? {} : { query: { schema: schemaFromShape(shape), shape } }
535
+ }
536
+
537
+ /** A procedure's admission, as the field its served endpoint carries; none after a problem. */
538
+ function admissionOf(id: string, artifact: ContractArtifact, options: BootOptions, problems: string[]): { readonly admission?: ProcedureAdmission } {
539
+ const admission = procedureAdmission(id, artifact, admissionOptions(options), problems)
540
+ return admission === null ? {} : { admission }
541
+ }
542
+
543
+ function admissionOptions(options: BootOptions): AdmissionOptions {
544
+ return {
545
+ application: options.environment.application,
546
+ ...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
547
+ ...(options.repeatableChallengeToken === undefined ? {} : { repeatableChallengeToken: options.repeatableChallengeToken }),
548
+ }
535
549
  }
536
550
 
537
551
  /**
538
- * A form's admission from its approved mount: the declared rate or the defaults, and a challenge verified with the
539
- * secret the application variable it names holds. Without that variable the application does not boot.
552
+ * A public procedure: its staged-kit handler, its key, and its admission. Without the key's credential (no token
553
+ * exchange, or the variable unset) `workloadCredentials` already reported why.
540
554
  */
541
- function formAdmission(
542
- name: string,
555
+ function publicEndpoint(
556
+ row: EndpointRow,
557
+ procedure: ProcedureDeclaration<Schema, unknown>,
558
+ bound: BoundHandler | null,
543
559
  artifact: ContractArtifact,
560
+ workloads: ReadonlyMap<string, WorkloadCredential>,
544
561
  options: BootOptions,
545
- files: FileCollections | null,
546
562
  problems: string[],
547
- ): FormAdmission | null {
548
- const mount = artifact.mounts[name]
549
- if (mount?.kind !== 'form') {
563
+ ): ServedEndpoint | null {
564
+ const where = `public procedure '${row.id}'`
565
+ const site = bound === null ? undefined : servedSite(procedure.type, bound)
566
+ if (bound === null || site === undefined) {
567
+ problems.push(`${where} has no handler of the staged kit; declare it with .handle(…) beside its declaration`)
550
568
  return null
551
569
  }
552
- const fileFields = new Map(Object.entries(mount.files ?? {}))
553
- for (const [field, file] of fileFields) {
554
- if (files === null || !files.has(file.collection)) {
555
- problems.push(`form '${name}' takes its file '${field}' into files '${file.collection}', which this runtime does not serve`)
556
- }
557
- }
558
- if (fileFields.size > 0 && mount.challenge === undefined) {
559
- problems.push(`form '${name}' takes files and declares no challenge; an anonymous upload needs a solved one`)
570
+ const actions = artifact.declared?.[row.id]?.actions ?? []
571
+ const credential = workloads.get(workloadSite('public', row.id))
572
+ const admission = procedureAdmission(row.id, artifact, admissionOptions(options), problems)
573
+ if (credential === undefined || admission === null) {
574
+ return null
560
575
  }
561
- let challenge: ChallengeVerifier | null = null
562
- if (mount.challenge !== undefined) {
563
- const secret: unknown = options.environment.application[mount.challenge.secret]
564
- if (typeof secret !== 'string' || secret.trim() === '') {
565
- problems.push(
566
- `form '${name}' verifies its ${mount.challenge.provider} challenge with the application variable ${mount.challenge.secret}, which is not set`,
567
- )
568
- return null
569
- }
570
- challenge = turnstileVerifier(secret.trim(), options.challengeFetch ?? ((request) => fetch(request)))
576
+ return {
577
+ kind: 'public',
578
+ row,
579
+ input: procedure.input,
580
+ output: procedure.output,
581
+ handler: bound.handler,
582
+ actions,
583
+ credential,
584
+ admission,
585
+ site,
586
+ ...queryOf(row.id, artifact),
571
587
  }
572
- return Object.freeze({
573
- perClient: mount.rate?.perClient ?? FORM_LIMITS.perClient,
574
- // A form with files accepts fewer submissions by default: each may write its collections' maxBytes.
575
- perForm: mount.rate?.perForm ?? (fileFields.size === 0 ? FORM_LIMITS.perForm : FORM_FILE_LIMITS.perForm),
576
- fields: Object.freeze(submissionFieldsOf(mount)),
577
- files: fileFields.size === 0 || files === null ? null : {
578
- fields: fileFields,
579
- collections: files,
580
- bytesPerHour: mount.rate?.bytesPerHour ?? FORM_FILE_LIMITS.bytesPerHour,
581
- timeoutMs: options.formFileTimeoutMs ?? FORM_FILE_LIMITS.timeoutMs,
582
- },
583
- challenge,
584
- repeatableToken: challenge === null ? null : (options.repeatableChallengeToken ?? null),
585
- })
586
588
  }
587
589
 
588
- /** `frameworkProblems` refused a webhook without the framework tables, so this answers only after a bug. */
589
- function frameworkOf(framework: FrameworkTables | null): FrameworkTables {
590
- if (framework === null) {
591
- throw new ServerFault('facility_unbound', 'a webhook was served without the framework tables')
592
- }
593
- return framework
594
- }
595
-
596
- /** The generated `webhooks.<name>.bind` and `.unbind` endpoints, served by the framework's own handlers. */
597
- function bindWebhookBindings(
598
- name: string,
599
- path: string,
600
- rows: ReadonlyMap<string, EndpointRow>,
601
- endpoints: Map<string, ServedEndpoint>,
602
- tables: () => WebhookTables,
603
- ): void {
604
- const handlers = bindingHandlers(name, path, tables)
605
- const bind = rows.get(`webhooks.${name}.bind`)
606
- const unbind = rows.get(`webhooks.${name}.unbind`)
607
- if (isCredentialed(bind)) {
608
- endpoints.set(bind.id, {
609
- kind: 'procedure',
610
- row: bind,
611
- input: webhookBindInput,
612
- output: webhookBindOutput,
613
- handler: handlers.bind,
614
- facilities: NO_FACILITIES,
615
- })
616
- }
617
- if (isCredentialed(unbind)) {
618
- endpoints.set(unbind.id, {
619
- kind: 'procedure',
620
- row: unbind,
621
- input: webhookUnbindInput,
622
- output: webhookUnbindOutput,
623
- handler: handlers.unbind,
624
- facilities: NO_FACILITIES,
625
- })
590
+ /** The webhook a procedure binds (`binds`), as the approved artifact declares it. */
591
+ function bindsOf(
592
+ id: string,
593
+ declaration: SiteDeclaration | null,
594
+ artifact: ContractArtifact,
595
+ problems: string[],
596
+ ): { readonly binds?: BindableWebhook } {
597
+ const name = declaration?.binds ?? null
598
+ if (name === null) {
599
+ return {}
600
+ }
601
+ const mount = Object.hasOwn(artifact.mounts, name) ? artifact.mounts[name] : undefined
602
+ if (mount?.kind !== 'webhook') {
603
+ problems.push(`procedure '${id}' binds '${name}', which is not a webhook of the contract`)
604
+ return {}
605
+ }
606
+ return { binds: { name, path: mount.path, scheme: mount.scheme, boundTo: mount.boundTo } }
607
+ }
608
+
609
+ /** Each approved token purpose with the claims schema the application declared for it. */
610
+ function runtimePurposes(contract: AnyContract, artifact: ContractArtifact): ReadonlyMap<string, RuntimePurpose> {
611
+ const declared = contract.surface.tokens ?? {}
612
+ const purposes = new Map<string, RuntimePurpose>()
613
+ for (const [purpose, approved] of Object.entries(artifact.tokens ?? {})) {
614
+ const own = Object.hasOwn(declared, purpose) ? declared[purpose] : undefined
615
+ if (own !== undefined) {
616
+ purposes.set(purpose, { claims: own.claims, maxDays: approved.maxDays })
617
+ }
626
618
  }
619
+ return purposes
627
620
  }
628
621
 
629
622
  export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
630
623
  const problems: string[] = []
624
+ installEgressGuard()
625
+ // What a handler reaches through these is the platform's own request, which no site's egress covers.
626
+ const objects = options.files === undefined ? undefined : platformFacility(options.files)
627
+ const membershipRpc = options.membership === undefined ? undefined : platformFacility(options.membership)
628
+ const workloadTokens = options.workloadTokens === undefined ? undefined : platformFacility(options.workloadTokens)
631
629
  const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
632
630
  const artifact = compileContract(app.contract, app.database?.tables ?? {})
633
631
  checkContractDigest(options.environment.framework, artifact, problems)
634
632
  const proxySecret = readProxySecret(options.environment.framework, options.requireProxySecret === true, problems)
633
+ const tokens = new SignedTokens(runtimePurposes(app.contract, artifact), readTokenKeys(options.environment.framework, artifact, problems))
634
+ const origin = readAppOrigin(options.environment.framework, problems)
635
+ const url = appUrl(artifact, origin)
635
636
  const operations = operationsSettings(options.environment.framework, problems)
636
637
  const scrubber = environmentScrubber(options.environment)
637
638
  const reporterFor = (source: ReportSource): OperationsReporter | null =>
@@ -639,33 +640,33 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
639
640
  ? null
640
641
  : operationsReporter({ ...operations, source, scrubber, ...(options.operationsFetch === undefined ? {} : { fetch: options.operationsFetch }) })
641
642
  const owned = new Set(Object.values(artifact.tables).flatMap((table) => table.scope.mode === 'dimension' ? [table.scope.dimension] : []))
642
- // A file collection's dimension is located through its own table and its owner's.
643
- for (const name of Object.keys(artifact.files)) {
644
- owned.add(`file:${name}`)
645
- }
646
643
  const containers = containersOf(artifact)
647
644
  unservable(artifact, options.assets, owned, containers, problems)
648
645
  // This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
649
646
  const issuer = createCapabilityIssuer()
650
- const files = fileCollections(artifact, app.database, options.files, issuer.authority, issuer.mint, problems)
647
+ const fileKinds = storedFileKinds(artifact, app.database, objects, problems)
651
648
  const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
652
649
  const framework = bound?.framework ?? null
653
650
  frameworkProblems(artifact, framework, problems)
654
- const database = files === null || bound === null ? (bound?.database ?? null) : files.locating(bound.database)
655
- const verifier = new TokenVerifier({ getJwks: () => options.iam.getJwks(), issuer: variables.issuer })
651
+ const database = bound?.database ?? null
652
+ const verifier = new TokenVerifier({ getJwks: () => platformCall(() => options.iam.getJwks()), issuer: variables.issuer })
656
653
  const workloads = workloadCredentials({
657
654
  artifact,
658
655
  framework: options.environment.framework,
659
656
  audience: variables.iamAppId,
660
- source: options.workloadTokens,
657
+ source: workloadTokens,
661
658
  verifier,
662
659
  }, problems)
663
660
  const connect = wireImports({
664
661
  artifact,
665
662
  snapshots: app.connect ?? {},
666
663
  framework: options.environment.framework,
667
- bindings: options.connectBindings ?? {},
668
- fallback: (request) => fetch(request),
664
+ bindings: Object.fromEntries(
665
+ Object.entries(options.connectBindings ?? {}).map((
666
+ [name, binding],
667
+ ): [string, ConnectFetch] => [name, (request) => platformCall(() => binding(request))]),
668
+ ),
669
+ fallback: (request) => platformCall(() => fetch(request)),
669
670
  }, problems)
670
671
  const configs = browserConfigs(artifact, options.environment, {
671
672
  dsn: operations === null ? null : (options.environment.framework['FABRIKA_OPERATIONS_DSN'] ?? '').trim(),
@@ -724,6 +725,13 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
724
725
  declared.add(id)
725
726
  const row = rows.get(id)
726
727
  const bound = boundHandler(path.reduce<unknown>(childAt, handlers), id, `procedure '${id}'`, procedure.type, artifact, problems)
728
+ if (row?.kind === 'public') {
729
+ const served = publicEndpoint(row, procedure, bound, artifact, workloads, options, problems)
730
+ if (served !== null) {
731
+ endpoints.set(id, served)
732
+ }
733
+ continue
734
+ }
727
735
  if (!isCredentialed(row)) {
728
736
  problems.push(`procedure '${id}' has no endpoint row`)
729
737
  } else if (bound === null) {
@@ -738,45 +746,39 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
738
746
  facilities: siteFacilities(facilitiesOf(artifact, row), bound.declaration),
739
747
  connect: siteConnect(connect.get(id), bound.declaration),
740
748
  site: servedSite(procedure.type, bound),
741
- ...(bound.declaration?.then == null ? {} : { then: bound.declaration.then }),
749
+ ...egressOf(bound),
750
+ ...bindsOf(id, bound.declaration, artifact, problems),
751
+ ...admissionOf(id, artifact, options, problems),
752
+ ...queryOf(id, artifact),
742
753
  })
743
754
  }
744
755
  }
745
- } else if (mount.kind === 'form') {
746
- const row = rows.get(name)
747
- const admission = formAdmission(name, artifact, options, files, problems)
748
- const approved = artifact.mounts[name]
749
- const then = approved?.kind === 'form' ? (approved.then ?? null) : null
750
- if (row !== undefined && admission !== null) {
751
- endpoints.set(name, { kind: 'form', row, form: name, input: mount.input, admission, then })
752
- }
753
756
  } else if (mount.kind === 'webhook') {
754
757
  declared.add(name)
755
758
  const row = rows.get(name)
756
759
  const bound = boundHandler(childAt(handlers, name), name, `webhook '${name}'`, 'mutation', artifact, problems)
757
- const credential: WorkloadCredential | undefined = workloads.get(`webhook:${name}`)
760
+ const credential: WorkloadCredential | undefined = workloads.get(workloadSite('webhook', name))
761
+ const approved = artifact.mounts[name]
758
762
  if (row === undefined || bound === null) {
759
763
  problems.push(`webhook '${name}' has no handler; add handlers.${name}: ({ input, ctx }) => …`)
760
- } else if (credential !== undefined) {
764
+ } else if (credential !== undefined && approved?.kind === 'webhook') {
761
765
  endpoints.set(name, {
762
766
  kind: 'webhook',
763
767
  row,
764
768
  webhook: name,
765
- dimension: mount.boundTo,
769
+ scheme: approved.scheme,
770
+ boundTo: approved.boundTo,
771
+ timeoutMs: approved.timeoutMs,
766
772
  actions: mount.actions,
767
773
  input: mount.input,
768
774
  handler: bound.handler,
769
775
  credential,
770
776
  site: bound.site ? (bound.declaration ?? undefined) : undefined,
771
777
  })
772
- bindWebhookBindings(name, mount.path, rows, endpoints, () => frameworkOf(framework).webhooks)
773
778
  }
774
779
  }
775
780
  }
776
- for (const [id, served] of files?.endpoints(rows, problems) ?? []) {
777
- endpoints.set(id, served)
778
- }
779
- const membership = options.membership === undefined ? null : new IamMembership(options.membership, variables.iamAppId)
781
+ const membership = membershipRpc === undefined ? null : new IamMembership(membershipRpc, variables.iamAppId)
780
782
  const jobs = bindJobs({
781
783
  artifact,
782
784
  handlers,
@@ -787,23 +789,30 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
787
789
  credentials: workloads,
788
790
  mint: issuer.mint,
789
791
  data: bound?.data ?? null,
790
- files,
791
- frameworkJobs: files === null ? [] : [
792
- ...(files.scans
792
+ fileKinds,
793
+ frameworkJobs: fileKinds === null ? [] : [
794
+ ...(fileKinds.scans
793
795
  ? [{
794
796
  name: FILE_SCAN_JOB,
795
797
  schedule: FILE_SCAN_SCHEDULE,
796
798
  timeoutMs: SCAN_LIMITS.jobTimeoutMs,
797
- run: (reporter: OperationsReporter, signal: AbortSignal) => files.scanPass(options.scanner ?? null, reporter, signal),
799
+ run: (reporter: OperationsReporter, signal: AbortSignal) => fileKinds.scanPass(options.scanner ?? null, reporter, signal),
798
800
  }]
799
801
  : []),
800
- { name: FILE_SWEEP_JOB, schedule: FILE_SWEEP_SCHEDULE, timeoutMs: FILE_SWEEP_TIMEOUT_MS, run: (_reporter, signal) => files.sweep(signal) },
802
+ {
803
+ name: FILE_SWEEP_JOB,
804
+ schedule: FILE_SWEEP_SCHEDULE,
805
+ timeoutMs: FILE_SWEEP_TIMEOUT_MS,
806
+ run: (_reporter, signal) => fileKinds.sweep(signal),
807
+ },
801
808
  ],
802
809
  membership,
803
810
  capabilities: issuer.authority,
804
811
  containers,
805
812
  ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
806
813
  mail,
814
+ tokens,
815
+ url,
807
816
  }, problems)
808
817
  for (const id of jobs.declared) {
809
818
  declared.add(id)
@@ -830,6 +839,8 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
830
839
  facilities: NO_FACILITIES,
831
840
  readOnly: true,
832
841
  site: servedSite('query', bound),
842
+ ...egressOf(bound),
843
+ ...admissionOf(id, artifact, options, problems),
833
844
  })
834
845
  }
835
846
  }
@@ -844,7 +855,9 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
844
855
  if (data === null && kitOriginOf(app.contract) === 'tableless') {
845
856
  servedWithoutData(endpoints, problems)
846
857
  }
847
- const site = [...endpoints.values()].find((served) => (served.kind === 'procedure' || served.kind === 'webhook') && served.site !== undefined)
858
+ const site = [...endpoints.values()].find((served) =>
859
+ served.kind === 'public' || ((served.kind === 'procedure' || served.kind === 'webhook') && served.site !== undefined)
860
+ )
848
861
  if (site !== undefined && data === null) {
849
862
  problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
850
863
  }
@@ -875,10 +888,15 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
875
888
  mint: issuer.mint,
876
889
  capabilities: issuer.authority,
877
890
  data,
878
- files,
891
+ fileKinds,
879
892
  failures: new FailureReports(reporterFor('fabrika.requests')),
880
893
  kick: jobs.kick,
881
894
  actions: Object.freeze(Object.keys(artifact.actions).sort()),
895
+ publicShare: new InFlightShare(publicShareOf(options.connections ?? DEFAULT_CONNECTIONS)),
896
+ clientVouched: options.clientAddress === 'vouched',
897
+ tokens,
898
+ url,
899
+ origin,
882
900
  })
883
901
  return Object.freeze({
884
902
  fetch: (request: Request, exec?: RequestExecutionContext) => runtime.fetch(request, exec),