@fabrika/app 0.3.3 → 0.4.1
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 +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -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 +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -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 +331 -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 +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -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 +824 -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 +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -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 +760 -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 +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -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/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -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,386 @@
|
|
|
1
|
+
// `ctx.connect`: the consumer side of a connector. A call validates its input against the vendored snapshot, posts it
|
|
2
|
+
// to the owner's export procedure with the edge key as the bearer, and answers a typed outcome. A deadline bounds the
|
|
3
|
+
// call and its retries; the answer is validated against the vendored output schema, so drift shows on the first call.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
API_KEY_PREFIX,
|
|
7
|
+
type ConnectVariables,
|
|
8
|
+
connectVariables,
|
|
9
|
+
type ContractArtifact,
|
|
10
|
+
type ExportDescription,
|
|
11
|
+
type ImportArtifact,
|
|
12
|
+
type SchemaShape,
|
|
13
|
+
} from '@fabrika/auth-core'
|
|
14
|
+
import { z } from 'zod/v4'
|
|
15
|
+
import { type ConnectSnapshot, describeSnapshot } from '../contract/connect'
|
|
16
|
+
import { CONNECT_LIMITS, CONNECT_PREFIX, exportBase, joinPath } from '../contract/defaults'
|
|
17
|
+
import { ContractError } from '../contract/problems'
|
|
18
|
+
import { declaredPath, schemaFromShape } from '../contract/schema-from-shape'
|
|
19
|
+
import { readBoundedBytes } from './bounded'
|
|
20
|
+
import { isActiveRequest, type RequestFrame } from './capability'
|
|
21
|
+
|
|
22
|
+
export type ConnectFailure = 'denied' | 'invalid_input' | 'unavailable' | 'timeout' | 'contract_violation'
|
|
23
|
+
|
|
24
|
+
/** `unavailable` and `timeout` are never reported as `denied`: "we could not ask" is not "no". */
|
|
25
|
+
export type ConnectResult<T> =
|
|
26
|
+
| { readonly outcome: 'ok'; readonly value: T }
|
|
27
|
+
| { readonly outcome: ConnectFailure; readonly message: string }
|
|
28
|
+
|
|
29
|
+
/** How a request reaches the owner's proxy: `fetch` over HTTPS, or a service binding to its proxy Worker. */
|
|
30
|
+
export type ConnectFetch = (request: Request) => Promise<Response>
|
|
31
|
+
|
|
32
|
+
/** Every import's variables and binding are named under it (`connectVariables`, `@fabrika/auth-core`). */
|
|
33
|
+
export const CONNECT_BINDING_PREFIX = 'FABRIKA_CONNECT_'
|
|
34
|
+
|
|
35
|
+
export { type ConnectVariables, connectVariables }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One procedure as the connector validates it: zod schemas built by the framework from the snapshot's described
|
|
39
|
+
* shapes, never the application's schema objects, so nothing the application changes after `boot` reaches them.
|
|
40
|
+
*/
|
|
41
|
+
interface CompiledProcedure {
|
|
42
|
+
readonly input: z.ZodType | null
|
|
43
|
+
readonly inputShape: SchemaShape | null
|
|
44
|
+
readonly output: z.ZodType
|
|
45
|
+
readonly outputShape: SchemaShape
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConnectorOptions {
|
|
49
|
+
/** The import's name in the contract, for the log line of a contract violation. */
|
|
50
|
+
readonly name: string
|
|
51
|
+
readonly export: string
|
|
52
|
+
readonly version: number
|
|
53
|
+
readonly origin: string
|
|
54
|
+
readonly key: string
|
|
55
|
+
readonly fetch: ConnectFetch
|
|
56
|
+
readonly timeoutMs: number
|
|
57
|
+
/** The imported procedures of the checked description, by path. */
|
|
58
|
+
readonly description: ExportDescription
|
|
59
|
+
readonly procedures: readonly string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Attempt = ConnectResult<unknown> & { readonly retry: boolean }
|
|
63
|
+
|
|
64
|
+
function failure(outcome: ConnectFailure, message: string, retry = false): Attempt {
|
|
65
|
+
return { outcome, message, retry }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `application/json`, with or without parameters, is the only media type an export answers in. */
|
|
69
|
+
export function isJsonMediaType(header: string | null): boolean {
|
|
70
|
+
return header?.split(';')[0]?.trim().toLowerCase() === 'application/json'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function discard(response: Response): Promise<void> {
|
|
74
|
+
await response.body?.cancel().catch(() => {})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One import's calls. A message names only what the framework wrote; the edge key is cut out of it regardless. */
|
|
78
|
+
export class Connector {
|
|
79
|
+
readonly #options: ConnectorOptions
|
|
80
|
+
readonly #procedures: ReadonlyMap<string, CompiledProcedure>
|
|
81
|
+
|
|
82
|
+
constructor(options: ConnectorOptions) {
|
|
83
|
+
this.#options = options
|
|
84
|
+
const procedures = new Map<string, CompiledProcedure>()
|
|
85
|
+
for (const path of options.procedures) {
|
|
86
|
+
const described = Object.hasOwn(options.description.procedures, path) ? options.description.procedures[path] : undefined
|
|
87
|
+
if (described !== undefined) {
|
|
88
|
+
procedures.set(path, {
|
|
89
|
+
input: described.input === null ? null : schemaFromShape(described.input),
|
|
90
|
+
inputShape: described.input,
|
|
91
|
+
output: schemaFromShape(described.output),
|
|
92
|
+
outputShape: described.output,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.#procedures = procedures
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async call(path: string, input: unknown): Promise<ConnectResult<unknown>> {
|
|
100
|
+
const procedure = this.#procedures.get(path)
|
|
101
|
+
if (procedure === undefined) {
|
|
102
|
+
return { outcome: 'invalid_input', message: 'not an imported procedure' }
|
|
103
|
+
}
|
|
104
|
+
const parsed = procedure.input === null ? null : z.safeParse(procedure.input, input ?? {})
|
|
105
|
+
if (parsed !== null && !parsed.success) {
|
|
106
|
+
const issue = parsed.error.issues[0]
|
|
107
|
+
const at = procedure.inputShape === null || issue === undefined ? '(root)' : declaredPath(procedure.inputShape, issue.path)
|
|
108
|
+
return { outcome: 'invalid_input', message: this.#redact(`the input breaks the vendored input schema at ${at} (${issue?.code ?? 'invalid'})`) }
|
|
109
|
+
}
|
|
110
|
+
const body = JSON.stringify(parsed === null ? {} : parsed.data)
|
|
111
|
+
const deadline = Date.now() + this.#options.timeoutMs
|
|
112
|
+
let last: Attempt = failure('timeout', `no answer within ${this.#options.timeoutMs} ms`)
|
|
113
|
+
for (let attempt = 0; attempt <= CONNECT_LIMITS.retries; attempt++) {
|
|
114
|
+
const remaining = deadline - Date.now()
|
|
115
|
+
if (remaining <= 0) {
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
// The remaining time is shared by the attempts still allowed, so a hung first try leaves room for a retry.
|
|
119
|
+
last = await this.#attempt(path, body, procedure, Math.max(1, Math.floor(remaining / (CONNECT_LIMITS.retries + 1 - attempt))))
|
|
120
|
+
if (!last.retry) {
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const message = this.#redact(last.outcome === 'ok' ? '' : last.message)
|
|
125
|
+
if (last.outcome === 'contract_violation') {
|
|
126
|
+
console.error(`fabrika: connect '${this.#options.name}.${path}': contract_violation: ${message}`)
|
|
127
|
+
}
|
|
128
|
+
return last.outcome === 'ok' ? { outcome: 'ok', value: last.value } : { outcome: last.outcome, message }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Defence in depth: no framework message quotes the answer, and the edge key is cut out of every one anyway. */
|
|
132
|
+
#redact(message: string): string {
|
|
133
|
+
return message.replaceAll(this.#options.key, '[edge key]')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One request under its own deadline; the deadline aborts the fetch and cancels the body reader. */
|
|
137
|
+
async #attempt(path: string, body: string, procedure: CompiledProcedure, timeoutMs: number): Promise<Attempt> {
|
|
138
|
+
const { origin, key } = this.#options
|
|
139
|
+
const url = new URL(joinPath(CONNECT_PREFIX, `${exportBase(this.#options.export, this.#options.version)}.${path}`), origin)
|
|
140
|
+
const controller = new AbortController()
|
|
141
|
+
const timedOut = failure('timeout', `no answer within ${timeoutMs} ms`, true)
|
|
142
|
+
const expired = new Promise<Attempt>((resolve) => controller.signal.addEventListener('abort', () => resolve(timedOut), { once: true }))
|
|
143
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
144
|
+
try {
|
|
145
|
+
const request = new Request(url, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
148
|
+
body,
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
redirect: 'manual',
|
|
151
|
+
})
|
|
152
|
+
const exchanged = this.#exchange(request, procedure, controller.signal).catch(() =>
|
|
153
|
+
controller.signal.aborted ? timedOut : failure('unavailable', 'the owner could not be reached', true)
|
|
154
|
+
)
|
|
155
|
+
return await Promise.race([exchanged, expired])
|
|
156
|
+
} finally {
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
// Whatever is still reading or waiting after the answer — or the deadline — stops here.
|
|
159
|
+
controller.abort()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #exchange(request: Request, procedure: CompiledProcedure, signal: AbortSignal): Promise<Attempt> {
|
|
164
|
+
const response = await this.#options.fetch(request)
|
|
165
|
+
const status = response.status
|
|
166
|
+
if (signal.aborted || status !== 200) {
|
|
167
|
+
await discard(response)
|
|
168
|
+
}
|
|
169
|
+
if (signal.aborted) {
|
|
170
|
+
return failure('timeout', 'no answer before the deadline', true)
|
|
171
|
+
}
|
|
172
|
+
if (status === 200) {
|
|
173
|
+
return await this.#answer(response, procedure, signal)
|
|
174
|
+
}
|
|
175
|
+
if (status >= 300 && status < 400) {
|
|
176
|
+
return failure('unavailable', `the owner answered ${status}, a redirect, which a connector never follows`)
|
|
177
|
+
}
|
|
178
|
+
if (status === 400) {
|
|
179
|
+
return failure('invalid_input', 'the owner refused the input')
|
|
180
|
+
}
|
|
181
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
182
|
+
return failure('denied', `the owner answered ${status}`)
|
|
183
|
+
}
|
|
184
|
+
if (status === 408 || status === 429 || status >= 500) {
|
|
185
|
+
return failure('unavailable', `the owner answered ${status}`, true)
|
|
186
|
+
}
|
|
187
|
+
return failure('contract_violation', `the owner answered ${status}, which no export procedure answers`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async #answer(response: Response, procedure: CompiledProcedure, signal: AbortSignal): Promise<Attempt> {
|
|
191
|
+
if (!isJsonMediaType(response.headers.get('Content-Type'))) {
|
|
192
|
+
await discard(response)
|
|
193
|
+
return failure('contract_violation', 'the answer is not application/json')
|
|
194
|
+
}
|
|
195
|
+
const bytes = await readBoundedBytes(response.body, {
|
|
196
|
+
maxBytes: CONNECT_LIMITS.maxAnswerBytes,
|
|
197
|
+
declaredLength: response.headers.get('Content-Length'),
|
|
198
|
+
signal,
|
|
199
|
+
})
|
|
200
|
+
if (bytes === null) {
|
|
201
|
+
return failure('contract_violation', `the answer is larger than ${CONNECT_LIMITS.maxAnswerBytes} bytes`)
|
|
202
|
+
}
|
|
203
|
+
let answer: unknown
|
|
204
|
+
try {
|
|
205
|
+
answer = JSON.parse(new TextDecoder().decode(bytes))
|
|
206
|
+
} catch {
|
|
207
|
+
return failure('contract_violation', 'the answer is not JSON')
|
|
208
|
+
}
|
|
209
|
+
const result = typeof answer === 'object' && answer !== null && 'result' in answer ? answer.result : undefined
|
|
210
|
+
const parsed = z.safeParse(procedure.output, result)
|
|
211
|
+
if (!parsed.success) {
|
|
212
|
+
const issue = parsed.error.issues[0]
|
|
213
|
+
const at = issue === undefined ? '(root)' : declaredPath(procedure.outputShape, issue.path)
|
|
214
|
+
return failure('contract_violation', `the answer breaks the vendored output schema at ${at} (${issue?.code ?? 'invalid'})`)
|
|
215
|
+
}
|
|
216
|
+
return { outcome: 'ok', value: parsed.data, retry: false }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `ctx.connect` of one request: each import its endpoint's `usedBy` names, with the procedures the contract imports. */
|
|
221
|
+
export type ConnectContextValue = Readonly<Record<string, Readonly<Record<string, (input?: unknown) => Promise<ConnectResult<unknown>>>>>>
|
|
222
|
+
|
|
223
|
+
/** One import as an endpoint may use it. */
|
|
224
|
+
export interface ImportWire {
|
|
225
|
+
readonly name: string
|
|
226
|
+
readonly connector: Connector
|
|
227
|
+
readonly procedures: readonly string[]
|
|
228
|
+
readonly usedBy: ReadonlySet<string>
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const REFUSED: ConnectResult<never> = Object.freeze({
|
|
232
|
+
outcome: 'denied',
|
|
233
|
+
message: 'ctx.connect works only inside the request that received it, in a handler the import names in usedBy',
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The `ctx.connect` of one request, bound to its frame. A method kept past its request, called from another request,
|
|
238
|
+
* from an export's (read-only) request or from an endpoint the import does not name is `denied`, and sends nothing.
|
|
239
|
+
*/
|
|
240
|
+
export function connectContext(imports: readonly ImportWire[], frame: RequestFrame): ConnectContextValue {
|
|
241
|
+
const call = (wire: ImportWire, path: string, input: unknown): Promise<ConnectResult<unknown>> => {
|
|
242
|
+
const endpoint = frame.endpoint
|
|
243
|
+
if (!isActiveRequest(frame) || frame.ended || frame.readOnly || endpoint === null || !wire.usedBy.has(endpoint)) {
|
|
244
|
+
return Promise.resolve(REFUSED)
|
|
245
|
+
}
|
|
246
|
+
return wire.connector.call(path, input)
|
|
247
|
+
}
|
|
248
|
+
return Object.freeze(Object.fromEntries(imports.map((wire) => [
|
|
249
|
+
wire.name,
|
|
250
|
+
Object.freeze(Object.fromEntries(wire.procedures.map((path) => [path, (input?: unknown) => call(wire, path, input)]))),
|
|
251
|
+
])))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface ImportWiring {
|
|
255
|
+
readonly artifact: ContractArtifact
|
|
256
|
+
readonly snapshots: Readonly<Record<string, ConnectSnapshot>>
|
|
257
|
+
/** The captured `FABRIKA_*` variables, which carry each import's URL and edge key. */
|
|
258
|
+
readonly framework: Readonly<Record<string, string>>
|
|
259
|
+
/** Cloudflare service bindings to owners' proxy Workers, by binding name; any other import uses `fallback`. */
|
|
260
|
+
readonly bindings: Readonly<Record<string, ConnectFetch>>
|
|
261
|
+
readonly fallback: ConnectFetch
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The imports each endpoint may use. Each import must come with the snapshot its digest names, unedited, and with its
|
|
266
|
+
* owner's URL and edge key; otherwise `problems` says why. What a connector validates against is compiled here, once,
|
|
267
|
+
* from the checked description, before the application's `deps` run.
|
|
268
|
+
*/
|
|
269
|
+
export function wireImports(wiring: ImportWiring, problems: string[]): ReadonlyMap<string, readonly ImportWire[]> {
|
|
270
|
+
const { artifact, snapshots, framework } = wiring
|
|
271
|
+
const byEndpoint = new Map<string, ImportWire[]>()
|
|
272
|
+
const bindings = new Map<string, string>()
|
|
273
|
+
for (const name of Object.keys(snapshots)) {
|
|
274
|
+
if (!Object.hasOwn(artifact.imports, name)) {
|
|
275
|
+
problems.push(`a snapshot is passed as '${name}', and the contract imports nothing of that name`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const [name, declared] of Object.entries(artifact.imports)) {
|
|
279
|
+
const where = `import '${name}'`
|
|
280
|
+
const snapshot = Object.hasOwn(snapshots, name) ? snapshots[name] : undefined
|
|
281
|
+
if (snapshot === undefined) {
|
|
282
|
+
problems.push(
|
|
283
|
+
`${where} has no snapshot; pass it in defineApp(vocabulary, tables, { imports: { ${name}: { snapshot, procedures } } }), from fabrika/connect/`,
|
|
284
|
+
)
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
const description = checkedDescription(where, declared, snapshot, problems)
|
|
288
|
+
if (description === null) {
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
const variables = connectVariables(name)
|
|
292
|
+
const other = bindings.get(variables.binding)
|
|
293
|
+
if (other !== undefined) {
|
|
294
|
+
problems.push(`${where} and import '${other}' both read ${variables.binding}_*; rename one of them`)
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
bindings.set(variables.binding, name)
|
|
298
|
+
const origin = readOrigin(variables.url, framework[variables.url], problems)
|
|
299
|
+
const key = framework[variables.key] ?? ''
|
|
300
|
+
if (!key.startsWith(API_KEY_PREFIX)) {
|
|
301
|
+
problems.push(`${variables.key} is not set to a ${API_KEY_PREFIX} key; the platform writes the key the owner's proxy admits ${where} with`)
|
|
302
|
+
}
|
|
303
|
+
if (origin === null || !key.startsWith(API_KEY_PREFIX)) {
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
const procedures = Object.freeze([...declared.procedures])
|
|
307
|
+
const wire: ImportWire = Object.freeze({
|
|
308
|
+
name,
|
|
309
|
+
procedures,
|
|
310
|
+
usedBy: new Set(declared.usedBy),
|
|
311
|
+
connector: new Connector({
|
|
312
|
+
name,
|
|
313
|
+
export: declared.export,
|
|
314
|
+
version: declared.version,
|
|
315
|
+
origin,
|
|
316
|
+
key,
|
|
317
|
+
fetch: Object.hasOwn(wiring.bindings, variables.binding) ? (wiring.bindings[variables.binding] ?? wiring.fallback) : wiring.fallback,
|
|
318
|
+
timeoutMs: declared.timeoutMs ?? CONNECT_LIMITS.defaultTimeoutMs,
|
|
319
|
+
description,
|
|
320
|
+
procedures,
|
|
321
|
+
}),
|
|
322
|
+
})
|
|
323
|
+
for (const id of declared.usedBy) {
|
|
324
|
+
byEndpoint.set(id, [...(byEndpoint.get(id) ?? []), wire])
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return byEndpoint
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function deepFreeze<T>(value: T): T {
|
|
331
|
+
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
|
|
332
|
+
Object.freeze(value)
|
|
333
|
+
for (const child of Object.values(value)) {
|
|
334
|
+
deepFreeze(child)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return value
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The snapshot's description, deep-frozen and independent of the snapshot object, when it is the import's. */
|
|
341
|
+
function checkedDescription(where: string, declared: ImportArtifact, snapshot: ConnectSnapshot, problems: string[]): ExportDescription | null {
|
|
342
|
+
const named = `${snapshot.app}/${snapshot.export}@${snapshot.version}`
|
|
343
|
+
if (snapshot.app !== declared.app || snapshot.export !== declared.export || snapshot.version !== declared.version) {
|
|
344
|
+
problems.push(`${where} imports ${declared.app}/${declared.export}@${declared.version}, and its snapshot is ${named}`)
|
|
345
|
+
return null
|
|
346
|
+
}
|
|
347
|
+
if (snapshot.digest !== declared.digest) {
|
|
348
|
+
problems.push(`${where} names digest ${declared.digest}, and its snapshot is ${snapshot.digest}; import the digest from the snapshot`)
|
|
349
|
+
return null
|
|
350
|
+
}
|
|
351
|
+
let description: ExportDescription
|
|
352
|
+
try {
|
|
353
|
+
description = describeSnapshot(snapshot)
|
|
354
|
+
} catch (error) {
|
|
355
|
+
problems.push(...(error instanceof ContractError ? error.problems : [`the snapshot ${named} does not describe`]))
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
if (description.digest !== declared.digest) {
|
|
359
|
+
problems.push(`the snapshot ${named} was edited after fabrika connect wrote it; run fabrika connect pull`)
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
const missing = declared.procedures.filter((path) => !Object.hasOwn(description.procedures, path))
|
|
363
|
+
if (missing.length > 0) {
|
|
364
|
+
problems.push(`${where} imports ${missing.join(', ')}, which ${named} does not export`)
|
|
365
|
+
return null
|
|
366
|
+
}
|
|
367
|
+
return deepFreeze(description)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The owner's origin; the value is never quoted, since an operator may paste a URL with credentials in it. */
|
|
371
|
+
function readOrigin(name: string, raw: string | undefined, problems: string[]): string | null {
|
|
372
|
+
if (raw === undefined || raw.trim() === '') {
|
|
373
|
+
problems.push(`${name} is not set; the platform writes the origin of the owner's proxy`)
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const url = new URL(raw.trim())
|
|
378
|
+
if ((url.protocol === 'https:' || url.protocol === 'http:') && url.username === '' && url.password === '') {
|
|
379
|
+
return url.origin
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// Reported below.
|
|
383
|
+
}
|
|
384
|
+
problems.push(`${name} is not an http(s) origin without credentials`)
|
|
385
|
+
return null
|
|
386
|
+
}
|