@fabrika/app 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -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 { challengeRowId, 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, ChallengeWork } 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,18 @@ 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
123
+ /**
124
+ * The work of every ALTCHA challenge in place of the contract's `cost`: the test kit's and `fabrika app dev`'s
125
+ * (`MINIMAL_CHALLENGE_WORK`), so a test solves one in milliseconds. A deployed application never sets it.
126
+ */
127
+ readonly challengeWork?: ChallengeWork
131
128
  /**
132
129
  * The developer's own machine only (`fabrika app dev`): what a job's handler threw, which every log of a deployed
133
130
  * application withholds because it may carry a credential.
@@ -145,8 +142,19 @@ export interface BootOptions {
145
142
  * `FABRIKA_EMAIL_*` nor `serve`'s `mail`.
146
143
  */
147
144
  readonly mailStandIn?: MailStandIn
145
+ /** The connections the application's pool holds: a quarter of them, at least one, is the public procedures' share. */
146
+ readonly connections?: number
147
+ /**
148
+ * `'vouched'` where the proxy's `X-Fabrika-Client-Ip` is the visitor's own address, so `ctx.client.ip` reads it. A
149
+ * runtime behind a balancer whose range the platform does not trust (Zerops, until it does) leaves it unset: the ip is
150
+ * then `null`.
151
+ */
152
+ readonly clientAddress?: 'vouched'
148
153
  }
149
154
 
155
+ /** The pool size a runtime adapter that names none is assumed to have: the Bun adapter's default. */
156
+ const DEFAULT_CONNECTIONS = 10
157
+
150
158
  export interface ContractApp {
151
159
  /** `exec` keeps a failure's report to Operations alive past the response (a Worker's `waitUntil`). */
152
160
  fetch(request: Request, exec?: RequestExecutionContext): Promise<Response>
@@ -270,9 +278,6 @@ function unservable(
270
278
  // Every link request re-reads where the object lies; without the table that records it, nothing could.
271
279
  problems.push(`link mount '${name}' shares a '${mount.dimension}', but no table in src/db/tables.ts owns that dimension to locate it`)
272
280
  }
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
281
  }
277
282
  for (const row of artifact.endpoints) {
278
283
  if (row.requirement?.kind !== 'action') {
@@ -422,6 +427,11 @@ function boundHandler(
422
427
  return null
423
428
  }
424
429
 
430
+ /** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
431
+ function egressOf(bound: BoundHandler): { readonly egress?: readonly string[] } {
432
+ return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
433
+ }
434
+
425
435
  /** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
426
436
  function siteFacilities(facilities: Facilities, declaration: SiteDeclaration | null): Facilities {
427
437
  if (declaration === null) {
@@ -504,24 +514,17 @@ function asksIam(served: ServedEndpoint): boolean {
504
514
  return creates || served.facilities.members === 'manage' || served.facilities.links.length > 0
505
515
  }
506
516
 
507
- /** A form and a webhook write the framework's own tables, which only `@fabrika/db`'s `scoped(tables)` reaches. */
517
+ /** A webhook and a procedure's buckets write the framework's own tables, which only `@fabrika/db`'s `scoped(tables)` reaches. */
508
518
  function frameworkProblems(artifact: ContractArtifact, framework: FrameworkTables | null, problems: string[]): void {
519
+ const counted = Object.entries(artifact.procedures ?? {}).find(([, rules]) => Object.values(rules.rate).some((bucket) => bucket !== null))
520
+ if (counted !== undefined && framework === null) {
521
+ problems.push(
522
+ `procedure '${counted[0]}' counts its calls in buckets, which live in @fabrika/db: pass serve(contract, { database: scoped(tables) })`,
523
+ )
524
+ }
509
525
  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 ?? {})]))
