@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.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- 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
|
+
}
|