@fabrika/app 0.3.2 → 0.4.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 (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,808 @@
1
+ // `boot(app, { environment, iam })`: called once, by the generated entrypoint through its runtime adapter, after the
2
+ // adapter captured the framework's variables and scrubbed them from the process. It compiles the contract, binds
3
+ // every handler to its endpoint and refuses to start when anything declared cannot be served.
4
+
5
+ import { IamMembership } from '@fabrika/auth'
6
+ import {
7
+ compileEndpointTable,
8
+ type ContractArtifact,
9
+ contractDigest,
10
+ type CredentialedEndpoint,
11
+ endpointAppDirectory,
12
+ endpointDigest,
13
+ endpointDirectory,
14
+ endpointMembers,
15
+ type EndpointRow,
16
+ FABRIKA_CONTRACT_DIGEST,
17
+ FABRIKA_IAM_APP_ID,
18
+ FABRIKA_PROXY_SECRET,
19
+ type FormMountArtifact,
20
+ type IamMembershipRpc,
21
+ type Jwks,
22
+ parseAppEnvironmentId,
23
+ } from '@fabrika/auth-core'
24
+ import type { Database } from '@fabrika/db'
25
+ import type { CapabilityAuthority } from '@fabrika/db/capability'
26
+ import {
27
+ bindScopedDatabase,
28
+ type DataAccess,
29
+ dataAccessOf,
30
+ type FrameworkTables,
31
+ frameworkTablesOf,
32
+ isScopedDatabase,
33
+ type WebhookTables,
34
+ } from '@fabrika/db/framework'
35
+ import type { DataKind } from '@fabrika/db/tables'
36
+ import { TokenVerifier } from '@fabrika/jwks'
37
+ import type { FileObjectStore } from '@fabrika/platform'
38
+ import { compileContract } from '../contract/compile'
39
+ import { exportDescription } from '../contract/connect'
40
+ import { isProcedure, type ProcedureDeclaration, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
41
+ import { exportBase, FORM_CHALLENGE_FIELD, FORM_LIMITS } from '../contract/defaults'
42
+ import type { Schema } from '../contract/define'
43
+ import { kitOriginOf } from '../contract/handled'
44
+ import type { RequestExecutionContext } from '../fetch'
45
+ import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS, fileCollections } from '../files/collections'
46
+ import { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE, type FileScanner, SCAN_LIMITS } from '../files/scan'
47
+ import { logReporter, type OperationsReporter, operationsReporter, operationsSettings, type ReportSource } from '../jobs/report'
48
+ import { bindJobs, type ContractJobs } from '../jobs/runner'
49
+ import { browserConfigs } from './browser-config'
50
+ import { createCapabilityIssuer, requestIsReadOnly } from './capability'
51
+ import { type ChallengeFetch, type ChallengeVerifier, turnstileVerifier } from './challenge'
52
+ import { type ConnectFetch, wireImports } from './connect'
53
+ import {
54
+ ContractRuntime,
55
+ type Facilities,
56
+ type FormAdmission,
57
+ NO_FACILITIES,
58
+ proxySecretDigest,
59
+ type ServedEndpoint,
60
+ type ServedSite,
61
+ type SpaAssets,
62
+ } from './dispatch'
63
+ import type { CapturedEnvironment } from './environment'
64
+ import { FailureReports } from './failures'
65
+ import { ServerFault } from './faults'
66
+ import type { SubmissionFields } from './form-admission'
67
+ import { applicationPolicy } from './headers'
68
+ import type { ContractDatabase, ContractImplementation, ImplementationDatabase } from './implement'
69
+ import { approvedDeclaration, approvedReach, isSiteHandler, type SiteDeclaration } from './invocation'
70
+ import type { ReadDirectory } from './membership'
71
+ import { bindingHandlers, webhookBindInput, webhookBindOutput, webhookUnbindInput, webhookUnbindOutput } from './webhook'
72
+ import { type WorkloadCredential, workloadCredentials, type WorkloadTokenSource } from './workload'
73
+
74
+ /** Where the token verifier gets IAM's public keys: the IAM binding on Cloudflare, the issuer's JWKS elsewhere. */
75
+ export interface IamKeySource {
76
+ getJwks(): Promise<Jwks>
77
+ }
78
+
79
+ export interface BootOptions {
80
+ /** The variables `captureEnvironment` took before the application's modules loaded. */
81
+ readonly environment: CapturedEnvironment
82
+ readonly iam: IamKeySource
83
+ /** Serves the SPA mounts. Without it, a contract that declares one does not boot. */
84
+ readonly assets?: SpaAssets
85
+ /** The application's Postgres connection, which the Bun adapter opens. Without it, an application on `@fabrika/db` does not boot. */
86
+ readonly database?: Database
87
+ /**
88
+ * The proxy authenticates itself with `FABRIKA_PROXY_SECRET`, and the application does not boot without it. The Bun
89
+ * adapter sets it: on Zerops a namespace is one private network. On Cloudflare the service binding is the
90
+ * authenticated hop (ADR-0022 rule 1). A secret that is set is checked either way.
91
+ */
92
+ readonly requireProxySecret?: boolean
93
+ /** The object storage of the file collections, which the Bun adapter opens. Without it, a contract with files does not boot. */
94
+ readonly files?: FileObjectStore
95
+ /**
96
+ * The malware scanner of the scan job of `scan: true` collections, which the Bun adapter supplies to a job run
97
+ * (`clamscanScanner`). Without it the scan job leaves every quarantined file quarantined and reports.
98
+ */
99
+ readonly scanner?: FileScanner
100
+ /**
101
+ * IAM's membership operations: the IAM binding on Cloudflare, `HttpIamRpc` elsewhere. Without it, a contract that
102
+ * creates scopes, manages members or issues share links does not boot.
103
+ */
104
+ readonly membership?: IamMembershipRpc
105
+ /**
106
+ * Cloudflare service bindings to the owners' proxy Workers, by binding name (`FABRIKA_CONNECT_<IMPORT>`). An import
107
+ * without one calls its owner with `fetch`.
108
+ */
109
+ readonly connectBindings?: Readonly<Record<string, ConnectFetch>>
110
+ /**
111
+ * Where a job or a webhook exchanges its key for a token: IAM's public token exchange on Bun. Without it, a contract
112
+ * with a job or a webhook does not boot.
113
+ */
114
+ readonly workloadTokens?: WorkloadTokenSource
115
+ /** What posts a report to Operations' ingest; `fetch` by default. */
116
+ readonly operationsFetch?: (request: Request) => Promise<Response>
117
+ /** What asks a form's challenge provider to verify a token; `fetch` by default. */
118
+ readonly challengeFetch?: ChallengeFetch
119
+ }
120
+
121
+ export interface ContractApp {
122
+ /** `exec` keeps a failure's report to Operations alive past the response (a Worker's `waitUntil`). */
123
+ fetch(request: Request, exec?: RequestExecutionContext): Promise<Response>
124
+ /** The contract's `c.cron` jobs; `fabrika job run <name>` runs one. */
125
+ readonly jobs: ContractJobs
126
+ }
127
+
128
+ /** Everything that stops a contract application from starting, with where it is and what to do. */
129
+ export class BootError extends Error {
130
+ override readonly name = 'BootError'
131
+ readonly problems: readonly string[]
132
+
133
+ constructor(problems: readonly string[]) {
134
+ super(`the application does not boot:\n- ${problems.join('\n- ')}`)
135
+ this.problems = problems
136
+ }
137
+ }
138
+
139
+ interface FrameworkVariables {
140
+ /** The token audience: this application's IAM id in this environment, `<app>@<env>`. */
141
+ readonly iamAppId: string
142
+ readonly issuer: string
143
+ readonly operationsOrigin: string | null
144
+ }
145
+
146
+ function httpOrigin(name: string, raw: unknown, problems: string[]): string | null {
147
+ if (typeof raw !== 'string' || raw.trim() === '') {
148
+ problems.push(`${name} is not set; the platform writes it into every deploy`)
149
+ return null
150
+ }
151
+ try {
152
+ const url = new URL(raw.trim())
153
+ if (url.protocol === 'https:' || url.protocol === 'http:') {
154
+ return url.origin
155
+ }
156
+ } catch {
157
+ // Reported below, without the value: it may carry a key.
158
+ }
159
+ problems.push(`${name} is not an http(s) URL`)
160
+ return null
161
+ }
162
+
163
+ function readFramework(framework: Readonly<Record<string, string>>, contractApp: string, problems: string[]): FrameworkVariables {
164
+ const iamAppId = framework[FABRIKA_IAM_APP_ID]?.trim() ?? ''
165
+ const parsed = parseAppEnvironmentId(iamAppId)
166
+ if (iamAppId === '') {
167
+ problems.push(`${FABRIKA_IAM_APP_ID} is not set; it is the audience every token must carry, <app>@<env>`)
168
+ } else if (parsed === null) {
169
+ problems.push(`${FABRIKA_IAM_APP_ID} '${iamAppId}' is not an application environment id (allowed: <app>@<env>)`)
170
+ } else if (parsed.app !== contractApp) {
171
+ problems.push(`${FABRIKA_IAM_APP_ID} names the application '${parsed.app}', but the contract declares '${contractApp}'`)
172
+ }
173
+ const issuer = httpOrigin('FABRIKA_IAM_ISSUER', framework['FABRIKA_IAM_ISSUER'], problems)
174
+ const dsn = framework['FABRIKA_OPERATIONS_DSN']
175
+ const operationsOrigin = dsn === undefined ? null : httpOrigin('FABRIKA_OPERATIONS_DSN', dsn, problems)
176
+ return { iamAppId, issuer: issuer ?? '', operationsOrigin }
177
+ }
178
+
179
+ /** The deploy was approved with one contract; this build must be that contract, or it does not start (ADR-0022 rule 5). */
180
+ function checkContractDigest(framework: Readonly<Record<string, string>>, artifact: ContractArtifact, problems: string[]): void {
181
+ const expected = framework[FABRIKA_CONTRACT_DIGEST]?.trim() ?? ''
182
+ const actual = contractDigest(artifact)
183
+ if (expected === '') {
184
+ problems.push(`${FABRIKA_CONTRACT_DIGEST} is not set; it is the digest of the contract the deploy was approved with`)
185
+ } else if (expected !== actual) {
186
+ problems.push(
187
+ `the contract's digest is ${actual}, but ${FABRIKA_CONTRACT_DIGEST} is ${expected}: this build is not the contract the deploy was approved with`,
188
+ )
189
+ }
190
+ }
191
+
192
+ /** Every dimension's containers, nearest first (`note → [workspace, organization]`); `buildArtifact` refuses a cycle. */
193
+ function containersOf(artifact: ContractArtifact): ReadonlyMap<string, readonly string[]> {
194
+ const containers = new Map<string, readonly string[]>()
195
+ for (const dimension of Object.keys(artifact.dimensions)) {
196
+ const chain: string[] = []
197
+ let next = artifact.dimensions[dimension]?.in ?? null
198
+ while (next !== null && !chain.includes(next)) {
199
+ chain.push(next)
200
+ next = artifact.dimensions[next]?.in ?? null
201
+ }
202
+ containers.set(dimension, chain)
203
+ }
204
+ return containers
205
+ }
206
+
207
+ /** The shortest secret accepted; Control generates 32 random bytes. */
208
+ const MIN_PROXY_SECRET_LENGTH = 32
209
+
210
+ /** The digest of the proxy secret the application compares every request's with, or `null` when it has none. */
211
+ function readProxySecret(framework: Readonly<Record<string, string>>, required: boolean, problems: string[]): Uint8Array | null {
212
+ const secret = framework[FABRIKA_PROXY_SECRET] ?? ''
213
+ if (secret === '') {
214
+ if (required) {
215
+ problems.push(`${FABRIKA_PROXY_SECRET} is not set; the proxy authenticates itself to the application with it`)
216
+ }
217
+ return null
218
+ }
219
+ if (secret.length < MIN_PROXY_SECRET_LENGTH) {
220
+ problems.push(`${FABRIKA_PROXY_SECRET} is shorter than ${MIN_PROXY_SECRET_LENGTH} characters`)
221
+ return null
222
+ }
223
+ return proxySecretDigest(secret)
224
+ }
225
+
226
+ /** What this version cannot serve yet; a contract that declares it does not boot rather than answer nothing. */
227
+ function unservable(
228
+ artifact: ContractArtifact,
229
+ assets: SpaAssets | undefined,
230
+ owned: ReadonlySet<string>,
231
+ containers: ReadonlyMap<string, readonly string[]>,
232
+ problems: string[],
233
+ ): void {
234
+ for (const [name, mount] of Object.entries(artifact.mounts)) {
235
+ if ((mount.kind === 'spa' || mount.kind === 'link') && assets === undefined) {
236
+ problems.push(`mount '${name}' serves an SPA, and the runtime adapter supplies no asset server`)
237
+ }
238
+ if (mount.kind === 'link' && !owned.has(mount.dimension)) {
239
+ // Every link request re-reads where the object lies; without the table that records it, nothing could.
240
+ problems.push(`link mount '${name}' shares a '${mount.dimension}', but no table in src/db/tables.ts owns that dimension to locate it`)
241
+ }
242
+ if (mount.kind === 'webhook' && mount.boundTo.kind === 'app') {
243
+ problems.push(`webhook '${name}' is bound to the whole application; this version serves a webhook bound to a dimension (boundTo: '<dimension>')`)
244
+ }
245
+ }
246
+ for (const row of artifact.endpoints) {
247
+ if (row.requirement?.kind !== 'action') {
248
+ continue
249
+ }
250
+ const { access } = row.requirement
251
+ if (access.form === 'creates' || access.form === 'app') {
252
+ continue
253
+ }
254
+ if (access.form === 'on' && access.from === null) {
255
+ continue
256
+ }
257
+ if (access.form === 'on' && !owned.has(access.dimension)) {
258
+ problems.push(`'${row.id}' acts on a '${access.dimension}', but no table in src/db/tables.ts owns that dimension to locate it`)
259
+ } else {
260
+ // A grant on a container reaches a value only through the table that records which container holds it.
261
+ const unlocated = [access.dimension, ...(containers.get(access.dimension) ?? [])].find((dimension) =>
262
+ (artifact.dimensions[dimension]?.in ?? null) !== null && !owned.has(dimension)
263
+ )
264
+ if (unlocated !== undefined) {
265
+ problems.push(
266
+ `'${row.id}' reaches a '${access.dimension}'; '${unlocated}' lies in '${
267
+ artifact.dimensions[unlocated]?.in
268
+ }', but no table in src/db/tables.ts owns '${unlocated}' to locate it`,
269
+ )
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ /** Own properties only, so a handler tree can never name a prototype member. */
276
+ function childAt(node: unknown, key: string): unknown {
277
+ return typeof node === 'object' && node !== null && Object.hasOwn(node, key) ? Reflect.get(node, key) : undefined
278
+ }
279
+
280
+ interface DeclaredProcedure {
281
+ readonly id: string
282
+ readonly path: readonly string[]
283
+ readonly procedure: ProcedureDeclaration<Schema, unknown>
284
+ }
285
+
286
+ function declaredProcedures(mount: string, tree: ProcedureTreeDeclaration<Schema, unknown>, path: readonly string[] = []): DeclaredProcedure[] {
287
+ return Object.entries(tree).flatMap(([name, node]) => {
288
+ const at = [...path, name]
289
+ return isProcedure(node) ? [{ id: [mount, ...at].join('.'), path: [mount, ...at], procedure: node }] : declaredProcedures(mount, node, at)
290
+ })
291
+ }
292
+
293
+ /** Every function in the tree must be a declared procedure's handler, even when a cast defeated the handler types. */
294
+ function undeclaredHandlers(node: unknown, declared: ReadonlySet<string>, path: readonly string[], problems: string[]): void {
295
+ const id = path.join('.')
296
+ if (typeof node === 'function' || isSiteHandler(node)) {
297
+ if (!declared.has(id)) {
298
+ problems.push(`handler '${id}' is not in the contract; declare the endpoint in fabrika.contract.ts first`)
299
+ }
300
+ return
301
+ }
302
+ if (typeof node !== 'object' || node === null) {
303
+ problems.push(`handler '${id || 'handlers'}' is not a function or a group of handlers`)
304
+ return
305
+ }
306
+ for (const key of Object.keys(node)) {
307
+ undeclaredHandlers(childAt(node, key), declared, [...path, key], problems)
308
+ }
309
+ }
310
+
311
+ interface BoundDatabase {
312
+ readonly database: ContractDatabase
313
+ /** The framework's own writes; only a `scoped(tables)` layer has them. */
314
+ readonly framework: FrameworkTables | null
315
+ /** The mini-ORM behind a staged-kit site's `ctx.db`; only a `scoped(tables)` layer has it. */
316
+ readonly data: DataAccess | null
317
+ }
318
+
319
+ /**
320
+ * The database `dispatch` reads. A `scoped(tables)` result is bound here, once, to the adapter's connection and the
321
+ * authority that binds capabilities to requests; the application never receives either, nor the locator.
322
+ */
323
+ function contractDatabase(
324
+ database: ImplementationDatabase | null,
325
+ connection: Database | undefined,
326
+ capabilities: CapabilityAuthority,
327
+ problems: string[],
328
+ ): BoundDatabase | null {
329
+ if (database === null) {
330
+ return null
331
+ }
332
+ if (!isScopedDatabase(database)) {
333
+ if ('locate' in database) {
334
+ return { database, framework: null, data: null }
335
+ }
336
+ problems.push('serve(contract, { database }) takes the scoped(tables) result of @fabrika/db itself, not a copy or a wrapper of it')
337
+ return null
338
+ }
339
+ if (connection === undefined) {
340
+ problems.push('the application uses @fabrika/db, which runs on Bun with Postgres, and the runtime adapter supplies no database connection')
341
+ return null
342
+ }
343
+ try {
344
+ const locate = bindScopedDatabase(database, { database: connection, capabilities, readOnly: requestIsReadOnly })
345
+ return { database: { tables: database.tables, locate }, framework: frameworkTablesOf(database), data: dataAccessOf(database) }
346
+ } catch {
347
+ problems.push('the data layer was bound before boot; only the framework binds it, so remove @fabrika/db/framework from the application')
348
+ return null
349
+ }
350
+ }
351
+
352
+ /**
353
+ * A handler tree leaf, bound. `declaration` is the reach the approved artifact gives the site, or `null` for a
354
+ * `defineContract` contract, whose reach is its imports' `usedBy` and its link mounts; `site` whether the leaf is a
355
+ * staged-kit site, which reaches data through `ctx.db`.
356
+ */
357
+ interface BoundHandler {
358
+ readonly handler: (args: { readonly input: unknown; readonly ctx: object }) => unknown
359
+ readonly declaration: SiteDeclaration | null
360
+ readonly site: boolean
361
+ }
362
+
363
+ /**
364
+ * A leaf of `site` (an endpoint id). Its reach is the approved artifact's, whatever the leaf is: a staged-kit
365
+ * contract's artifact holds every site's declaration (none when the site declares none), and a site leaf that
366
+ * declares otherwise does not boot.
367
+ */
368
+ function boundHandler(
369
+ node: unknown,
370
+ site: string,
371
+ where: string,
372
+ kind: DataKind,
373
+ artifact: ContractArtifact,
374
+ problems: string[],
375
+ ): BoundHandler | null {
376
+ if (isSiteHandler(node)) {
377
+ const leaf = node
378
+ return {
379
+ handler: (args) => Reflect.apply(leaf.handler, undefined, [args]),
380
+ declaration: approvedDeclaration(where, site, leaf.declared, kind, artifact, problems),
381
+ site: true,
382
+ }
383
+ }
384
+ if (typeof node === 'function') {
385
+ return {
386
+ handler: (args) => Reflect.apply(node, undefined, [args]),
387
+ declaration: artifact.declared === undefined ? null : approvedReach(site, artifact),
388
+ site: false,
389
+ }
390
+ }
391
+ return null
392
+ }
393
+
394
+ /** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
395
+ function siteFacilities(facilities: Facilities, declaration: SiteDeclaration | null): Facilities {
396
+ if (declaration === null) {
397
+ return facilities
398
+ }
399
+ const links = facilities.links.filter((link) => link.name === declaration.issues)
400
+ return links.length === facilities.links.length ? facilities : Object.freeze({ members: facilities.members, links: Object.freeze(links) })
401
+ }
402
+
403
+ /** A staged-kit contract's endpoint gets `ctx.connect` only for the connectors it declares. */
404
+ function siteConnect<W extends { readonly name: string }>(
405
+ wires: readonly W[] | undefined,
406
+ declaration: SiteDeclaration | null,
407
+ ): readonly W[] | undefined {
408
+ if (declaration === null || wires === undefined) {
409
+ return wires
410
+ }
411
+ const declared = wires.filter((wire) => declaration.connect.includes(wire.name))
412
+ return declared.length === 0 ? undefined : declared
413
+ }
414
+
415
+ /**
416
+ * A kit without tables has no `ctx.db`: its procedures are served as plain handlers with their approved reach, and a
417
+ * site that declares `sql` does not boot, since `ctx.sql` runs on `@fabrika/db`.
418
+ */
419
+ function servedWithoutData(endpoints: Map<string, ServedEndpoint>, problems: string[]): void {
420
+ for (const [id, served] of endpoints) {
421
+ if (served.kind !== 'procedure' || served.site === undefined) {
422
+ continue
423
+ }
424
+ if (served.site.declaration.sql !== null) {
425
+ problems.push(`'${id}' declares sql, which runs on @fabrika/db; defineApp(vocabulary, tables) gives an application tables`)
426
+ }
427
+ endpoints.set(id, { ...served, site: undefined })
428
+ }
429
+ }
430
+
431
+ /** The site `dispatch` builds `ctx.db` for: a staged-kit site leaf only, with its approved declaration. */
432
+ function servedSite(kind: DataKind, bound: BoundHandler): ServedSite | undefined {
433
+ return !bound.site || bound.declaration === null ? undefined : { kind, declaration: bound.declaration }
434
+ }
435
+
436
+ /** `ctx.members` and `ctx.links` of an endpoint: decided from its access form and the artifact, once. */
437
+ function facilitiesOf(artifact: ContractArtifact, row: EndpointRow): Facilities {
438
+ if (row.requirement?.kind !== 'action') {
439
+ return NO_FACILITIES
440
+ }
441
+ const { action, access } = row.requirement
442
+ if (endpointAppDirectory(artifact, row)) {
443
+ return Object.freeze({ members: 'application', links: Object.freeze([]) })
444
+ }
445
+ if ((access.form !== 'in' && access.form !== 'on') || access.from === null) {
446
+ return NO_FACILITIES
447
+ }
448
+ const links = Object.entries(artifact.mounts).flatMap(([name, mount]) =>
449
+ mount.kind === 'link' && mount.dimension === access.dimension && mount.issuedBy === action ? [{ name, prefix: mount.prefix }] : []
450
+ )
451
+ return Object.freeze({ members: endpointMembers(artifact, row) === 'manage' ? 'manage' : readDirectory(artifact, row), links: Object.freeze(links) })
452
+ }
453
+
454
+ /** The member directory the endpoint reads, and whether its dimension shows labels. */
455
+ function readDirectory(artifact: ContractArtifact, row: EndpointRow): ReadDirectory | null {
456
+ const directory = endpointDirectory(artifact, row)
457
+ if (directory === null) {
458
+ return null
459
+ }
460
+ const dimension = Object.hasOwn(artifact.dimensions, directory.dimension) ? artifact.dimensions[directory.dimension] : undefined
461
+ return Object.freeze({ ...directory, labels: dimension?.directory === 'labels' })
462
+ }
463
+
464
+ /**
465
+ * Whether serving the endpoint needs IAM: a new scope, a membership change or a share link. A member directory does
466
+ * not: it asks IAM only when the handler calls it, and fails that call alone without a transport.
467
+ */
468
+ function asksIam(served: ServedEndpoint): boolean {
469
+ if (served.kind !== 'procedure') {
470
+ return false
471
+ }
472
+ const creates = served.row.requirement.kind === 'action' && served.row.requirement.access.form === 'creates'
473
+ return creates || served.facilities.members === 'manage' || served.facilities.links.length > 0
474
+ }
475
+
476
+ /** A form and a webhook write the framework's own tables, which only `@fabrika/db`'s `scoped(tables)` reaches. */
477
+ function frameworkProblems(artifact: ContractArtifact, framework: FrameworkTables | null, problems: string[]): void {
478
+ for (const [name, mount] of Object.entries(artifact.mounts)) {
479
+ if (mount.kind !== 'form' && mount.kind !== 'webhook') {
480
+ continue
481
+ }
482
+ if (framework === null) {
483
+ problems.push(`mount '${name}' is a ${mount.kind}, which writes through @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
484
+ continue
485
+ }
486
+ if (mount.kind === 'form') {
487
+ const input = artifact.endpoints.find((row) => row.id === name)?.input
488
+ const submitted = submissionFieldsOf(mount)
489
+ const fields = (input?.type === 'object' ? Object.keys(input.fields) : []).filter((field) =>
490
+ field !== submitted.honeypot && !(submitted.challenge && field === FORM_CHALLENGE_FIELD)
491
+ )
492
+ problems.push(...framework.inboxProblems(name, fields))
493
+ }
494
+ }
495
+ }
496
+
497
+ function isCredentialed(row: EndpointRow | undefined): row is CredentialedEndpoint {
498
+ return row !== undefined && row.requirement !== null
499
+ }
500
+
501
+ function submissionFieldsOf(mount: FormMountArtifact): SubmissionFields {
502
+ return { honeypot: mount.honeypot ?? null, challenge: mount.challenge !== undefined }
503
+ }
504
+
505
+ /**
506
+ * A form's admission from its approved mount: the declared rate or the defaults, and a challenge verified with the
507
+ * secret the application variable it names holds. Without that variable the application does not boot.
508
+ */
509
+ function formAdmission(name: string, artifact: ContractArtifact, options: BootOptions, problems: string[]): FormAdmission | null {
510
+ const mount = artifact.mounts[name]
511
+ if (mount?.kind !== 'form') {
512
+ return null
513
+ }
514
+ let challenge: ChallengeVerifier | null = null
515
+ if (mount.challenge !== undefined) {
516
+ const secret: unknown = options.environment.application[mount.challenge.secret]
517
+ if (typeof secret !== 'string' || secret.trim() === '') {
518
+ problems.push(
519
+ `form '${name}' verifies its ${mount.challenge.provider} challenge with the application variable ${mount.challenge.secret}, which is not set`,
520
+ )
521
+ return null
522
+ }
523
+ challenge = turnstileVerifier(secret.trim(), options.challengeFetch ?? ((request) => fetch(request)))
524
+ }
525
+ return Object.freeze({
526
+ perClient: mount.rate?.perClient ?? FORM_LIMITS.perClient,
527
+ perForm: mount.rate?.perForm ?? FORM_LIMITS.perForm,
528
+ fields: Object.freeze(submissionFieldsOf(mount)),
529
+ challenge,
530
+ })
531
+ }
532
+
533
+ /** `frameworkProblems` refused a webhook without the framework tables, so this answers only after a bug. */
534
+ function frameworkOf(framework: FrameworkTables | null): FrameworkTables {
535
+ if (framework === null) {
536
+ throw new ServerFault('facility_unbound', 'a webhook was served without the framework tables')
537
+ }
538
+ return framework
539
+ }
540
+
541
+ /** The generated `webhooks.<name>.bind` and `.unbind` endpoints, served by the framework's own handlers. */
542
+ function bindWebhookBindings(
543
+ name: string,
544
+ path: string,
545
+ rows: ReadonlyMap<string, EndpointRow>,
546
+ endpoints: Map<string, ServedEndpoint>,
547
+ tables: () => WebhookTables,
548
+ ): void {
549
+ const handlers = bindingHandlers(name, path, tables)
550
+ const bind = rows.get(`webhooks.${name}.bind`)
551
+ const unbind = rows.get(`webhooks.${name}.unbind`)
552
+ if (isCredentialed(bind)) {
553
+ endpoints.set(bind.id, {
554
+ kind: 'procedure',
555
+ row: bind,
556
+ input: webhookBindInput,
557
+ output: webhookBindOutput,
558
+ handler: handlers.bind,
559
+ facilities: NO_FACILITIES,
560
+ })
561
+ }
562
+ if (isCredentialed(unbind)) {
563
+ endpoints.set(unbind.id, {
564
+ kind: 'procedure',
565
+ row: unbind,
566
+ input: webhookUnbindInput,
567
+ output: webhookUnbindOutput,
568
+ handler: handlers.unbind,
569
+ facilities: NO_FACILITIES,
570
+ })
571
+ }
572
+ }
573
+
574
+ export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
575
+ const problems: string[] = []
576
+ const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
577
+ const artifact = compileContract(app.contract, app.database?.tables ?? {})
578
+ checkContractDigest(options.environment.framework, artifact, problems)
579
+ const proxySecret = readProxySecret(options.environment.framework, options.requireProxySecret === true, problems)
580
+ const operations = operationsSettings(options.environment.framework, problems)
581
+ const reporterFor = (source: ReportSource): OperationsReporter | null =>
582
+ operations === null
583
+ ? null
584
+ : operationsReporter({ ...operations, source, ...(options.operationsFetch === undefined ? {} : { fetch: options.operationsFetch }) })
585
+ const owned = new Set(Object.values(artifact.tables).flatMap((table) => table.scope.mode === 'dimension' ? [table.scope.dimension] : []))
586
+ // A file collection's dimension is located through its own table and its owner's.
587
+ for (const name of Object.keys(artifact.files)) {
588
+ owned.add(`file:${name}`)
589
+ }
590
+ const containers = containersOf(artifact)
591
+ unservable(artifact, options.assets, owned, containers, problems)
592
+ // This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
593
+ const issuer = createCapabilityIssuer()
594
+ const files = fileCollections(artifact, app.database, options.files, issuer.authority, issuer.mint, problems)
595
+ const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
596
+ const framework = bound?.framework ?? null
597
+ frameworkProblems(artifact, framework, problems)
598
+ const database = files === null || bound === null ? (bound?.database ?? null) : files.locating(bound.database)
599
+ const verifier = new TokenVerifier({ getJwks: () => options.iam.getJwks(), issuer: variables.issuer })
600
+ const workloads = workloadCredentials({
601
+ artifact,
602
+ framework: options.environment.framework,
603
+ audience: variables.iamAppId,
604
+ source: options.workloadTokens,
605
+ verifier,
606
+ }, problems)
607
+ const connect = wireImports({
608
+ artifact,
609
+ snapshots: app.connect ?? {},
610
+ framework: options.environment.framework,
611
+ bindings: options.connectBindings ?? {},
612
+ fallback: (request) => fetch(request),
613
+ }, problems)
614
+ const configs = browserConfigs(artifact, options.environment, {
615
+ dsn: operations === null ? null : (options.environment.framework['FABRIKA_OPERATIONS_DSN'] ?? '').trim(),
616
+ release: options.environment.framework['FABRIKA_RELEASE']?.trim() || null,
617
+ }, problems)
618
+ if (problems.length > 0) {
619
+ throw new BootError(problems)
620
+ }
621
+
622
+ const rows = new Map(artifact.endpoints.map((row) => [row.id, row]))
623
+ const endpoints = new Map<string, ServedEndpoint>()
624
+ const handlers = app.handlers(options.environment.application, { capabilities: issuer.authority })
625
+ const declared = new Set<string>()
626
+ for (const [name, mount] of Object.entries(app.contract.surface)) {
627
+ if (SURFACE_SECTIONS.some((section) => section === name) || typeof mount !== 'object' || !('kind' in mount)) {
628
+ continue
629
+ }
630
+ if (mount.kind === 'health') {
631
+ const row = rows.get(name)
632
+ if (row !== undefined) {
633
+ endpoints.set(name, { kind: 'health', row })
634
+ }
635
+ } else if (mount.kind === 'spa') {
636
+ const row = rows.get(name)
637
+ const config = configs.get(name)
638
+ if (config !== undefined && isCredentialed(row)) {
639
+ endpoints.set(name, { kind: 'spa', row, prefix: mount.prefix, dir: mount.dir, config })
640
+ } else if (config !== undefined && row?.kind === 'public-spa') {
641
+ endpoints.set(name, { kind: 'public-spa', row, prefix: mount.prefix, dir: mount.dir, config })
642
+ }
643
+ } else if (mount.kind === 'rpc' || mount.kind === 'link') {
644
+ if (mount.kind === 'link') {
645
+ const row = rows.get(name)
646
+ if (row !== undefined) {
647
+ endpoints.set(name, { kind: 'link-spa', row, prefix: mount.prefix, dir: mount.spa })
648
+ }
649
+ }
650
+ for (const { id, path, procedure } of declaredProcedures(name, mount.procedures)) {
651
+ declared.add(id)
652
+ const row = rows.get(id)
653
+ const bound = boundHandler(path.reduce<unknown>(childAt, handlers), id, `procedure '${id}'`, procedure.type, artifact, problems)
654
+ if (!isCredentialed(row)) {
655
+ problems.push(`procedure '${id}' has no endpoint row`)
656
+ } else if (bound === null) {
657
+ problems.push(`procedure '${id}' has no handler; add it to handlers`)
658
+ } else {
659
+ endpoints.set(id, {
660
+ kind: 'procedure',
661
+ row,
662
+ input: procedure.input,
663
+ output: procedure.output,
664
+ handler: bound.handler,
665
+ facilities: siteFacilities(facilitiesOf(artifact, row), bound.declaration),
666
+ connect: siteConnect(connect.get(id), bound.declaration),
667
+ site: servedSite(procedure.type, bound),
668
+ })
669
+ }
670
+ }
671
+ } else if (mount.kind === 'form') {
672
+ const row = rows.get(name)
673
+ const admission = formAdmission(name, artifact, options, problems)
674
+ const approved = artifact.mounts[name]
675
+ const then = approved?.kind === 'form' ? (approved.then ?? null) : null
676
+ if (row !== undefined && admission !== null) {
677
+ endpoints.set(name, { kind: 'form', row, form: name, input: mount.input, admission, then })
678
+ }
679
+ } else if (mount.kind === 'webhook') {
680
+ declared.add(name)
681
+ const row = rows.get(name)
682
+ const bound = boundHandler(childAt(handlers, name), name, `webhook '${name}'`, 'mutation', artifact, problems)
683
+ const credential: WorkloadCredential | undefined = workloads.get(`webhook:${name}`)
684
+ if (row === undefined || bound === null) {
685
+ problems.push(`webhook '${name}' has no handler; add handlers.${name}: ({ input, ctx }) => …`)
686
+ } else if (credential !== undefined) {
687
+ endpoints.set(name, {
688
+ kind: 'webhook',
689
+ row,
690
+ webhook: name,
691
+ dimension: mount.boundTo,
692
+ actions: mount.actions,
693
+ input: mount.input,
694
+ handler: bound.handler,
695
+ credential,
696
+ site: bound.site ? (bound.declaration ?? undefined) : undefined,
697
+ })
698
+ bindWebhookBindings(name, mount.path, rows, endpoints, () => frameworkOf(framework).webhooks)
699
+ }
700
+ }
701
+ }
702
+ for (const [id, served] of files?.endpoints(rows, problems) ?? []) {
703
+ endpoints.set(id, served)
704
+ }
705
+ const membership = options.membership === undefined ? null : new IamMembership(options.membership, variables.iamAppId)
706
+ const jobs = bindJobs({
707
+ artifact,
708
+ handlers,
709
+ database,
710
+ framework,
711
+ ownedDimensions: owned,
712
+ reporter: reporterFor('fabrika.jobs') ?? logReporter,
713
+ credentials: workloads,
714
+ mint: issuer.mint,
715
+ data: bound?.data ?? null,
716
+ files,
717
+ frameworkJobs: files === null ? [] : [
718
+ ...(files.scans
719
+ ? [{
720
+ name: FILE_SCAN_JOB,
721
+ schedule: FILE_SCAN_SCHEDULE,
722
+ timeoutMs: SCAN_LIMITS.jobTimeoutMs,
723
+ run: (reporter: OperationsReporter, signal: AbortSignal) => files.scanPass(options.scanner ?? null, reporter, signal),
724
+ }]
725
+ : []),
726
+ { name: FILE_SWEEP_JOB, schedule: FILE_SWEEP_SCHEDULE, timeoutMs: FILE_SWEEP_TIMEOUT_MS, run: (_reporter, signal) => files.sweep(signal) },
727
+ ],
728
+ membership,
729
+ capabilities: issuer.authority,
730
+ containers,
731
+ }, problems)
732
+ for (const id of jobs.declared) {
733
+ declared.add(id)
734
+ }
735
+ for (const [name, declaration] of Object.entries(app.contract.surface.exports ?? {})) {
736
+ const base = exportBase(name, declaration.version)
737
+ for (const { path, procedure } of declaredProcedures(name, declaration.procedures)) {
738
+ const id = [base, ...path.slice(1)].join('.')
739
+ const at = ['exports', ...path]
740
+ declared.add(at.join('.'))
741
+ const row = rows.get(id)
742
+ const bound = boundHandler(at.reduce<unknown>(childAt, handlers), id, `export procedure '${id}'`, 'query', artifact, problems)
743
+ if (!isCredentialed(row)) {
744
+ problems.push(`export procedure '${id}' has no endpoint row`)
745
+ } else if (bound === null) {
746
+ problems.push(`export procedure '${id}' has no handler; add it to handlers.${at.join('.')}`)
747
+ } else {
748
+ endpoints.set(id, {
749
+ kind: 'procedure',
750
+ row,
751
+ input: procedure.input,
752
+ output: procedure.output,
753
+ handler: bound.handler,
754
+ facilities: NO_FACILITIES,
755
+ readOnly: true,
756
+ site: servedSite('query', bound),
757
+ })
758
+ }
759
+ }
760
+ const row = rows.get(base)
761
+ const description = exportDescription(artifact, name)
762
+ if (isCredentialed(row) && description !== null) {
763
+ endpoints.set(base, { kind: 'description', row, description })
764
+ }
765
+ }
766
+ undeclaredHandlers(handlers, declared, [], problems)
767
+ const data = bound?.data ?? null
768
+ if (data === null && kitOriginOf(app.contract) === 'tableless') {
769
+ servedWithoutData(endpoints, problems)
770
+ }
771
+ const site = [...endpoints.values()].find((served) => (served.kind === 'procedure' || served.kind === 'webhook') && served.site !== undefined)
772
+ if (site !== undefined && data === null) {
773
+ problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
774
+ }
775
+ const asking = [...endpoints.values()].find(asksIam)
776
+ if (asking !== undefined && options.membership === undefined) {
777
+ problems.push(
778
+ `'${asking.row.id}' asks IAM to create a scope, manage members or issue a link, and the runtime adapter supplies no IAM membership transport (the IAM binding on Cloudflare; FABRIKA_IAM_RPC_URL and FABRIKA_IAM_RPC_KEY on Bun)`,
779
+ )
780
+ }
781
+ if (problems.length > 0) {
782
+ throw new BootError(problems)
783
+ }
784
+
785
+ const runtime = new ContractRuntime({
786
+ table: compileEndpointTable(artifact),
787
+ endpoints,
788
+ digests: new Map(artifact.endpoints.map((row) => [row.id, endpointDigest(artifact, row)])),
789
+ verifier,
790
+ audience: variables.iamAppId,
791
+ proxySecretDigest: proxySecret,
792
+ policy: applicationPolicy(artifact.csp, variables.operationsOrigin),
793
+ database,
794
+ ownedDimensions: owned,
795
+ containers,
796
+ assets: options.assets ?? null,
797
+ membership,
798
+ framework,
799
+ mint: issuer.mint,
800
+ capabilities: issuer.authority,
801
+ data,
802
+ files,
803
+ failures: new FailureReports(reporterFor('fabrika.requests')),
804
+ kick: jobs.kick,
805
+ actions: Object.freeze(Object.keys(artifact.actions).sort()),
806
+ })
807
+ return Object.freeze({ fetch: (request: Request, exec?: RequestExecutionContext) => runtime.fetch(request, exec), jobs: jobs.jobs })
808
+ }