526
+ if (mount.kind === 'webhook' && framework === null) {
527
+ problems.push(`mount '${name}' is a webhook, which writes through @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
525
528
  }
526
529
  }
527
530
  }
@@ -530,108 +533,137 @@ function isCredentialed(row: EndpointRow | undefined): row is CredentialedEndpoi
530
533
  return row !== undefined && row.requirement !== null
531
534
  }
532
535
 
533
- function submissionFieldsOf(mount: FormMountArtifact): SubmissionFields {
534
- return { honeypot: mount.honeypot ?? null, challenge: mount.challenge !== undefined }
536
+ /** A POST procedure's `http.query`, as the field its served endpoint carries: parsed by the approved shape. */
537
+ function queryOf(id: string, artifact: ContractArtifact): { readonly query?: ProcedureQuery } {
538
+ const shape = artifact.procedures !== undefined && Object.hasOwn(artifact.procedures, id) ? artifact.procedures[id]?.query : undefined
539
+ return shape === undefined ? {} : { query: { schema: schemaFromShape(shape), shape } }
540
+ }
541
+
542
+ /** A procedure's admission, as the field its served endpoint carries; none after a problem. */
543
+ function admissionOf(id: string, artifact: ContractArtifact, options: BootOptions, problems: string[]): { readonly admission?: ProcedureAdmission } {
544
+ const admission = procedureAdmission(id, artifact, admissionOptions(options), problems)
545
+ return admission === null ? {} : { admission }
546
+ }
547
+
548
+ function admissionOptions(options: BootOptions): AdmissionOptions {
549
+ return {
550
+ application: options.environment.application,
551
+ ...(options.challengeFetch === undefined ? {} : { challengeFetch: options.challengeFetch }),
552
+ ...(options.repeatableChallengeToken === undefined ? {} : { repeatableChallengeToken: options.repeatableChallengeToken }),
553
+ ...(options.challengeWork === undefined ? {} : { challengeWork: options.challengeWork }),
554
+ }
535
555
  }
536
556
 
537
557
  /**
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.
558
+ * The framework's own endpoint that issues the ALTCHA challenge of a served public procedure, with its own buckets;
559
+ * `null` for a procedure without one.
540
560
  */
541
- function formAdmission(
542
- name: string,
561
+ function challengeIssuerEndpoint(
562
+ procedure: Extract<ServedEndpoint, { kind: 'public' }>,
563
+ rows: ReadonlyMap<string, EndpointRow>,
543
564
  artifact: ContractArtifact,
544
565
  options: BootOptions,
545
- files: FileCollections | null,
546
566
  problems: string[],
547
- ): FormAdmission | null {
548
- const mount = artifact.mounts[name]
549
- if (mount?.kind !== 'form') {
567
+ ): ServedEndpoint | null {
568
+ const issuer = procedure.admission.challenge?.issuer ?? null
569
+ if (issuer === null) {
550
570
  return null
551
571
  }
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`)
560
- }
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)))
572
+ const id = challengeRowId(procedure.row.id)
573
+ const row = rows.get(id)
574
+ if (row === undefined || row.source.kind !== 'challenge') {
575
+ problems.push(`public procedure '${procedure.row.id}' has no endpoint that issues its challenge`)
576
+ return null
571
577
  }
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
- })
578
+ const admission = procedureAdmission(id, artifact, admissionOptions(options), problems)
579
+ return admission === null ? null : { kind: 'challenge', row, issuer, admission }
586
580
  }
