@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
package/src/bun.ts
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
// ─── Bun process adapter ─────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
3
|
// A long-running Bun process needs only request dispatch and explicit draining.
|
|
4
|
-
// It does not emulate Worker cron, queues, or passThroughOnException().
|
|
4
|
+
// It does not emulate Worker cron, queues, or passThroughOnException(). A
|
|
5
|
+
// contract application is served through `serveContract`, from the generated
|
|
6
|
+
// entrypoint.
|
|
5
7
|
|
|
6
|
-
import
|
|
8
|
+
import { HttpIamMembership } from '@fabrika/auth'
|
|
9
|
+
import type { Database, PostgresOptions } from '@fabrika/db'
|
|
10
|
+
import { isScopedDatabase } from '@fabrika/db/framework'
|
|
11
|
+
import { parseJwks } from '@fabrika/jwks'
|
|
12
|
+
import type { FileObjectStore } from '@fabrika/platform'
|
|
13
|
+
import { applicationRuntimeUrl, S3BlobStore } from '@fabrika/platform-node'
|
|
14
|
+
import { diskAssets } from './disk-assets.js'
|
|
15
|
+
import type { FetchService, RequestExecutionContext } from './fetch.js'
|
|
16
|
+
import { clamscanScanner } from './files/clamscan.js'
|
|
17
|
+
import { boot, BootError, type IamKeySource } from './implement/boot.js'
|
|
18
|
+
import { captureEnvironment } from './implement/environment.js'
|
|
19
|
+
import { type ContractImplementation, isContractImplementation } from './implement/implement.js'
|
|
20
|
+
import { issuerWorkloadTokens, type WorkloadTokenSource } from './implement/workload.js'
|
|
21
|
+
import { JOB_CHECK_INTERVAL_MS, type JobRunOptions, type JobRunResult } from './jobs/runner.js'
|
|
22
|
+
import { makeProcessUndumpable } from './undumpable.js'
|
|
23
|
+
|
|
24
|
+
export { diskAssets } from './disk-assets.js'
|
|
25
|
+
export { clamscanArguments, type ClamscanOptions, clamscanScanner } from './files/clamscan.js'
|
|
26
|
+
|
|
27
|
+
export { markProductionProcess } from '@fabrika/db/production'
|
|
7
28
|
|
|
8
29
|
export interface ManagedBunHandlerOptions {
|
|
9
30
|
/** Required sink for rejected background tasks. Do not log raw errors or secret-bearing values. */
|
|
@@ -25,7 +46,7 @@ export interface BunHandler {
|
|
|
25
46
|
drain(): Promise<void>
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
export function createBunHandler<Env>(app:
|
|
49
|
+
export function createBunHandler<Env>(app: FetchService<Env>, env: Env, options: BunHandlerOptions): BunHandler {
|
|
29
50
|
if (options.executionContext !== undefined) {
|
|
30
51
|
return {
|
|
31
52
|
fetch: (request) => app.fetch(request, env, options.executionContext),
|
|
@@ -61,3 +82,281 @@ export function createBunHandler<Env>(app: FabrikaApp<Env>, env: Env, options: B
|
|
|
61
82
|
},
|
|
62
83
|
}
|
|
63
84
|
}
|
|
85
|
+
|
|
86
|
+
// ─── Contract applications ───────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** IAM's public key set at the issuer's `/.well-known/jwks.json`, for a process with no IAM binding. */
|
|
89
|
+
export function issuerJwks(issuer: unknown): IamKeySource {
|
|
90
|
+
return {
|
|
91
|
+
async getJwks() {
|
|
92
|
+
if (typeof issuer !== 'string') {
|
|
93
|
+
throw new Error('FABRIKA_IAM_ISSUER is not set')
|
|
94
|
+
}
|
|
95
|
+
const response = await fetch(new URL('/.well-known/jwks.json', issuer), { headers: { accept: 'application/json' } })
|
|
96
|
+
const jwks = response.ok ? parseJwks(await response.json()) : null
|
|
97
|
+
if (jwks === null) {
|
|
98
|
+
throw new Error('the issuer published no usable key set')
|
|
99
|
+
}
|
|
100
|
+
return jwks
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ContractServer {
|
|
106
|
+
/** The port the server listens on; `PORT=0` picks a free one. */
|
|
107
|
+
readonly port: number
|
|
108
|
+
/** Stops accepting requests and waits for the ones in flight. */
|
|
109
|
+
stop(): Promise<void>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What the adapter takes from `@fabrika/db` before the application loads, so no application module can stand in for
|
|
114
|
+
* it: a replaced `Database.postgres` would receive the database URL.
|
|
115
|
+
*/
|
|
116
|
+
interface DataLayerOpeners {
|
|
117
|
+
readonly postgres: (options: PostgresOptions) => Database
|
|
118
|
+
readonly applicationSchema: (appId: string) => string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Loads `@fabrika/db` before the application: it captures its built-ins and freezes the Kysely compiler at load. */
|
|
122
|
+
async function loadDataLayer(): Promise<DataLayerOpeners> {
|
|
123
|
+
const { Database, applicationSchema } = await import('@fabrika/db')
|
|
124
|
+
return { postgres: Database.postgres, applicationSchema }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The provisioning credential the platform writes; only the launcher reads it, and the application's process never holds it. */
|
|
128
|
+
const PROVISIONING_DATABASE_URL = 'FABRIKA_APP_DATABASE_URL'
|
|
129
|
+
/** The runtime role's credential, which the launcher derives and hands the application's process in its place. */
|
|
130
|
+
const RUNTIME_DATABASE_URL = 'FABRIKA_APP_RUNTIME_DATABASE_URL'
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The application's connection: its runtime role `run_<FABRIKA_APP_ID>`, whose URL the launcher derived from the
|
|
134
|
+
* provisioning credential, on the schema `app_<FABRIKA_APP_ID>` its migrations created. A URL is a credential, so no
|
|
135
|
+
* message quotes it.
|
|
136
|
+
*/
|
|
137
|
+
function openApplicationDatabase(data: DataLayerOpeners, framework: Readonly<Record<string, string>>): Database {
|
|
138
|
+
const url = framework[RUNTIME_DATABASE_URL] ?? ''
|
|
139
|
+
const appId = framework['FABRIKA_APP_ID'] ?? ''
|
|
140
|
+
const problems: string[] = []
|
|
141
|
+
if (url.trim() === '') {
|
|
142
|
+
problems.push('FABRIKA_APP_DATABASE_URL is not set; the platform writes it for an application with a database')
|
|
143
|
+
}
|
|
144
|
+
let schema = ''
|
|
145
|
+
try {
|
|
146
|
+
schema = data.applicationSchema(appId)
|
|
147
|
+
} catch {
|
|
148
|
+
problems.push(`FABRIKA_APP_ID '${appId}' is not an application id (allowed: lowercase letters, digits and -, at most 59)`)
|
|
149
|
+
}
|
|
150
|
+
if (problems.length > 0) {
|
|
151
|
+
throw new BootError(problems)
|
|
152
|
+
}
|
|
153
|
+
return data.postgres({ url, schema })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether this process holds the provisioning credential: then it is the launcher, and the application never runs in
|
|
158
|
+
* it. The application's process gets the runtime credential instead.
|
|
159
|
+
*/
|
|
160
|
+
function holdsProvisioningCredential(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
161
|
+
return (env[PROVISIONING_DATABASE_URL] ?? '') !== ''
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The launcher: before any application module loads, derives the runtime role's credential from the provisioning one
|
|
168
|
+
* and runs this same command again with an environment that carries the runtime credential and not the provisioning
|
|
169
|
+
* one, so neither the application's process nor anything it spawns can read the latter, `/proc/self/environ`
|
|
170
|
+
* included; the launcher makes itself non-dumpable first, so its own `/proc/<pid>/environ` is not readable either. Stdio is inherited, signals are forwarded, and the child's exit becomes this process's.
|
|
171
|
+
*/
|
|
172
|
+
async function relaunchAsRuntime(env: Readonly<Record<string, string | undefined>>): Promise<never> {
|
|
173
|
+
let runtimeUrl: string
|
|
174
|
+
try {
|
|
175
|
+
runtimeUrl = applicationRuntimeUrl(env[PROVISIONING_DATABASE_URL] ?? '', env['FABRIKA_APP_ID'] ?? '')
|
|
176
|
+
} catch {
|
|
177
|
+
throw new BootError([
|
|
178
|
+
'FABRIKA_APP_DATABASE_URL is not a connection URL with a password, or FABRIKA_APP_ID is not an application id; the runtime role derives its credential from both',
|
|
179
|
+
])
|
|
180
|
+
}
|
|
181
|
+
const environment: Record<string, string> = {}
|
|
182
|
+
for (const [name, value] of Object.entries(env)) {
|
|
183
|
+
if (value !== undefined && name !== PROVISIONING_DATABASE_URL) environment[name] = value
|
|
184
|
+
}
|
|
185
|
+
environment[RUNTIME_DATABASE_URL] = runtimeUrl
|
|
186
|
+
// Otherwise the application could read this process's environment, the provisioning credential in it, through /proc.
|
|
187
|
+
makeProcessUndumpable()
|
|
188
|
+
const child = Bun.spawn([process.execPath, ...process.execArgv, ...process.argv.slice(1)], {
|
|
189
|
+
env: environment,
|
|
190
|
+
stdin: 'inherit',
|
|
191
|
+
stdout: 'inherit',
|
|
192
|
+
stderr: 'inherit',
|
|
193
|
+
})
|
|
194
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
195
|
+
process.on(signal, () => child.kill(signal))
|
|
196
|
+
}
|
|
197
|
+
await child.exited
|
|
198
|
+
if (child.signalCode !== null) {
|
|
199
|
+
for (const signal of FORWARDED_SIGNALS) process.removeAllListeners(signal)
|
|
200
|
+
process.kill(process.pid, child.signalCode)
|
|
201
|
+
}
|
|
202
|
+
process.exit(child.exitCode ?? 1)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const STORAGE_VARIABLES = {
|
|
206
|
+
endpoint: 'FABRIKA_APP_STORAGE_ENDPOINT',
|
|
207
|
+
bucket: 'FABRIKA_APP_STORAGE_BUCKET',
|
|
208
|
+
accessKeyId: 'FABRIKA_APP_STORAGE_ACCESS_KEY_ID',
|
|
209
|
+
secretAccessKey: 'FABRIKA_APP_STORAGE_SECRET_ACCESS_KEY',
|
|
210
|
+
} as const
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The file collections' object storage (Zerops object storage, path-style), when the platform wrote its variables;
|
|
214
|
+
* a partial set stops the boot. The credentials are never quoted in a message.
|
|
215
|
+
*/
|
|
216
|
+
export function openFileStorage(framework: Readonly<Record<string, string>>): FileObjectStore | undefined {
|
|
217
|
+
const value = (name: string) => framework[name]?.trim() ?? ''
|
|
218
|
+
const names = Object.values(STORAGE_VARIABLES)
|
|
219
|
+
const missing = names.filter((name) => value(name) === '')
|
|
220
|
+
if (missing.length === names.length) {
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
if (missing.length > 0) {
|
|
224
|
+
throw new BootError(missing.map((name) => `${name} is not set; the platform writes every FABRIKA_APP_STORAGE_* variable together`))
|
|
225
|
+
}
|
|
226
|
+
return S3BlobStore.connect({
|
|
227
|
+
endpoint: value(STORAGE_VARIABLES.endpoint),
|
|
228
|
+
bucket: value(STORAGE_VARIABLES.bucket),
|
|
229
|
+
accessKeyId: value(STORAGE_VARIABLES.accessKeyId),
|
|
230
|
+
secretAccessKey: value(STORAGE_VARIABLES.secretAccessKey),
|
|
231
|
+
region: 'us-east-1',
|
|
232
|
+
virtualHostedStyle: false,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* IAM's membership transport, when the platform gives the process `FABRIKA_IAM_RPC_URL` (IAM's public origin) and
|
|
238
|
+
* `FABRIKA_IAM_RPC_KEY` (this environment's membership transport key); without both, there is none, and `boot` refuses
|
|
239
|
+
* a contract that needs one.
|
|
240
|
+
*/
|
|
241
|
+
function httpMembership(framework: Readonly<Record<string, string>>): HttpIamMembership | undefined {
|
|
242
|
+
const origin = framework['FABRIKA_IAM_RPC_URL']?.trim() ?? ''
|
|
243
|
+
const key = framework['FABRIKA_IAM_RPC_KEY']?.trim() ?? ''
|
|
244
|
+
return origin === '' || key === '' ? undefined : new HttpIamMembership({ origin, key })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** IAM's public token exchange, at the issuer every deploy names; `boot` reports a missing issuer itself. */
|
|
248
|
+
function workloadTokensOf(framework: Readonly<Record<string, string>>): WorkloadTokenSource | undefined {
|
|
249
|
+
const issuer = framework['FABRIKA_IAM_ISSUER']?.trim() ?? ''
|
|
250
|
+
return issuer === '' ? undefined : issuerWorkloadTokens(issuer)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
|
|
254
|
+
export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Captures the framework's variables and scrubs them from the process, then imports the application, boots it and
|
|
258
|
+
* serves it on `PORT` (default 3000), on every interface: the project's private network is how the proxy reaches
|
|
259
|
+
* it. Called only by the generated entrypoint, with `process.env`. SIGTERM drains. Given the provisioning credential,
|
|
260
|
+
* it is the launcher instead: it never loads the application and re-runs the entrypoint as the runtime role.
|
|
261
|
+
*/
|
|
262
|
+
export async function serveContract(load: ImplementationLoader, env: Readonly<Record<string, string | undefined>>): Promise<ContractServer> {
|
|
263
|
+
if (holdsProvisioningCredential(env)) {
|
|
264
|
+
return relaunchAsRuntime(env)
|
|
265
|
+
}
|
|
266
|
+
const environment = captureEnvironment(env)
|
|
267
|
+
const port = Number.parseInt(environment.application['PORT'] === undefined ? '3000' : String(environment.application['PORT']), 10)
|
|
268
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
269
|
+
throw new Error('PORT must be an integer between 0 and 65535')
|
|
270
|
+
}
|
|
271
|
+
const data = await loadDataLayer()
|
|
272
|
+
const { default: app } = await load()
|
|
273
|
+
const iam = issuerJwks(environment.framework['FABRIKA_IAM_ISSUER'])
|
|
274
|
+
const database = isScopedDatabase(app.database) ? openApplicationDatabase(data, environment.framework) : undefined
|
|
275
|
+
const membership = httpMembership(environment.framework)
|
|
276
|
+
// Zerops is where a Bun application runs, and a namespace there is one private network (ADR-0022 rule 5).
|
|
277
|
+
const files = openFileStorage(environment.framework)
|
|
278
|
+
const workloadTokens = workloadTokensOf(environment.framework)
|
|
279
|
+
const served = boot(app, {
|
|
280
|
+
environment,
|
|
281
|
+
iam,
|
|
282
|
+
assets: diskAssets(process.cwd()),
|
|
283
|
+
database,
|
|
284
|
+
files,
|
|
285
|
+
membership,
|
|
286
|
+
workloadTokens,
|
|
287
|
+
requireProxySecret: true,
|
|
288
|
+
})
|
|
289
|
+
// The work a request leaves running (a failure's report, a job a form kicked) is drained before the database closes.
|
|
290
|
+
const handler = createBunHandler({ fetch: (request, _env: undefined, exec) => served.fetch(request, exec) }, undefined, {
|
|
291
|
+
onBackgroundError: () => console.error('fabrika: a background task of a request failed'),
|
|
292
|
+
})
|
|
293
|
+
const server = Bun.serve({ port, hostname: '0.0.0.0', fetch: (request) => handler.fetch(request) })
|
|
294
|
+
// The crontab may never fire; the serving process notices a job that stopped succeeding without being invoked.
|
|
295
|
+
const check = () => void served.jobs.check()
|
|
296
|
+
const checks = served.jobs.names.length === 0 ? undefined : setInterval(check, JOB_CHECK_INTERVAL_MS)
|
|
297
|
+
if (checks !== undefined) {
|
|
298
|
+
check()
|
|
299
|
+
}
|
|
300
|
+
const stop = async (): Promise<void> => {
|
|
301
|
+
clearInterval(checks)
|
|
302
|
+
await server.stop(false)
|
|
303
|
+
await handler.drain()
|
|
304
|
+
await database?.close()
|
|
305
|
+
}
|
|
306
|
+
process.once('SIGTERM', () => void stop().then(() => process.exit(0)))
|
|
307
|
+
return { port: server.port ?? port, stop }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The application module at `path`, imported when the loader runs; its default export must be what `serve` returned. */
|
|
311
|
+
export function implementationAt(path: string): ImplementationLoader {
|
|
312
|
+
return async () => {
|
|
313
|
+
const module: unknown = await import(path)
|
|
314
|
+
const value = typeof module === 'object' && module !== null ? Reflect.get(module, 'default') : undefined
|
|
315
|
+
if (!isContractImplementation(value)) {
|
|
316
|
+
throw new BootError([`${path} does not default-export serve(contract, { … })`])
|
|
317
|
+
}
|
|
318
|
+
return { default: value }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* One run of the job `name` (`fabrika job run <name>`, the crontab's command): the framework's variables are captured
|
|
324
|
+
* and scrubbed before the application loads, exactly as `serveContract` does, then `boot` binds the job and the run
|
|
325
|
+
* takes its lease. The connection closes when the run ends. Given the provisioning credential, it re-runs the command
|
|
326
|
+
* as the runtime role, as `serveContract` does.
|
|
327
|
+
*/
|
|
328
|
+
export async function runContractJob(
|
|
329
|
+
load: ImplementationLoader,
|
|
330
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
331
|
+
name: string,
|
|
332
|
+
options: JobRunOptions = {},
|
|
333
|
+
): Promise<JobRunResult> {
|
|
334
|
+
if (holdsProvisioningCredential(env)) {
|
|
335
|
+
return relaunchAsRuntime(env)
|
|
336
|
+
}
|
|
337
|
+
const environment = captureEnvironment(env)
|
|
338
|
+
const data = await loadDataLayer()
|
|
339
|
+
const { default: app } = await load()
|
|
340
|
+
const iam = issuerJwks(environment.framework['FABRIKA_IAM_ISSUER'])
|
|
341
|
+
const database = isScopedDatabase(app.database) ? openApplicationDatabase(data, environment.framework) : undefined
|
|
342
|
+
try {
|
|
343
|
+
const workloadTokens = workloadTokensOf(environment.framework)
|
|
344
|
+
const membership = httpMembership(environment.framework)
|
|
345
|
+
const files = openFileStorage(environment.framework)
|
|
346
|
+
return await boot(app, {
|
|
347
|
+
environment,
|
|
348
|
+
iam,
|
|
349
|
+
assets: diskAssets(process.cwd()),
|
|
350
|
+
database,
|
|
351
|
+
files,
|
|
352
|
+
membership,
|
|
353
|
+
workloadTokens,
|
|
354
|
+
requireProxySecret: true,
|
|
355
|
+
// ClamAV on PATH, as the provider's build installs it for a `scan: true` collection; missing, the scan fails closed.
|
|
356
|
+
scanner: clamscanScanner(),
|
|
357
|
+
})
|
|
358
|
+
.jobs.run(name, options)
|
|
359
|
+
} finally {
|
|
360
|
+
await database?.close()
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The caller document every SPA mount serves at `<prefix>/__fabrika-me.json`: who the page's person is and what the
|
|
2
|
+
// token says they hold. A page shows and hides by it; it decides nothing, because every endpoint checks again.
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod/v4'
|
|
5
|
+
|
|
6
|
+
const ScopedActions = z.strictObject({ type: z.string(), value: z.string(), actions: z.array(z.string()) })
|
|
7
|
+
|
|
8
|
+
const PageCallerSchema = z.strictObject({
|
|
9
|
+
label: z.string(),
|
|
10
|
+
email: z.string(),
|
|
11
|
+
actions: z.strictObject({
|
|
12
|
+
/** The actions held as a global grant, so in every value. */
|
|
13
|
+
app: z.array(z.string()),
|
|
14
|
+
/** Each scope value the person holds something in, with those actions; sorted by dimension, then value. */
|
|
15
|
+
scopes: z.array(ScopedActions),
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export type PageCaller = z.infer<typeof PageCallerSchema>
|
|
20
|
+
|
|
21
|
+
/** The caller document as the framework answers it; throws on any other value. */
|
|
22
|
+
export function parsePageCaller(value: unknown): PageCaller {
|
|
23
|
+
return PageCallerSchema.parse(value)
|
|
24
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// The typed client of one contract mount: `api.notes.remove({ id })` POSTs the input to `/api/notes.remove`, the
|
|
2
|
+
// path `compileContract` gives that procedure. Built from the contract value, so it can call only declared
|
|
3
|
+
// procedures, and it checks each answer against the procedure's output schema before handing it out. An attachment
|
|
4
|
+
// procedure answers a file, which `download(api.<procedure>, input)` fetches and saves.
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod/v4'
|
|
7
|
+
import { type AttachmentBrand, attachmentOf } from '../contract/attachment'
|
|
8
|
+
import { isProcedure, type ProcedureTreeDeclaration, type ProcedureType, SURFACE_SECTIONS } from '../contract/declaration'
|
|
9
|
+
import { joinPath, LINK_API_SEGMENT } from '../contract/defaults'
|
|
10
|
+
import type { AnyContract, InputSchema, Procedure, Schema, Surface } from '../contract/define'
|
|
11
|
+
import { type FileClient, type FileTransport, type LinkCollections, type LinkFileClient, linkFiles, sessionFiles } from './files'
|
|
12
|
+
import { type BounceConfig, type BounceOption, exchange, exchangeFile, resolveBounce, RpcError, type RpcFetch } from './transport'
|
|
13
|
+
|
|
14
|
+
type SurfaceOf<C> = C extends { readonly surface: infer S } ? S : never
|
|
15
|
+
|
|
16
|
+
/** The mounts a client calls: RPC mounts and link mounts. */
|
|
17
|
+
export type ClientMount<C> =
|
|
18
|
+
& { [M in keyof SurfaceOf<C>]: SurfaceOf<C>[M] extends { readonly kind: 'rpc' | 'link' } ? M : never }[keyof SurfaceOf<C>]
|
|
19
|
+
& string
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One procedure: its input as the caller writes it (defaults optional), its output as parsed, and the output schema
|
|
23
|
+
* it parses with (`@fabrika/app/react` reads a cached answer back through it).
|
|
24
|
+
*/
|
|
25
|
+
export type ProcedureCall<I, O extends Schema> =
|
|
26
|
+
& ([I] extends [InputSchema] ? (input: z.input<I>) => Promise<z.output<O>> : (input?: Record<never, never>) => Promise<z.output<O>>)
|
|
27
|
+
& { readonly output: O }
|
|
28
|
+
|
|
29
|
+
export type ClientTree<T> = {
|
|
30
|
+
readonly [K in keyof T]: T[K] extends Procedure<'query' | 'mutation', infer I, infer O, unknown>
|
|
31
|
+
? O extends AttachmentBrand ? AttachmentProcedure<I> : ProcedureCall<I, O>
|
|
32
|
+
: ClientTree<T[K]>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type FilesOf<C> = SurfaceOf<C> extends { readonly files: infer F } ? F : Record<never, never>
|
|
36
|
+
|
|
37
|
+
/** `files.<name>` of every collection, for the signed-in callers of an RPC mount that admits people. */
|
|
38
|
+
type SessionFiles<C, Callers> = Callers extends readonly (infer Caller)[] ? 'human' extends Caller ? [keyof FilesOf<C>] extends [never] ? unknown
|
|
39
|
+
: { readonly files: { readonly [N in keyof FilesOf<C>]: FileClient } }
|
|
40
|
+
: unknown
|
|
41
|
+
: unknown
|
|
42
|
+
|
|
43
|
+
/** `files.<name>` of every `link: true` collection, for a link mount's holders. */
|
|
44
|
+
type HolderFiles<C, M> = [LinkCollections<FilesOf<C>, M>] extends [never] ? unknown
|
|
45
|
+
: { readonly files: { readonly [N in LinkCollections<FilesOf<C>, M>]: LinkFileClient } }
|
|
46
|
+
|
|
47
|
+
type MountOf<C, M> = SurfaceOf<C>[M & keyof SurfaceOf<C>]
|
|
48
|
+
|
|
49
|
+
/** One function per procedure, an `AttachmentProcedure` per attachment, and `files` where the mount's callers reach collections. */
|
|
50
|
+
export type ContractClient<C, M> = MountOf<C, M> extends { readonly kind: 'link'; readonly procedures: infer T } ? ClientTree<T> & HolderFiles<C, M>
|
|
51
|
+
: MountOf<C, M> extends { readonly callers: infer Callers; readonly procedures: infer T } ? ClientTree<T> & SessionFiles<C, Callers>
|
|
52
|
+
: never
|
|
53
|
+
|
|
54
|
+
/** The answer of a client procedure: `ResultOf<typeof api.tasks.get>`; an attachment procedure answers a `File`. */
|
|
55
|
+
export type ResultOf<F> = F extends (...args: never[]) => Promise<infer R> ? R : F extends AttachmentProcedure<unknown> ? File : never
|
|
56
|
+
|
|
57
|
+
/** The input of a client procedure as the caller writes it: `InputOf<typeof api.tasks.create>`. */
|
|
58
|
+
export type InputOf<F> = F extends (...args: infer A) => Promise<unknown> ? A[0]
|
|
59
|
+
: F extends AttachmentProcedure<infer I> ? DownloadInput<I>[0]
|
|
60
|
+
: never
|
|
61
|
+
|
|
62
|
+
export interface ClientOptions {
|
|
63
|
+
/** The application's origin; procedure paths are joined to it. Defaults to the page's own origin. */
|
|
64
|
+
readonly baseUrl?: string
|
|
65
|
+
/** Override `fetch` (tests, a script). Defaults to the global `fetch`. */
|
|
66
|
+
readonly fetch?: RpcFetch
|
|
67
|
+
/**
|
|
68
|
+
* A `px_` key, sent as `Authorization: Bearer`. A request with a key carries no cookie. In a browser the key goes
|
|
69
|
+
* only to the page's own origin.
|
|
70
|
+
*/
|
|
71
|
+
readonly key?: string
|
|
72
|
+
/** On a 401 that carries a login URL, send the browser to sign in instead of throwing. */
|
|
73
|
+
readonly bounceOnAuth?: BounceOption
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A link mount's caller is the holder of its share key, which every request presents. */
|
|
77
|
+
export interface LinkClientOptions extends ClientOptions {
|
|
78
|
+
readonly key: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type OptionsFor<C, M> = SurfaceOf<C>[M & keyof SurfaceOf<C>] extends { readonly kind: 'link' } ? [options: LinkClientOptions]
|
|
82
|
+
: [options?: ClientOptions]
|
|
83
|
+
|
|
84
|
+
interface CallTarget {
|
|
85
|
+
readonly id: string
|
|
86
|
+
readonly url: string
|
|
87
|
+
readonly type: ProcedureType
|
|
88
|
+
readonly input: Schema | null
|
|
89
|
+
readonly output: Schema
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What a value of a client tree is, kept beside it so the value stays a plain function or record: a procedure's
|
|
94
|
+
* `<mount>.<path>`, its type and input schema, or a group's prefix. `@fabrika/app/react` keys its cache by it.
|
|
95
|
+
*/
|
|
96
|
+
export interface ClientNode {
|
|
97
|
+
readonly mount: string
|
|
98
|
+
/** The dotted path below the mount; `''` for the client itself. */
|
|
99
|
+
readonly path: string
|
|
100
|
+
readonly procedure: { readonly type: ProcedureType; readonly input: Schema | null } | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const clientNodes = new WeakMap<object, ClientNode>()
|
|
104
|
+
|
|
105
|
+
/** The node a value of a client tree is, or `null` for any other value. */
|
|
106
|
+
export function clientNodeOf(value: unknown): ClientNode | null {
|
|
107
|
+
return (typeof value === 'function' || (typeof value === 'object' && value !== null)) ? (clientNodes.get(value) ?? null) : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The request of one procedure call: its input as JSON, with the key as a bearer or the page's session. */
|
|
111
|
+
function requestOf(options: ClientOptions, input: unknown): () => RequestInit {
|
|
112
|
+
return () => {
|
|
113
|
+
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
114
|
+
if (options.key !== undefined) {
|
|
115
|
+
headers.set('Authorization', `Bearer ${options.key}`)
|
|
116
|
+
}
|
|
117
|
+
return { method: 'POST', credentials: options.key === undefined ? 'same-origin' : 'omit', headers, body: JSON.stringify(input ?? {}) }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function caller(target: CallTarget, at: TreeAt, options: ClientOptions, bounce: BounceConfig | null) {
|
|
122
|
+
const call = async (input?: unknown): Promise<unknown> => {
|
|
123
|
+
const result = await exchange(options.fetch ?? fetch, target.url, requestOf(options, input), bounce)
|
|
124
|
+
const parsed = z.safeParse(target.output, result)
|
|
125
|
+
if (!parsed.success) {
|
|
126
|
+
throw new RpcError({ type: 'invalid-response', message: `'${target.id}' answered a value its output schema refuses`, httpStatus: 200 })
|
|
127
|
+
}
|
|
128
|
+
return parsed.data
|
|
129
|
+
}
|
|
130
|
+
Object.defineProperty(call, 'output', { value: target.output, enumerable: true })
|
|
131
|
+
clientNodes.set(call, { mount: at.mount, path: at.path, procedure: { type: target.type, input: target.input } })
|
|
132
|
+
return call
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface TreeAt {
|
|
136
|
+
readonly mount: string
|
|
137
|
+
/** The origin the paths are joined to, without a trailing `/`. */
|
|
138
|
+
readonly origin: string
|
|
139
|
+
readonly base: string
|
|
140
|
+
readonly path: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function clientTree(
|
|
144
|
+
tree: ProcedureTreeDeclaration<Schema, unknown>,
|
|
145
|
+
at: TreeAt,
|
|
146
|
+
make: (target: CallTarget, at: TreeAt) => unknown,
|
|
147
|
+
): Record<string, unknown> {
|
|
148
|
+
const client: Record<string, unknown> = {}
|
|
149
|
+
for (const [name, node] of Object.entries(tree)) {
|
|
150
|
+
const path = at.path === '' ? name : `${at.path}.${name}`
|
|
151
|
+
client[name] = isProcedure(node)
|
|
152
|
+
? make({ id: `${at.mount}.${path}`, url: `${at.origin}${joinPath(at.base, path)}`, type: node.type, input: node.input, output: node.output }, {
|
|
153
|
+
...at,
|
|
154
|
+
path,
|
|
155
|
+
})
|
|
156
|
+
: clientTree(node, { ...at, path }, make)
|
|
157
|
+
}
|
|
158
|
+
const group = Object.freeze(client)
|
|
159
|
+
clientNodes.set(group, { mount: at.mount, path: at.path, procedure: null })
|
|
160
|
+
return group
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
declare const attachmentInput: unique symbol
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* An attachment procedure of a client: not a function, because its answer is a file. `download(api.<procedure>,
|
|
167
|
+
* input)` fetches and saves it.
|
|
168
|
+
*/
|
|
169
|
+
export interface AttachmentProcedure<I> {
|
|
170
|
+
/** `<mount>.<path>` */
|
|
171
|
+
readonly id: string
|
|
172
|
+
/** The file name the contract declares. */
|
|
173
|
+
readonly filename: string
|
|
174
|
+
/** Carries the input schema for `download`; never present at runtime. */
|
|
175
|
+
readonly [attachmentInput]?: I
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The request each attachment procedure sends, kept beside it so the value itself carries no key. */
|
|
179
|
+
const downloads = new WeakMap<object, { readonly url: string; readonly options: ClientOptions; readonly bounce: BounceConfig | null }>()
|
|
180
|
+
|
|
181
|
+
function attachmentProcedure(
|
|
182
|
+
target: CallTarget,
|
|
183
|
+
filename: string,
|
|
184
|
+
options: ClientOptions,
|
|
185
|
+
bounce: BounceConfig | null,
|
|
186
|
+
): AttachmentProcedure<unknown> {
|
|
187
|
+
const procedure = Object.freeze({ id: target.id, filename })
|
|
188
|
+
downloads.set(procedure, { url: target.url, options, bounce })
|
|
189
|
+
return procedure
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The input `download` takes: the procedure's, or none. */
|
|
193
|
+
export type DownloadInput<I> = [I] extends [InputSchema] ? [input: z.input<I>] : [input?: Record<never, never>]
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Calls an attachment procedure and saves its file: in a browser the page offers it as a download under the declared
|
|
197
|
+
* name. Answers the file, also where there is no page to save it from (a script, a test). A refusal is an `RpcError`.
|
|
198
|
+
*/
|
|
199
|
+
export async function download<I>(procedure: AttachmentProcedure<I>, ...input: DownloadInput<I>): Promise<File> {
|
|
200
|
+
const request = downloads.get(procedure)
|
|
201
|
+
if (request === undefined) {
|
|
202
|
+
throw new Error(`'${procedure.id}' is not an attachment procedure of a client`)
|
|
203
|
+
}
|
|
204
|
+
const { body } = await exchangeFile(request.options.fetch ?? fetch, request.url, requestOf(request.options, input[0]), request.bounce)
|
|
205
|
+
const file = new File([body], procedure.filename, { type: body.type })
|
|
206
|
+
saveFile(file)
|
|
207
|
+
return file
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function saveFile(file: File): void {
|
|
211
|
+
if (typeof document === 'undefined') {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
const url = URL.createObjectURL(file)
|
|
215
|
+
const link = document.createElement('a')
|
|
216
|
+
link.href = url
|
|
217
|
+
link.download = file.name
|
|
218
|
+
// A detached link downloads in every current browser; the page's document stays untouched.
|
|
219
|
+
link.click()
|
|
220
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A function for every procedure of `tree` and a group for every nested tree: every declared call exists. */
|
|
224
|
+
function coversTree(value: unknown, tree: ProcedureTreeDeclaration<Schema, unknown>): boolean {
|
|
225
|
+
if (typeof value !== 'object' || value === null) {
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
return Object.entries(tree).every(([name, node]) => {
|
|
229
|
+
const member: unknown = Reflect.get(value, name)
|
|
230
|
+
if (!isProcedure(node)) {
|
|
231
|
+
return coversTree(member, node)
|
|
232
|
+
}
|
|
233
|
+
return attachmentOf(node.output) === null
|
|
234
|
+
? typeof member === 'function' && Reflect.get(member, 'output') === node.output
|
|
235
|
+
: typeof member === 'object' && member !== null && downloads.has(member)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A client of every collection in `names` under `files`, or no `files` at all. */
|
|
240
|
+
function coversFiles(value: unknown, names: readonly string[]): boolean {
|
|
241
|
+
if (typeof value !== 'object' || value === null) {
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
const files: unknown = Reflect.get(value, 'files')
|
|
245
|
+
if (names.length === 0) {
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
return typeof files === 'object' && files !== null && names.every((name) => typeof Reflect.get(files, name) === 'object')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The page's origin when the client runs in a browser, else `null`. */
|
|
252
|
+
function pageOrigin(): string | null {
|
|
253
|
+
const location: unknown = Reflect.get(globalThis, 'location')
|
|
254
|
+
const origin: unknown = typeof location === 'object' && location !== null ? Reflect.get(location, 'origin') : undefined
|
|
255
|
+
return typeof origin === 'string' ? origin : null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The client of mount `mount` of `contract`: one function per declared procedure. A link mount's client sends its
|
|
260
|
+
* share key as a bearer, so `options.key` is required there.
|
|
261
|
+
*/
|
|
262
|
+
export function createClient<const C extends AnyContract, const M extends ClientMount<C>>(
|
|
263
|
+
contract: C,
|
|
264
|
+
mount: M,
|
|
265
|
+
...rest: OptionsFor<C, M>
|
|
266
|
+
): ContractClient<C, M> {
|
|
267
|
+
const options: ClientOptions = rest[0] ?? {}
|
|
268
|
+
const surface: Surface = contract.surface
|
|
269
|
+
const node = SURFACE_SECTIONS.some((section) => section === mount) ? undefined : surface[mount]
|
|
270
|
+
if (typeof node !== 'object' || !('kind' in node) || (node.kind !== 'rpc' && node.kind !== 'link')) {
|
|
271
|
+
throw new Error(`'${mount}' is not an RPC or link mount of the contract`)
|
|
272
|
+
}
|
|
273
|
+
if (node.kind === 'link' && (options.key === undefined || options.key === '')) {
|
|
274
|
+
throw new Error(`'${mount}' is a link mount; its client needs the share key`)
|
|
275
|
+
}
|
|
276
|
+
const base = node.kind === 'link' ? joinPath(node.prefix, LINK_API_SEGMENT) : node.prefix
|
|
277
|
+
const bounce = resolveBounce(`${contract.vocabulary.app}:${mount}`, options.bounceOnAuth)
|
|
278
|
+
const origin = (options.baseUrl ?? '').replace(/\/+$/, '')
|
|
279
|
+
const page = pageOrigin()
|
|
280
|
+
// A page's key goes only to the page's own application; a script names the application it calls.
|
|
281
|
+
if (options.key !== undefined && page !== null && origin !== '' && new URL(origin, page).origin !== page) {
|
|
282
|
+
throw new Error(`a client with a key in a browser calls only ${page}; baseUrl '${origin}' is another origin`)
|
|
283
|
+
}
|
|
284
|
+
const procedures = clientTree(node.procedures, { mount, origin, base, path: '' }, (target, at) => {
|
|
285
|
+
const mark = attachmentOf(target.output)
|
|
286
|
+
return mark === null ? caller(target, at, options, bounce) : attachmentProcedure(target, mark.declaration.filename, options, bounce)
|
|
287
|
+
})
|
|
288
|
+
const transport: FileTransport = { origin, fetch: options.fetch ?? fetch, key: options.key, bounce }
|
|
289
|
+
const collections = Object.entries(surface.files ?? {}).flatMap(([name, collection]): (readonly [string, FileClient | LinkFileClient])[] => {
|
|
290
|
+
if (node.kind === 'link') {
|
|
291
|
+
const serves = collection.link === true || (Array.isArray(collection.link) && collection.link.includes(mount))
|
|
292
|
+
return serves ? [[name, linkFiles(transport, node.prefix, mount, name)]] : []
|
|
293
|
+
}
|
|
294
|
+
return node.callers.includes('human') ? [[name, sessionFiles(transport, name)]] : []
|
|
295
|
+
})
|
|
296
|
+
if (collections.length > 0 && Object.hasOwn(node.procedures, 'files')) {
|
|
297
|
+
throw new Error(`'${mount}' has a procedure group named files, which the client of its file collections takes`)
|
|
298
|
+
}
|
|
299
|
+
const client = collections.length === 0 ? procedures : Object.freeze({ ...procedures, files: Object.freeze(Object.fromEntries(collections)) })
|
|
300
|
+
// The client with its file collections is the mount's root too: `@fabrika/app/react` keys a provider by it.
|
|
301
|
+
if (client !== procedures) clientNodes.set(client, { mount, path: '', procedure: null })
|
|
302
|
+
// The tree was built from this mount's declaration; the check ties the value to the type the contract derives.
|
|
303
|
+
const names = collections.map(([name]) => name)
|
|
304
|
+
const covers = (value: unknown): value is ContractClient<C, M> => coversTree(value, node.procedures) && coversFiles(value, names)
|
|
305
|
+
if (!covers(client)) {
|
|
306
|
+
throw new Error(`the client of '${mount}' does not cover its procedures`)
|
|
307
|
+
}
|
|
308
|
+
return client
|
|
309
|
+
}
|