587
581
 
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')
582
+ /**
583
+ * A public procedure: its staged-kit handler, its key, and its admission. Without the key's credential (no token
584
+ * exchange, or the variable unset) `workloadCredentials` already reported why.
585
+ */
586
+ function publicEndpoint(
587
+ row: EndpointRow,
588
+ procedure: ProcedureDeclaration<Schema, unknown>,
589
+ bound: BoundHandler | null,
590
+ artifact: ContractArtifact,
591
+ workloads: ReadonlyMap<string, WorkloadCredential>,
592
+ options: BootOptions,
593
+ problems: string[],
594
+ ): Extract<ServedEndpoint, { kind: 'public' }> | null {
595
+ const where = `public procedure '${row.id}'`
596
+ const site = bound === null ? undefined : servedSite(procedure.type, bound)
597
+ if (bound === null || site === undefined) {
598
+ problems.push(`${where} has no handler of the staged kit; declare it with .handle(…) beside its declaration`)
599
+ return null
600
+ }
601
+ const actions = artifact.declared?.[row.id]?.actions ?? []
602
+ const credential = workloads.get(workloadSite('public', row.id))
603
+ const admission = procedureAdmission(row.id, artifact, admissionOptions(options), problems)
604
+ if (credential === undefined || admission === null) {
605
+ return null
606
+ }
607
+ return {
608
+ kind: 'public',
609
+ row,
610
+ input: procedure.input,
611
+ output: procedure.output,
612
+ handler: bound.handler,
613
+ actions,
614
+ credential,
615
+ admission,
616
+ site,
617
+ ...queryOf(row.id, artifact),
592
618
  }
593
- return framework
594
619
  }
595
620
 
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
- })
621
+ /** The webhook a procedure binds (`binds`), as the approved artifact declares it. */
622
+ function bindsOf(
623
+ id: string,
624
+ declaration: SiteDeclaration | null,
625
+ artifact: ContractArtifact,
626
+ problems: string[],
627
+ ): { readonly binds?: BindableWebhook } {
628
+ const name = declaration?.binds ?? null
629
+ if (name === null) {
630
+ return {}
631
+ }
632
+ const mount = Object.hasOwn(artifact.mounts, name) ? artifact.mounts[name] : undefined
633
+ if (mount?.kind !== 'webhook') {
634
+ problems.push(`procedure '${id}' binds '${name}', which is not a webhook of the contract`)
635
+ return {}
636
+ }
637
+ return { binds: { name, path: mount.path, scheme: mount.scheme, boundTo: mount.boundTo } }
638
+ }
639
+
640
+ /** Each approved token purpose with the claims schema the application declared for it. */
641
+ function runtimePurposes(contract: AnyContract, artifact: ContractArtifact): ReadonlyMap<string, RuntimePurpose> {
642
+ const declared = contract.surface.tokens ?? {}
643
+ const purposes = new Map<string, RuntimePurpose>()
644
+ for (const [purpose, approved] of Object.entries(artifact.tokens ?? {})) {
645
+ const own = Object.hasOwn(declared, purpose) ? declared[purpose] : undefined
646
+ if (own !== undefined) {
647
+ purposes.set(purpose, { claims: own.claims, maxDays: approved.maxDays })
648
+ }
626
649
  }
650
+ return purposes
627
651
  }
628
652
 
629
653
  export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
630
654
  const problems: string[] = []
655
+ installEgressGuard()
656
+ // What a handler reaches through these is the platform's own request, which no site's egress covers.
657
+ const objects = options.files === undefined ? undefined : platformFacility(options.files)
658
+ const membershipRpc = options.membership === undefined ? undefined : platformFacility(options.membership)
659
+ const workloadTokens = options.workloadTokens === undefined ? undefined : platformFacility(options.workloadTokens)
631
660
  const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
632
661
  const artifact = compileContract(app.contract, app.database?.tables ?? {})
633
662
  checkContractDigest(options.environment.framework, artifact, problems)
634
663
  const proxySecret = readProxySecret(options.environment.framework, options.requireProxySecret === true, problems)
664
+ const tokens = new SignedTokens(runtimePurposes(app.contract, artifact), readTokenKeys(options.environment.framework, artifact, problems))
665
+ const origin = readAppOrigin(options.environment.framework, problems)
666
+ const url = appUrl(artifact, origin)
635
667
  const operations = operationsSettings(options.environment.framework, problems)
636
668
  const scrubber = environmentScrubber(options.environment)
637
669
  const reporterFor = (source: ReportSource): OperationsReporter | null =>
@@ -639,33 +671,33 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
639
671
  ? null
640
672
  : operationsReporter({ ...operations, source, scrubber, ...(options.operationsFetch === undefined ? {} : { fetch: options.operationsFetch }) })
641
673
  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
674
  const containers = containersOf(artifact)
647
675
  unservable(artifact, options.assets, owned, containers, problems)
648
676
  // This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
649
677
  const issuer = createCapabilityIssuer()
650
- const files = fileCollections(artifact, app.database, options.files, issuer.authority, issuer.mint, problems)
678
+ const fileKinds = storedFileKinds(artifact, app.database, objects, problems)
651
679
  const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
652
680
  const framework = bound?.framework ?? null
653
681
  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 })
682
+ const database = bound?.database ?? null
683
+ const verifier = new TokenVerifier({ getJwks: () => platformCall(() => options.iam.getJwks()), issuer: variables.issuer })
656
684
  const workloads = workloadCredentials({
657
685
  artifact,
658
686
  framework: options.environment.framework,
659
687
  audience: variables.iamAppId,
660
- source: options.workloadTokens,
688
+ source: workloadTokens,
661
689
  verifier,
662
690
  }, problems)
663
691
  const connect = wireImports({
664
692
  artifact,
665
693
  snapshots: app.connect ?? {},
666
694
  framework: options.environment.framework,
667
- bindings: options.connectBindings ?? {},
668
- fallback: (request) => fetch(request),
695
+ bindings: Object.fromEntries(
696
+ Object.entries(options.connectBindings ?? {}).map((
697
+ [name, binding],
698
+ ): [string, ConnectFetch] => [name, (request) => platformCall(() => binding(request))]),
699
+ ),
700
+ fallback: (request) => platformCall(() => fetch(request)),
669
701
  }, problems)
670
702
  const configs = browserConfigs(artifact, options.environment, {
671
703
  dsn: operations === null ? null : (options.environment.framework['FABRIKA_OPERATIONS_DSN'] ?? '').trim(),
@@ -724,6 +756,17 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
724
756
  declared.add(id)
725
757
  const row = rows.get(id)
726
758
  const bound = boundHandler(path.reduce<unknown>(childAt, handlers), id, `procedure '${id}'`, procedure.type, artifact, problems)
759
+ if (row?.kind === 'public') {
760
+ const served = publicEndpoint(row, procedure, bound, artifact, workloads, options, problems)
761
+ if (served !== null) {
762
+ endpoints.set(id, served)
763
+ const issuer = challengeIssuerEndpoint(served, rows, artifact, options, problems)
764
+ if (issuer !== null) {
765
+ endpoints.set(issuer.row.id, issuer)
766
+ }
767
+ }
768
+ continue
769
+ }
727
770
  if (!isCredentialed(row)) {
728
771
  problems.push(`procedure '${id}' has no endpoint row`)
729
772
  } else if (bound === null) {
@@ -738,45 +781,39 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
738
781
  facilities: siteFacilities(facilitiesOf(artifact, row), bound.declaration),
739
782
  connect: siteConnect(connect.get(id), bound.declaration),
740
783
  site: servedSite(procedure.type, bound),
741
- ...(bound.declaration?.then == null ? {} : { then: bound.declaration.then }),
784
+ ...egressOf(bound),
785
+ ...bindsOf(id, bound.declaration, artifact, problems),
786
+ ...admissionOf(id, artifact, options, problems),
787
+ ...queryOf(id, artifact),
742
788
  })
743
789
  }
744
790
  }
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
791
  } else if (mount.kind === 'webhook') {
754
792
  declared.add(name)
755
793
  const row = rows.get(name)
756
794
  const bound = boundHandler(childAt(handlers, name), name, `webhook '${name}'`, 'mutation', artifact, problems)
757
- const credential: WorkloadCredential | undefined = workloads.get(`webhook:${name}`)
795
+ const credential: WorkloadCredential | undefined = workloads.get(workloadSite('webhook', name))
796
+ const approved = artifact.mounts[name]
758
797
  if (row === undefined || bound === null) {
759
798
  problems.push(`webhook '${name}' has no handler; add handlers.${name}: ({ input, ctx }) => …`)
760
- } else if (credential !== undefined) {
799
+ } else if (credential !== undefined && approved?.kind === 'webhook') {
761
800
  endpoints.set(name, {
762
801
  kind: 'webhook',
763
802
  row,
764
803
  webhook: name,
765
- dimension: mount.boundTo,
804
+ scheme: approved.scheme,
805
+ boundTo: approved.boundTo,
806
+ timeoutMs: approved.timeoutMs,
766
807
  actions: mount.actions,
767
808
  input: mount.input,
768
809
  handler: bound.handler,
769
810
  credential,
770
811
  site: bound.site ? (bound.declaration ?? undefined) : undefined,
771
812
  })
772
- bindWebhookBindings(name, mount.path, rows, endpoints, () => frameworkOf(framework).webhooks)
773
813
  }
774
814
  }
775
815
  }
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)
816
+ const membership = membershipRpc === undefined ? null : new IamMembership(membershipRpc, variables.iamAppId)
780
817
  const jobs = bindJobs({
781
818
  artifact,
782
819
  handlers,
@@ -787,23 +824,30 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
787
824
  credentials: workloads,
788
825
  mint: issuer.mint,
789
826
  data: bound?.data ?? null,
790
- files,
791
- frameworkJobs: files === null ? [] : [
792
- ...(files.scans
827
+ fileKinds,
828
+ frameworkJobs: fileKinds === null ? [] : [
829
+ ...(fileKinds.scans
793
830
  ? [{
794
831
  name: FILE_SCAN_JOB,
795
832
  schedule: FILE_SCAN_SCHEDULE,
796
833
  timeoutMs: SCAN_LIMITS.jobTimeoutMs,
797
- run: (reporter: OperationsReporter, signal: AbortSignal) => files.scanPass(options.scanner ?? null, reporter, signal),
834
+ run: (reporter: OperationsReporter, signal: AbortSignal) => fileKinds.scanPass(options.scanner ?? null, reporter, signal),
798
835
  }]
799
836
  : []),
800
- { name: FILE_SWEEP_JOB, schedule: FILE_SWEEP_SCHEDULE, timeoutMs: FILE_SWEEP_TIMEOUT_MS, run: (_reporter, signal) => files.sweep(signal) },
837
+ {
838
+ name: FILE_SWEEP_JOB,
839
+ schedule: FILE_SWEEP_SCHEDULE,
840
+ timeoutMs: FILE_SWEEP_TIMEOUT_MS,
841
+ run: (_reporter, signal) => fileKinds.sweep(signal),
842
+ },
801
843
  ],
802
844
  membership,
803
845
  capabilities: issuer.authority,
804
846
  containers,
805
847
  ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
806
848
  mail,
849
+ tokens,
850
+ url,
807
851
  }, problems)
808
852
  for (const id of jobs.declared) {
809
853
  declared.add(id)
@@ -830,6 +874,8 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
830
874
  facilities: NO_FACILITIES,
831
875
  readOnly: true,
832
876
  site: servedSite('query', bound),
877
+ ...egressOf(bound),
878
+ ...admissionOf(id, artifact, options, problems),
833
879
  })
834
880
  }
835
881
  }
@@ -844,7 +890,9 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
844
890
  if (data === null && kitOriginOf(app.contract) === 'tableless') {
845
891
  servedWithoutData(endpoints, problems)
846
892
  }
847
- const site = [...endpoints.values()].find((served) => (served.kind === 'procedure' || served.kind === 'webhook') && served.site !== undefined)
893
+ const site = [...endpoints.values()].find((served) =>
894
+ served.kind === 'public' || ((served.kind === 'procedure' || served.kind === 'webhook') && served.site !== undefined)
895
+ )
848
896
  if (site !== undefined && data === null) {
849
897
  problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
850
898
  }
@@ -875,10 +923,15 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
875
923
  mint: issuer.mint,
876
924
  capabilities: issuer.authority,
877
925
  data,
878
- files,
926
+ fileKinds,
879
927
  failures: new FailureReports(reporterFor('fabrika.requests')),
880
928
  kick: jobs.kick,
881
929
  actions: Object.freeze(Object.keys(artifact.actions).sort()),
930
+ publicShare: new InFlightShare(publicShareOf(options.connections ?? DEFAULT_CONNECTIONS)),
931
+ clientVouched: options.clientAddress === 'vouched',
932
+ tokens,
933
+ url,
934
+ origin,
882
935
  })
883
936
  return Object.freeze({
884
937
  fetch: (request: Request, exec?: RequestExecutionContext) => runtime.fetch(request, exec),
@@ -70,3 +70,26 @@ export async function readBoundedBytes(stream: ReadableStream<Uint8Array> | null
70
70
  signal?.removeEventListener('abort', cancel)
71
71
  }
72
72
  }
73
+
74
+ /** `body` passed on as it streams, failed once more than `maxBytes` went through; `exceeded` tells that failure apart. */
75
+ export function boundedStream(body: ReadableStream<Uint8Array>, maxBytes: number): {
76
+ readonly stream: ReadableStream<Uint8Array>
77
+ exceeded(): boolean
78
+ } {
79
+ let size = 0
80
+ let over = false
81
+ const stream = body.pipeThrough(
82
+ new TransformStream<Uint8Array, Uint8Array>({
83
+ transform(chunk, controller) {
84
+ size += chunk.byteLength
85
+ if (size > maxBytes) {
86
+ over = true
87
+ controller.error(new Error(`the body is longer than ${maxBytes} bytes`))
88
+ return
89
+ }
90
+ controller.enqueue(chunk)
91
+ },
92
+ }),
93
+ )
94
+ return { stream, exceeded: () => over }
95
+ }
@@ -11,7 +11,7 @@ export interface BrowserConfig {
11
11
  /** `FABRIKA_ENVIRONMENT`, which the page's error reports carry, or `null` when unset. */
12
12
  readonly environment: string | null
13
13
  /**
14
- * Every form challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
14
+ * Every public procedure challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
15
15
  * there is none, so the file of an application without them holds the three Operations values only.
16
16
  */
17
17
  readonly public?: Readonly<Record<string, string>>
@@ -33,11 +33,12 @@ export function browserConfigs(
33
33
  return typeof value === 'string' && value.trim() !== '' ? value : null
34
34
  }
35
35
  const siteKeys: Record<string, string> = {}
36
- for (const [form, mount] of Object.entries(artifact.mounts)) {
37
- const challenge = mount.kind === 'form' ? mount.challenge : undefined
38
- if (challenge?.siteKey === undefined) continue
36
+ for (const [id, procedure] of Object.entries(artifact.procedures ?? {})) {
37
+ const challenge = procedure.challenge
38
+ // Only Turnstile's widget needs a key in the page; ALTCHA's fetches its challenge from the application.
39
+ if (challenge?.provider !== 'turnstile' || challenge.siteKey === undefined) continue
39
40
  const value = valueOf(challenge.siteKey)
40
- if (value === null) problems.push(`form '${form}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
41
+ if (value === null) problems.push(`procedure '${id}' names its ${challenge.provider} site key in ${challenge.siteKey}, which is not set`)
41
42
  else siteKeys[challenge.siteKey] = value
42
43
  }
43
44
  const configs = new Map<string, BrowserConfig>()