@formancy/data-server 0.0.0-stage → 0.1.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.
@@ -0,0 +1,3328 @@
1
+ import { t as resolveSecret } from "./secrets-DhKHG-Y9.mjs";
2
+ import rateLimit from "@fastify/rate-limit";
3
+ import { CROSS_ORIGIN_REQUEST_HEADERS, DEFAULT_LOOKUP_DEADLINE_MS, GENERATED_SPEC_VERSION, MAX_LOOKUP_DEADLINE_MS, NOTHING_LEFT, READ_REFUSED, WRITE_ID_HEADER, applyPresentation, authorizeOperation, bindingsVersionProblem, buildLookupConfig, checkSubmittedFields, createSnapshot, deleteEffects, deleteProblems, describeReassigned, diffSnapshots, driftRefusal, findObject, formMenu, generateForm, grantedOperations, grantsOnKey, intendedRecord, isDatabaseKind, listColumns, listable, lookupRowFilter, menuForm, planCreate, planDelete, planList, planRead, planUpdate, presentationShapeProblems, readableFields, reassignedKeys, rebasePresentation, recordPage, recordToken, rejectedSelection, rowFilterColumnProblem, runtimeOperations, scopeRowFilters, throughProblems, toFormAnswers, validateListQuery, validateLookupQuery, validatePolicy } from "@formancy/data-core";
4
+ import Fastify, { LogController } from "fastify";
5
+ import { createHash, createHmac, randomUUID } from "node:crypto";
6
+ import { link, mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { canonicalize } from "@formancy/spec";
9
+ import { validateSchema } from "@formancy/spec/validate";
10
+ import { connectPostgres, createPostgresAdapter, createPostgresLookups, createPostgresRecords } from "@formancy/data-postgres";
11
+ import { connectSqlServer, createSqlServerAdapter, createSqlServerLookups, createSqlServerRecords } from "@formancy/data-sqlserver";
12
+ //#region src/config-store.ts
13
+ /** What `read` throws for a version that exists and does not parse. Its message names the file. */
14
+ var UnparsableVersionError = class extends Error {
15
+ name = "UnparsableVersionError";
16
+ };
17
+ const CONFIGURATION_ID = new RegExp(`^[a-z0-9][a-z0-9._-]{0,${String(127)}}$`);
18
+ const VERSION_FILE = /^([1-9][0-9]*)\.json$/;
19
+ function assertId(id) {
20
+ if (!CONFIGURATION_ID.test(id)) throw new Error(`${JSON.stringify(id)} is not a configuration id`);
21
+ }
22
+ function isCode(error, code) {
23
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
24
+ }
25
+ /**
26
+ * A configuration store on a directory — a persistent volume in a container.
27
+ *
28
+ * Layout: `<root>/<id>/<version>.json`, pretty-printed, one file per version,
29
+ * so an export is a copy of the directory and a review is a diff.
30
+ *
31
+ * **Compare-and-swap without a lock.** A version is written to a temporary file,
32
+ * flushed, and then hard-linked to its final name. `link` fails if the name
33
+ * exists, atomically, so of two writers that both read version 3 as the newest,
34
+ * exactly one creates 4.json and the other gets a conflict. Nobody ever sees a
35
+ * half-written version, because the final name only appears once the bytes are
36
+ * on disk. A crash leaves a temporary file that nothing reads.
37
+ *
38
+ * On a local filesystem — ext4, xfs, NTFS — `link` is atomic. Over NFS it is
39
+ * atomic on the server but the error a client sees after a retransmission can
40
+ * be wrong, which is why the plan limits this store to a single-instance pilot.
41
+ */
42
+ function createFileConfigurationStore(root) {
43
+ /**
44
+ * Whether the root does not exist yet — a fresh volume, an empty store — and
45
+ * a loud failure if it exists and is not a directory.
46
+ *
47
+ * Asked whenever something under the root is missing, because "missing"
48
+ * means two different things. On Windows a path through a FILE reports
49
+ * ENOENT, not ENOTDIR, so a volume path that names a file would otherwise
50
+ * make every published form look unpublished.
51
+ */
52
+ async function rootIsMissing() {
53
+ try {
54
+ if (!(await stat(root)).isDirectory()) throw new Error(`${root} is not a directory; the configuration store needs one`);
55
+ return false;
56
+ } catch (error) {
57
+ if (isCode(error, "ENOENT")) return true;
58
+ throw error;
59
+ }
60
+ }
61
+ async function versions(id) {
62
+ assertId(id);
63
+ let names;
64
+ try {
65
+ names = await readdir(join(root, id));
66
+ } catch (error) {
67
+ if (!isCode(error, "ENOENT") && !isCode(error, "ENOTDIR")) throw error;
68
+ await rootIsMissing();
69
+ return [];
70
+ }
71
+ const found = [];
72
+ for (const name of names) {
73
+ const match = VERSION_FILE.exec(name);
74
+ if (match?.[1] !== void 0) found.push(Number(match[1]));
75
+ }
76
+ return found.sort((a, b) => a - b);
77
+ }
78
+ async function latest(id) {
79
+ return (await versions(id)).at(-1) ?? null;
80
+ }
81
+ return {
82
+ latest,
83
+ versions,
84
+ async read(id, version) {
85
+ assertId(id);
86
+ if (!Number.isSafeInteger(version) || version < 1) throw new Error(`${String(version)} is not a version`);
87
+ const path = join(root, id, `${String(version)}.json`);
88
+ let text;
89
+ try {
90
+ text = await readFile(path, "utf8");
91
+ } catch (error) {
92
+ if (!isCode(error, "ENOENT") && !isCode(error, "ENOTDIR")) throw error;
93
+ await rootIsMissing();
94
+ return;
95
+ }
96
+ try {
97
+ return JSON.parse(text);
98
+ } catch {
99
+ throw new UnparsableVersionError(`${path} is not valid JSON; a published version must never be edited`);
100
+ }
101
+ },
102
+ async publish(id, expectedBase, value) {
103
+ const current = await latest(id);
104
+ if (current !== expectedBase) return {
105
+ ok: false,
106
+ reason: "conflict",
107
+ current
108
+ };
109
+ const directory = join(root, id);
110
+ await mkdir(directory, { recursive: true });
111
+ const next = (current ?? 0) + 1;
112
+ const temporary = join(directory, `.publishing-${randomUUID()}`);
113
+ const handle = await open(temporary, "wx");
114
+ try {
115
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
116
+ await handle.sync();
117
+ } finally {
118
+ await handle.close();
119
+ }
120
+ try {
121
+ await link(temporary, join(directory, `${String(next)}.json`));
122
+ return {
123
+ ok: true,
124
+ version: next
125
+ };
126
+ } catch (error) {
127
+ if (isCode(error, "EEXIST")) return {
128
+ ok: false,
129
+ reason: "conflict",
130
+ current: await latest(id)
131
+ };
132
+ throw error;
133
+ } finally {
134
+ await rm(temporary, { force: true });
135
+ }
136
+ },
137
+ async list() {
138
+ if (await rootIsMissing()) return [];
139
+ const names = await readdir(root);
140
+ const ids = [];
141
+ for (const name of names) if (CONFIGURATION_ID.test(name) && await latest(name) !== null) ids.push(name);
142
+ return ids.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
143
+ }
144
+ };
145
+ }
146
+ //#endregion
147
+ //#region src/audit.ts
148
+ /** Events as structured log lines, one per request. */
149
+ function logAuditSink(log) {
150
+ return (event) => {
151
+ log.info({ audit: event }, "audit");
152
+ };
153
+ }
154
+ /**
155
+ * A form id as the trail records it, on either plane: the text addressed when
156
+ * it has the store's shape for a form id, otherwise `null`. A path parameter
157
+ * is anything a caller types, token or no token, up to the router's limit; a
158
+ * form id's shape holds no `:`, `;`, `@`, `=` or `/`, so no connection
159
+ * string, and names something the store could hold (0033). It does hold an
160
+ * IP address, a host name, or a lower-case word that is somebody's password:
161
+ * those are form ids, and are recorded as typed.
162
+ */
163
+ function auditedForm(id) {
164
+ return typeof id === "string" && CONFIGURATION_ID.test(id) ? id : null;
165
+ }
166
+ /** The keyed hash a record is named by: the first 32 hex characters of HMAC-SHA-256. */
167
+ function recordReference(key, token) {
168
+ if (key === void 0 || token === void 0 || token === "") return null;
169
+ return createHmac("sha256", key).update(token, "utf8").digest("hex").slice(0, 32);
170
+ }
171
+ /** `METHOD url`, with HEAD as the GET it answers for. */
172
+ function routeKey$1(method, url) {
173
+ const verb = method.toUpperCase();
174
+ return `${verb === "HEAD" ? "GET" : verb} ${url}`;
175
+ }
176
+ /**
177
+ * One event per request, for every route of the plane it is called on, from
178
+ * one set of hooks both planes share — so the two cannot disagree on what an
179
+ * outcome is, or on whether a refusal is audited (0023, 0033).
180
+ *
181
+ * Called before the plane registers a route: from then on a route in that
182
+ * context with no audit name stops the registration, whether or not a sink is
183
+ * configured. `onRoute` sees only the routes registered after it, so a route
184
+ * registered in the context before this call is refused on every request
185
+ * instead, before its handler runs, and the log names it: either way a route
186
+ * without a name never answers unaudited.
187
+ *
188
+ * The event is built once the reply is decided, from the response itself:
189
+ * every ending — success, refusal, a database out of reach — without each
190
+ * branch remembering to say so.
191
+ *
192
+ * Exactly once for a request that reaches a route, whichever way it ends,
193
+ * measured against Fastify 5.12.5 on Node 22. `onResponse` runs only when the
194
+ * response finishes. One whose client left while the route still ran never
195
+ * finishes: the handler completes and its write commits, `onSend` runs with
196
+ * the response already destroyed, and neither `onResponse` nor
197
+ * `onRequestAbort` runs, the latter because the body had been read. One
198
+ * pipelined behind another answer on its connection has no socket of its own
199
+ * until that answer is written; if the connection goes first it emits
200
+ * neither `finish` nor `close`, and is never destroyed. So the event is also
201
+ * written from `onSend` when the response or its connection is already gone,
202
+ * and otherwise from the connection's `close` if it comes before the event
203
+ * is written: a response whose socket goes after `onSend` goes with its
204
+ * connection, and a pipelined one has nothing else to say so. Whichever
205
+ * comes first writes it, and the others find it written. A connection holds
206
+ * one listener while any answer waits on it, however many requests a client
207
+ * pipelined, and none once the last is written.
208
+ */
209
+ function auditRequests(app, options) {
210
+ const { names, sink, describe } = options;
211
+ app.addHook("onRoute", (route) => {
212
+ for (const method of [route.method].flat()) {
213
+ const key = routeKey$1(method, route.url);
214
+ if (!Object.hasOwn(names, key)) throw new Error(`${key} has no audit name (0023, 0033)`);
215
+ }
216
+ });
217
+ app.addHook("onRequest", async (request, reply) => {
218
+ const key = routeKey$1(request.method, request.routeOptions.url ?? "");
219
+ if (Object.hasOwn(names, key)) return void 0;
220
+ request.log.error(`${key} has no audit name (0023, 0033)`);
221
+ return reply.code(500).send({
222
+ code: "unaudited-route",
223
+ message: "This route has no audit name, and does not answer."
224
+ });
225
+ });
226
+ if (sink === void 0) return;
227
+ const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
228
+ const codes = /* @__PURE__ */ new WeakMap();
229
+ const written = /* @__PURE__ */ new WeakSet();
230
+ /** Per connection, the requests whose answers wait on it, and its one `close` listener for all of them. */
231
+ const waiting = /* @__PURE__ */ new WeakMap();
232
+ /** Until its event is written, a request's connection going writes it. */
233
+ const wait = (request, reply) => {
234
+ const connection = request.raw.socket;
235
+ let entry = waiting.get(connection);
236
+ if (entry === void 0) {
237
+ const requests = /* @__PURE__ */ new Map();
238
+ entry = {
239
+ requests,
240
+ close: () => {
241
+ for (const [each, answer] of requests) emit(each, answer);
242
+ }
243
+ };
244
+ waiting.set(connection, entry);
245
+ connection.once("close", entry.close);
246
+ }
247
+ entry.requests.set(request, reply);
248
+ };
249
+ /** Written: the connection need not say so, and holds no listener once nothing on it waits. */
250
+ const settle = (request) => {
251
+ const connection = request.raw.socket;
252
+ const entry = waiting.get(connection);
253
+ if (entry === void 0 || !entry.requests.delete(request) || entry.requests.size > 0) return;
254
+ connection.off("close", entry.close);
255
+ waiting.delete(connection);
256
+ };
257
+ const emit = async (request, reply) => {
258
+ if (written.has(request)) return;
259
+ written.add(request);
260
+ settle(request);
261
+ const operation = names[routeKey$1(request.method, request.routeOptions.url ?? "")];
262
+ if (operation === void 0) return;
263
+ const status = reply.statusCode;
264
+ try {
265
+ const base = {
266
+ at: now(),
267
+ actor: request.identity?.actor.id ?? null,
268
+ operation,
269
+ status,
270
+ outcome: status < 400 ? "ok" : codes.get(request) ?? `http-${String(status)}`
271
+ };
272
+ await sink(describe(request, base));
273
+ } catch (error) {
274
+ request.log.error({ error: error.message }, "an audit event could not be written");
275
+ }
276
+ };
277
+ app.addHook("onSend", async (request, reply, payload) => {
278
+ if (reply.statusCode >= 400 && typeof payload === "string") try {
279
+ const code = JSON.parse(payload).code;
280
+ if (typeof code === "string") codes.set(request, code);
281
+ } catch {}
282
+ if (reply.raw.destroyed || request.raw.socket.destroyed) {
283
+ await emit(request, reply);
284
+ return payload;
285
+ }
286
+ wait(request, reply);
287
+ return payload;
288
+ });
289
+ app.addHook("onResponse", async (request, reply) => {
290
+ await emit(request, reply);
291
+ });
292
+ }
293
+ //#endregion
294
+ //#region src/lookup-deadline.ts
295
+ /** What is wrong with `ms` as a deadline, said of `name`, or undefined: a whole number from 1 to the longest a Node timer holds. */
296
+ function lookupDeadlineProblem(name, ms, written = String(ms)) {
297
+ if (Number.isSafeInteger(ms) && ms >= 1 && ms <= MAX_LOOKUP_DEADLINE_MS) return void 0;
298
+ return `${name} is a whole number of milliseconds from 1 to ${String(MAX_LOOKUP_DEADLINE_MS)}; it is ${written}.`;
299
+ }
300
+ /**
301
+ * `FORMANCY_DATA_LOOKUP_DEADLINE_MS` as written: unset is the measured
302
+ * default; otherwise decimal digits only, no sign, fraction, exponent or
303
+ * space, from 1 to the longest a Node timer holds. The refusal quotes what
304
+ * was written, which is a number of milliseconds and never a secret.
305
+ */
306
+ function lookupDeadlineSetting(text) {
307
+ if (text === void 0) return {
308
+ ok: true,
309
+ ms: DEFAULT_LOOKUP_DEADLINE_MS
310
+ };
311
+ const ms = /^[0-9]+$/.test(text) ? Number(text) : NaN;
312
+ const problem = lookupDeadlineProblem("FORMANCY_DATA_LOOKUP_DEADLINE_MS", ms, JSON.stringify(text));
313
+ return problem === void 0 ? {
314
+ ok: true,
315
+ ms
316
+ } : {
317
+ ok: false,
318
+ problem
319
+ };
320
+ }
321
+ /**
322
+ * A signal that aborts when `ms` have passed, with a TimeoutError, or when
323
+ * `within` aborts, with `within`'s own reason (0061).
324
+ *
325
+ * A timer of its own rather than `AbortSignal.any` over
326
+ * `AbortSignal.timeout`: a timeout signal cannot be cleared, and the
327
+ * composite stayed reachable from it until it fired -- about 730 bytes a
328
+ * request, for up to 24.8 days at the longest deadline (2026-10-15 review,
329
+ * Node 24.21.0). `clear()` stops the timer and the listening, so a lookup
330
+ * that answered holds nothing. The timer is unreferenced, as a timeout
331
+ * signal's is: a deadline never keeps the process alive.
332
+ */
333
+ function lookupDeadline(ms, within) {
334
+ const controller = new AbortController();
335
+ const timer = setTimeout(() => controller.abort(new DOMException(`The lookup passed its deadline of ${String(ms)} ms.`, "TimeoutError")), ms);
336
+ if (typeof timer === "object") timer.unref();
337
+ const left = () => controller.abort(within?.reason);
338
+ if (within?.aborted === true) controller.abort(within.reason);
339
+ else within?.addEventListener("abort", left, { once: true });
340
+ return {
341
+ signal: controller.signal,
342
+ clear() {
343
+ clearTimeout(timer);
344
+ within?.removeEventListener("abort", left);
345
+ }
346
+ };
347
+ }
348
+ //#endregion
349
+ //#region src/generation.ts
350
+ function isRecord$10(value) {
351
+ return typeof value === "object" && value !== null && !Array.isArray(value);
352
+ }
353
+ function lookupChoices(value) {
354
+ if (value === void 0) return [];
355
+ if (!Array.isArray(value)) return void 0;
356
+ const choices = [];
357
+ for (const entry of value) {
358
+ if (!isRecord$10(entry) || typeof entry["foreignKey"] !== "string") return void 0;
359
+ const display = entry["display"];
360
+ if (!Array.isArray(display) || !display.every((column) => typeof column === "string")) return void 0;
361
+ choices.push({
362
+ foreignKey: entry["foreignKey"],
363
+ display: [...display]
364
+ });
365
+ }
366
+ return choices;
367
+ }
368
+ /**
369
+ * One reading of a generation request, for the proposal route and for the
370
+ * bundle check (0030), so what a proposal was generated from and what a
371
+ * bundle says it was generated from are read by the same rules.
372
+ *
373
+ * Normalised: `lookups` and `pinned` are always lists, and `versionColumn` is
374
+ * present only when it is a string. Other properties are not carried, so a
375
+ * stored request that is not equal to its own reading holds something this
376
+ * reading would not have produced.
377
+ */
378
+ function readGeneration(value) {
379
+ if (!isRecord$10(value)) return {
380
+ ok: false,
381
+ problems: ["a generation request is an object"]
382
+ };
383
+ const problems = [];
384
+ const { connection, root, formId, title, versionColumn, pinned } = value;
385
+ if (typeof connection !== "string") problems.push("connection must name a connection");
386
+ if (!isRecord$10(root) || typeof root["schema"] !== "string" || typeof root["name"] !== "string") problems.push("root must be { schema, name }");
387
+ if (typeof formId !== "string" || !CONFIGURATION_ID.test(formId)) problems.push("formId must be a lower-case form id");
388
+ if (typeof title !== "string") problems.push("title must be text");
389
+ const lookups = lookupChoices(value["lookups"]);
390
+ if (lookups === void 0) problems.push("lookups must be a list of { foreignKey, display: [columns] }");
391
+ const pinnedColumns = pinned === void 0 ? [] : Array.isArray(pinned) && pinned.every((name) => typeof name === "string") ? [...pinned] : void 0;
392
+ if (pinnedColumns === void 0) problems.push("pinned must be a list of columns");
393
+ if (versionColumn !== void 0 && typeof versionColumn !== "string") problems.push("versionColumn must name a column");
394
+ if (problems.length > 0 || lookups === void 0 || pinnedColumns === void 0 || !isRecord$10(root)) return {
395
+ ok: false,
396
+ problems
397
+ };
398
+ return {
399
+ ok: true,
400
+ generation: {
401
+ connection,
402
+ root: {
403
+ schema: root["schema"],
404
+ name: root["name"]
405
+ },
406
+ formId,
407
+ title,
408
+ lookups,
409
+ pinned: pinnedColumns,
410
+ ...typeof versionColumn === "string" ? { versionColumn } : {}
411
+ }
412
+ };
413
+ }
414
+ //#endregion
415
+ //#region src/served-schema.ts
416
+ function isRecord$9(value) {
417
+ return typeof value === "object" && value !== null && !Array.isArray(value);
418
+ }
419
+ /**
420
+ * A stored form or base as this server serves one: a document formancy's
421
+ * validator accepts, in the spec version the generator writes (0042), or
422
+ * every reason it is not, each beginning with `label`.
423
+ *
424
+ * The version is this server's question as well as the validator's, because
425
+ * the validator accepts every version its release speaks, and that grows
426
+ * with the release: from `@formancy/spec` 0.4.0 on a spec 4 document is valid,
427
+ * and a host page whose renderer is at 0.3.0 refuses it outright. Another
428
+ * version is refused whole, since what the validator would add is about a
429
+ * version this server does not serve. A construct of a later version in a
430
+ * document that says the generator's is refused with the same reason, by
431
+ * the validator's code, rather than with its advice to move the document to
432
+ * that version, which this server would then refuse.
433
+ */
434
+ function servedSchema(label, document) {
435
+ const only = `and this server publishes and serves only spec ${JSON.stringify(GENERATED_SPEC_VERSION)}, the version it generates (0042)`;
436
+ const declared = isRecord$9(document) ? document["specVersion"] : void 0;
437
+ if (typeof declared === "string" && declared !== GENERATED_SPEC_VERSION) return {
438
+ valid: false,
439
+ problems: [`${label}: is spec ${JSON.stringify(declared)}, ${only}`]
440
+ };
441
+ const checked = validateSchema(document);
442
+ if (checked.valid) return checked;
443
+ return {
444
+ valid: false,
445
+ problems: checked.errors.map((error) => error.code.startsWith("version.") ? `${label}: ${error.path} needs spec ${JSON.stringify(String(error.values["version"]))}, ${only}` : `${label}: ${error.message}`)
446
+ };
447
+ }
448
+ //#endregion
449
+ //#region src/bundle-format2.ts
450
+ function isRecord$8(value) {
451
+ return typeof value === "object" && value !== null && !Array.isArray(value);
452
+ }
453
+ /**
454
+ * What format 2 adds (0030): a generation request in the form the proposal
455
+ * route reads one, for this bundle's connection and form; a base formancy
456
+ * accepts, in the generator's spec version (`servedSchema`); and a
457
+ * presentation that applies to that base and gives exactly the stored form.
458
+ * Not whether the generator would write that base — that is
459
+ * `generatedProblems`, at publish only.
460
+ */
461
+ function format2Problems(document, form, bindings, snapshot) {
462
+ const problems = [];
463
+ const read = readGeneration(document["generation"]);
464
+ const base = servedSchema("base", document["base"]);
465
+ if (!read.ok) problems.push(...read.problems.map((problem) => `generation: ${problem}`));
466
+ else if (canonicalize(read.generation) !== canonicalize(document["generation"])) problems.push("generation: is not in its normalised form (lookups and pinned as lists, versionColumn only when given, nothing else)");
467
+ else {
468
+ if (read.generation.connection !== document["connection"]) problems.push(`generation: names connection ${read.generation.connection}, and the bundle is bound to ${String(document["connection"])}`);
469
+ if (base.valid && read.generation.formId !== base.schema.id) problems.push(`generation: names form ${read.generation.formId}, and the base is ${base.schema.id}`);
470
+ if (base.valid) problems.push(...requestProblems(read.generation, base.schema, bindings, snapshot));
471
+ }
472
+ if (!base.valid) problems.push(...base.problems);
473
+ const shape = presentationShapeProblems(document["presentation"]);
474
+ if (shape.length > 0) return [...problems, ...shape.map((problem) => `presentation: ${problem}`)];
475
+ if (!base.valid) return problems;
476
+ const applied = applyPresentation(base.schema, document["presentation"], bindings);
477
+ if (!applied.ok) problems.push(...applied.problems.map((problem) => `presentation: ${problem}`));
478
+ else if (canonicalize(applied.form) !== canonicalize(form)) problems.push("form: is not the base with its presentation applied; the form, the base or the presentation was edited");
479
+ return problems;
480
+ }
481
+ /**
482
+ * What the stored request alone decides, against the base and bindings it is
483
+ * said to have produced (0030): the title, the root, a confirmed version
484
+ * column, the pins and the lookups. A regeneration generates from this
485
+ * request and its draft is checked at publish against the same request, so
486
+ * an edit to it alone would reach the next version unannounced. None of
487
+ * these asks the generator, so a later release cannot fail a stored version
488
+ * on them.
489
+ *
490
+ * Not caught: a pin taken out by hand. A field written by no operation could
491
+ * be pinned, generated, unwritable by the account or from a view, and only
492
+ * the generator tells them apart. The policy still decides what is written
493
+ * and which rows are reached; what such an edit changes is the next draft's
494
+ * form.
495
+ */
496
+ function requestProblems(generation, base, bindings, snapshot) {
497
+ const problems = [];
498
+ const tableOf = (ref) => `${ref.schema}.${ref.name}`;
499
+ if (generation.title !== base.title) problems.push(`generation: is titled ${JSON.stringify(generation.title)}, and the base ${JSON.stringify(base.title)}`);
500
+ if (!isRecord$8(bindings.root) || generation.root.schema !== bindings.root.schema || generation.root.name !== bindings.root.name) problems.push(`generation: names root ${tableOf(generation.root)}, and the bindings ${isRecord$8(bindings.root) ? tableOf(bindings.root) : "none"}`);
501
+ const concurrency = isRecord$8(bindings.concurrency) ? bindings.concurrency : null;
502
+ const confirmed = concurrency?.kind === "version-column" && concurrency.confirmed === true ? concurrency.column : void 0;
503
+ const rowversion = concurrency?.kind === "rowversion" || hasRowversion(snapshot, bindings.root);
504
+ if (generation.versionColumn !== void 0 && !rowversion && generation.versionColumn !== confirmed) problems.push(`generation: confirms version column ${generation.versionColumn}, and the bindings use ${confirmed ?? "none"}`);
505
+ if (generation.versionColumn === void 0 && confirmed !== void 0) problems.push(`generation: confirms no version column, and the bindings use ${confirmed} as a confirmed one`);
506
+ for (const column of generation.pinned ?? []) {
507
+ const field = bindings.fields.find((binding) => binding.kind === "column" && binding.column === column);
508
+ if (field !== void 0 && (field.writes.create || field.writes.update)) problems.push(`generation: pins ${column}, and its field ${field.field} is written by the form`);
509
+ }
510
+ const same = (left) => (right) => left.foreignKey === right.foreignKey && canonicalize(left.display) === canonicalize(right.display);
511
+ const shown = (lookup) => `${lookup.foreignKey} showing ${lookup.display.join(", ")}`;
512
+ const bound = bindings.fields.flatMap((binding) => binding.kind === "lookup" ? [binding] : []);
513
+ for (const lookup of generation.lookups) if (!bound.some(same(lookup))) problems.push(`generation: chooses a lookup over ${shown(lookup)}, which the bindings do not have`);
514
+ for (const lookup of bound) if (!generation.lookups.some(same(lookup))) problems.push(`generation: the bindings have a lookup over ${shown(lookup)}, which it does not choose`);
515
+ return problems;
516
+ }
517
+ /** Whether the snapshot's root has a rowversion column; false for a snapshot no catalog could produce. */
518
+ function hasRowversion(snapshot, root) {
519
+ if (snapshot === null || !isRecord$8(root)) return false;
520
+ return findObject(snapshot, root)?.columns.some((column) => column.type.kind === "rowversion") ?? false;
521
+ }
522
+ /**
523
+ * Whether this server's generator writes the bundle's base and bindings from
524
+ * its snapshot and generation request (0030). Asked at publish, so a base
525
+ * somebody edited — a relaxed maxLength, a dropped `required` — is never
526
+ * stored as if the database had said so. Not asked on read: a later release
527
+ * whose generator writes something else must not make every version stored
528
+ * before it "corrupt".
529
+ */
530
+ function generatedProblems(bundle) {
531
+ let generated;
532
+ try {
533
+ generated = generateForm(bundle.snapshot, bundle.generation);
534
+ } catch (error) {
535
+ return [`generation: ${error.message}`];
536
+ }
537
+ const problems = [];
538
+ if (canonicalize(generated.form) !== canonicalize(bundle.base)) problems.push("base: is not what this server generates from the stored snapshot and generation request");
539
+ if (canonicalize(generated.bindings) !== canonicalize(bundle.bindings)) problems.push("bindings: are not what this server generates from the stored snapshot and generation request");
540
+ return problems;
541
+ }
542
+ //#endregion
543
+ //#region src/bundle.ts
544
+ function isRecord$7(value) {
545
+ return typeof value === "object" && value !== null && !Array.isArray(value);
546
+ }
547
+ /**
548
+ * Whether a document can be served as a published form, with every reason it
549
+ * cannot.
550
+ *
551
+ * Run on publish and again on every read from the store, because the store is
552
+ * files on a volume and a file can be edited by hand (0013). Each check is one
553
+ * a hand edit could break silently:
554
+ *
555
+ * - the form must be one formancy's own validator accepts, in the spec
556
+ * version the generator writes (`servedSchema`, 0042) -- the validator
557
+ * alone accepts every version its release speaks;
558
+ * - the snapshot must still hash to its own fingerprint — an edited column
559
+ * type would otherwise reach the planner, which trusts it;
560
+ * - the bindings must have been generated from that snapshot;
561
+ * - the bindings must be a version this release reads — one published before
562
+ * 0027 cannot say which operation a field is written on, and is republished;
563
+ * - every bound field must exist in the form, and the policy must fit the
564
+ * bindings and the snapshot (`policyProblems`), so a rule nobody enforces
565
+ * cannot be published;
566
+ * - in format 2 (0030), the form must be its base with its presentation
567
+ * applied, and the generation request the one this server would store and
568
+ * the one its base and bindings say they came from (`bundle-format2.ts`).
569
+ *
570
+ * Format 1 is still read, so every version published before 0030 is served.
571
+ *
572
+ * It never throws. A part of another shape than a check reads -- a hand
573
+ * edit's `null` where the bindings' fields belong -- is one more reason the
574
+ * version is not served: thrown, it would reach the server's default 500,
575
+ * which answers with the exception's message, and the menu of forms (0045)
576
+ * would have to tell it from a store that cannot be read.
577
+ */
578
+ function validateBundle(document) {
579
+ try {
580
+ return checkedBundle(document);
581
+ } catch (error) {
582
+ return {
583
+ ok: false,
584
+ problems: [`the bundle has a part of another shape than it should: ${error.message}`]
585
+ };
586
+ }
587
+ }
588
+ function checkedBundle(document) {
589
+ if (!isRecord$7(document)) return {
590
+ ok: false,
591
+ problems: ["a bundle is an object"]
592
+ };
593
+ const problems = [];
594
+ const format = document["format"];
595
+ if (format !== 1 && format !== 2) problems.push("format must be 1 or 2");
596
+ if (typeof document["connection"] !== "string" || document["connection"] === "") problems.push("connection must name a connection");
597
+ const form = servedSchema("form", document["form"]);
598
+ if (!form.valid) problems.push(...form.problems);
599
+ const snapshot = document["snapshot"];
600
+ const bindings = document["bindings"];
601
+ const policy = document["policy"];
602
+ if (!isRecord$7(snapshot) || !isRecord$7(bindings) || !isRecord$7(policy)) {
603
+ problems.push("snapshot, bindings and policy must all be present");
604
+ return {
605
+ ok: false,
606
+ problems
607
+ };
608
+ }
609
+ let wellFormed = false;
610
+ try {
611
+ const { fingerprint, ...rest } = snapshot;
612
+ if (createSnapshot(rest).fingerprint !== fingerprint) problems.push("the snapshot does not hash to its own fingerprint: it was edited after it was taken");
613
+ wellFormed = true;
614
+ } catch (error) {
615
+ problems.push(`the snapshot is not one a catalog could produce: ${error.message}`);
616
+ }
617
+ const version = bindingsVersionProblem(bindings.version);
618
+ if (version !== null) {
619
+ problems.push(version);
620
+ return {
621
+ ok: false,
622
+ problems
623
+ };
624
+ }
625
+ if (bindings.snapshotFingerprint !== snapshot.fingerprint) problems.push("the bindings were not generated from this snapshot");
626
+ if (form.valid && Array.isArray(bindings.fields)) {
627
+ const keys = new Set(form.schema.model.fields.map((field) => field.key));
628
+ for (const binding of bindings.fields) if (!keys.has(binding.field)) problems.push(`the bindings name ${binding.field}, which the form does not have`);
629
+ }
630
+ if (wellFormed) problems.push(...policyProblems(snapshot, bindings, policy));
631
+ else problems.push(...fitProblems(policy, bindings));
632
+ if (format === 2 && form.valid && Array.isArray(bindings.fields)) problems.push(...format2Problems(document, form.schema, bindings, wellFormed ? snapshot : null));
633
+ return problems.length === 0 ? {
634
+ ok: true,
635
+ bundle: document
636
+ } : {
637
+ ok: false,
638
+ problems
639
+ };
640
+ }
641
+ /**
642
+ * Everything a policy does not fit: the bindings (`validatePolicy`), the
643
+ * snapshot's columns a row filter compares (`unfilterableColumns`), what
644
+ * the snapshot says of each lookup a through names (`throughProblems`, 0043),
645
+ * and what a delete it grants would also do to other tables
646
+ * (`deleteProblems`, 0050) -- each of which the planner asks too. One
647
+ * function for the bundle check, on publish and on every read, and for the
648
+ * regeneration route's report, so the three cannot disagree about one policy.
649
+ */
650
+ function policyProblems(snapshot, bindings, policy) {
651
+ return [
652
+ ...fitProblems(policy, bindings),
653
+ ...unfilterableColumns(snapshot, bindings, policy),
654
+ ...throughProblems(snapshot, bindings, policy).map((problem) => `policy: ${problem}`),
655
+ ...isRecord$7(bindings.root) ? deleteProblems(snapshot, bindings, policy).map((problem) => `policy: ${problem}`) : []
656
+ ];
657
+ }
658
+ function fitProblems(policy, bindings) {
659
+ const fitted = validatePolicy(policy, bindings);
660
+ return fitted.ok ? [] : fitted.problems.map((problem) => `policy: ${problem}`);
661
+ }
662
+ /** The filter rules of a policy as stored, whatever shape a hand edit left them in; `validatePolicy` reports a bad shape. */
663
+ function rulesOf(value) {
664
+ return Array.isArray(value) ? value.filter((rule) => isRecord$7(rule) && typeof rule["column"] === "string") : [];
665
+ }
666
+ /**
667
+ * Row filter columns a filter cannot compare, as `rowFilterColumnProblem`
668
+ * names them: absent, not readable by the snapshot's account (0027), or of a
669
+ * kind with no spelling both engines compare alike — a boolean, a float, a
670
+ * time or a timestamp (0028). The root's filter is the WHERE of every read
671
+ * and update and is written on every create; a lookup's is the WHERE of every
672
+ * search on its target. Either way the planner refuses every request it
673
+ * scopes, so saying so at publish is cheaper than at every request. A root
674
+ * filter column the account may not INSERT refuses every create in the same
675
+ * way, so it is a problem while the form offers create.
676
+ */
677
+ function unfilterableColumns(snapshot, bindings, policy) {
678
+ const problems = [];
679
+ const unfilterable = (where, ref, rules) => {
680
+ const object = findObject(snapshot, ref);
681
+ if (object === void 0) return;
682
+ for (const { column } of rules) {
683
+ const problem = rowFilterColumnProblem(object, column);
684
+ if (problem !== null) problems.push(`policy: ${where} ${problem}`);
685
+ }
686
+ };
687
+ unfilterable("rowFilters", bindings.root, rulesOf(policy.rowFilters));
688
+ if (isRecord$7(bindings.operations) && bindings.operations.create === true) {
689
+ const root = findObject(snapshot, bindings.root);
690
+ for (const { column } of rulesOf(policy.rowFilters)) if (root?.columns.find((candidate) => candidate.name === column)?.access.insert === false) problems.push(`policy: rowFilters ${column} is a column this connection's account may not insert, and every create writes it`);
691
+ }
692
+ const lookups = isRecord$7(policy.lookups) ? policy.lookups : {};
693
+ for (const binding of Array.isArray(bindings.fields) ? bindings.fields : []) if (binding.kind === "lookup") unfilterable(`lookups.${binding.field}`, binding.target.table, rulesOf(lookups[binding.field]));
694
+ return problems;
695
+ }
696
+ //#endregion
697
+ //#region src/published.ts
698
+ /** A form id is a configuration id: lower case, so it means one thing on every filesystem (0013). */
699
+ const FORM_ID = CONFIGURATION_ID;
700
+ /**
701
+ * The newest published version of a form, validated again on the way out of
702
+ * the store (0019). One function for both planes, because "a hand-edited
703
+ * version is never served" is one decision and two copies of it could drift.
704
+ *
705
+ * The problems go to the operator's log; the caller learns only that the
706
+ * version is not served.
707
+ */
708
+ async function loadPublished(store, id, log) {
709
+ const unknown = {
710
+ ok: false,
711
+ status: 404,
712
+ code: "unknown-form",
713
+ message: `No form is called ${id}.`
714
+ };
715
+ if (!FORM_ID.test(id)) return unknown;
716
+ const version = await store.latest(id);
717
+ if (version === null) return unknown;
718
+ return checked(store, id, version, log);
719
+ }
720
+ /**
721
+ * One published version of a form, by number, validated as `loadPublished`
722
+ * validates the newest (0030): the versions list and a restore read through
723
+ * here, so an older version edited by hand is never served or restored.
724
+ */
725
+ async function loadVersion(store, id, version, log) {
726
+ if (!FORM_ID.test(id) || await store.latest(id) === null) return {
727
+ ok: false,
728
+ status: 404,
729
+ code: "unknown-form",
730
+ message: `No form is called ${id}.`
731
+ };
732
+ return checked(store, id, version, log);
733
+ }
734
+ async function checked(store, id, version, log) {
735
+ const document = await store.read(id, version);
736
+ if (document === void 0) return {
737
+ ok: false,
738
+ status: 404,
739
+ code: "unknown-version",
740
+ message: `${id} has no version ${String(version)}.`
741
+ };
742
+ const validation = validateBundle(document);
743
+ if (!validation.ok) {
744
+ log.error({
745
+ form: id,
746
+ version,
747
+ problems: validation.problems
748
+ }, "a published bundle failed validation");
749
+ return {
750
+ ok: false,
751
+ status: 500,
752
+ code: "corrupt-bundle",
753
+ message: `Version ${String(version)} of ${id} no longer validates and is not served.`
754
+ };
755
+ }
756
+ return {
757
+ ok: true,
758
+ version,
759
+ bundle: validation.bundle
760
+ };
761
+ }
762
+ //#endregion
763
+ //#region src/routes/runtime-errors.ts
764
+ /**
765
+ * A planner or policy refusal as an HTTP answer.
766
+ *
767
+ * Access refusals are 403 and say which rule, never which value: the policy's
768
+ * own messages name keys, columns and attributes only (0011).
769
+ */
770
+ function planRefusal(code, message) {
771
+ switch (code) {
772
+ case "operation-denied":
773
+ case "field-denied":
774
+ case "missing-attribute":
775
+ case "over-posting":
776
+ case "unknown-lookup":
777
+ case "operation-unavailable": return {
778
+ status: 403,
779
+ body: {
780
+ code,
781
+ message
782
+ }
783
+ };
784
+ case "drift": return {
785
+ status: 409,
786
+ body: {
787
+ code,
788
+ message
789
+ }
790
+ };
791
+ case "invalid-context": return {
792
+ status: 403,
793
+ body: {
794
+ code,
795
+ message
796
+ }
797
+ };
798
+ case "invalid-policy":
799
+ case "invalid-bindings": return {
800
+ status: 500,
801
+ body: {
802
+ code,
803
+ message: "This form is published with a configuration the server cannot apply."
804
+ }
805
+ };
806
+ case "invalid-request":
807
+ case "invalid-record-token":
808
+ case "invalid-version":
809
+ case "nothing-to-update":
810
+ case "not-sortable":
811
+ case "not-searchable": return {
812
+ status: 400,
813
+ body: {
814
+ code,
815
+ message
816
+ }
817
+ };
818
+ case "record-not-read": return {
819
+ status: 409,
820
+ body: {
821
+ code,
822
+ message: "This update could not be compared with the record as read. Nothing was saved."
823
+ }
824
+ };
825
+ }
826
+ }
827
+ /** The form field a column is bound to, directly or through a lookup, for a field error. */
828
+ function fieldOf(bindings, column) {
829
+ if (column === void 0) return void 0;
830
+ for (const binding of bindings.fields) {
831
+ if (binding.kind === "column" && binding.column === column) return binding.field;
832
+ if (binding.kind === "lookup" && binding.columns.includes(column)) return binding.field;
833
+ }
834
+ }
835
+ /**
836
+ * A database's refusal as an HTTP answer (0015).
837
+ *
838
+ * Constraint failures become a field error where the engine names a column the
839
+ * form binds, so a person sees the message beside the field. `refused` is
840
+ * 422, because sending it again will be refused the same way; `unavailable`
841
+ * is 503, because it may not be. `unknown-outcome` is not answered here: only
842
+ * a write has one, and the write routes answer it with `unknownOutcome`, which
843
+ * names what was addressed. A read that reported one would be an adapter's
844
+ * bug, and is thrown -- a 500 -- rather than told to a person as "may have
845
+ * been saved" (0031).
846
+ */
847
+ function recordFailure(bindings, failure) {
848
+ const field = fieldOf(bindings, failure.column);
849
+ const withField = (status, code, message) => ({
850
+ status,
851
+ body: field === void 0 ? {
852
+ code,
853
+ message
854
+ } : {
855
+ code,
856
+ message,
857
+ fieldErrors: [{
858
+ field,
859
+ code,
860
+ message
861
+ }]
862
+ }
863
+ });
864
+ switch (failure.code) {
865
+ case "not-found": return {
866
+ status: 404,
867
+ body: {
868
+ code: "not-found",
869
+ message: "No such record."
870
+ }
871
+ };
872
+ case "stale": return {
873
+ status: 409,
874
+ body: {
875
+ code: "stale",
876
+ message: "The record changed since it was read. Reload it, review the changes, and save again."
877
+ }
878
+ };
879
+ case "unique-violation": return withField(422, "unique-violation", "Another record already has this value.");
880
+ case "foreign-key-violation": return withField(422, "foreign-key-violation", "This refers to a record that does not exist, or is still referred to.");
881
+ case "not-null-violation": return withField(422, "not-null-violation", "A value is required.");
882
+ case "check-violation": return withField(422, "check-violation", "The database does not accept this value.");
883
+ case "too-long": return withField(422, "too-long", "This value is longer than the database stores.");
884
+ case "out-of-range": return withField(422, "out-of-range", "This value is outside the range the database stores.");
885
+ case "permission-denied": return {
886
+ status: 403,
887
+ body: {
888
+ code: "permission-denied",
889
+ message: "The database connection is not allowed to do this."
890
+ }
891
+ };
892
+ case "schema-changed": return {
893
+ status: 409,
894
+ body: {
895
+ code: "schema-changed",
896
+ message: "The database changed since this form was published."
897
+ }
898
+ };
899
+ case "refused": return withField(422, "refused", "The database refused this request, and nothing was saved. Sending it again will be refused the same way.");
900
+ case "unavailable": return {
901
+ status: 503,
902
+ body: {
903
+ code: "unavailable",
904
+ message: "The database could not complete this now. Nothing was saved."
905
+ }
906
+ };
907
+ case "unknown-outcome": throw new Error("only a write has an unknown outcome; the write routes answer it with unknownOutcome");
908
+ }
909
+ }
910
+ /**
911
+ * A delete's refusal by the database as an HTTP answer (0050), in a
912
+ * delete's words: nothing was deleted, never "nothing was saved". Never a
913
+ * field error: a delete writes no field, and the column a constraint names
914
+ * is another table's. A key that still refers to the record is
915
+ * `referenced`, said without the table, the key or how many rows -- the
916
+ * route logs the key's name (runtime-deletes.ts) -- though the answer
917
+ * itself tells the person that something refers to the record, which any
918
+ * refusal would.
919
+ * `unknown-outcome` is the route's to answer with `unknownOutcome`, which
920
+ * names what was addressed; here it is a bug, as for a read.
921
+ */
922
+ function deleteFailure(failure) {
923
+ const refused = {
924
+ status: 422,
925
+ body: {
926
+ code: "refused",
927
+ message: "The database refused this request, and nothing was deleted. Sending it again will be refused the same way."
928
+ }
929
+ };
930
+ switch (failure.code) {
931
+ case "not-found": return {
932
+ status: 404,
933
+ body: {
934
+ code: "not-found",
935
+ message: "No such record."
936
+ }
937
+ };
938
+ case "stale": return {
939
+ status: 409,
940
+ body: {
941
+ code: "stale",
942
+ message: "The record changed since it was read. Reload it and review the changes before deleting it."
943
+ }
944
+ };
945
+ case "foreign-key-violation": return {
946
+ status: 422,
947
+ body: {
948
+ code: "referenced",
949
+ message: "Other records still refer to this one, so the database keeps it. Nothing was deleted."
950
+ }
951
+ };
952
+ case "unique-violation":
953
+ case "not-null-violation":
954
+ case "check-violation":
955
+ case "too-long":
956
+ case "out-of-range":
957
+ case "refused": return refused;
958
+ case "permission-denied": return {
959
+ status: 403,
960
+ body: {
961
+ code: "permission-denied",
962
+ message: "The database connection is not allowed to do this."
963
+ }
964
+ };
965
+ case "schema-changed": return {
966
+ status: 409,
967
+ body: {
968
+ code: "schema-changed",
969
+ message: "The database changed since this form was published."
970
+ }
971
+ };
972
+ case "unavailable": return {
973
+ status: 503,
974
+ body: {
975
+ code: "unavailable",
976
+ message: "The database could not complete this now. Nothing was deleted."
977
+ }
978
+ };
979
+ case "unknown-outcome": throw new Error("only a write has an unknown outcome; the delete route answers it with unknownOutcome");
980
+ }
981
+ }
982
+ /**
983
+ * A write that was sent and whose answer was lost, as an HTTP answer (0031):
984
+ * 502, because something between this server and the database failed, and in
985
+ * words that say it may have been saved. Nothing here sends it again.
986
+ *
987
+ * The sentence never names a value (0011): what was addressed travels in
988
+ * `record` and `version`, for the host to reconcile with. Each sentence says
989
+ * what is safe next. An update and a delete are protected by their version
990
+ * (0015, 0050). A create
991
+ * whose key the insert named can be read by it. A create whose key the
992
+ * database numbers cannot be found by anything here.
993
+ */
994
+ function unknownOutcome(operation, record, version) {
995
+ let message;
996
+ if (operation === "delete") message = "The connection to the database failed after the deletion was sent. It may have been deleted. Deleting again with the same version deletes nothing else: if it was deleted, that is answered as not found.";
997
+ else if (operation === "update") message = "The connection to the database failed after the change was sent. It may have been saved. Saving again with the same version is safe: it is stored at most once.";
998
+ else if (record !== null) message = "The connection to the database failed after the record was sent. It may have been saved: read it before entering it again.";
999
+ else message = "The connection to the database failed after the record was sent. It may have been saved, and the database numbers new records, so only a search of your own can tell.";
1000
+ return {
1001
+ status: 502,
1002
+ body: {
1003
+ code: "unknown-outcome",
1004
+ message,
1005
+ operation,
1006
+ record,
1007
+ version
1008
+ }
1009
+ };
1010
+ }
1011
+ //#endregion
1012
+ //#region src/routes/write-once.ts
1013
+ /**
1014
+ * How long a sending's answer is kept for a resend, and how much is kept.
1015
+ *
1016
+ * Measured on 2026-10-09 by the host page's browser gate
1017
+ * (apps/host/scripts/browser-test.mjs, "Create resent by Chromium"), with
1018
+ * Playwright's Chromium against the real server on PostgreSQL, six runs: the
1019
+ * resend reached the server 5 to 6 ms after the connection closed -- the
1020
+ * gate polls every 5 ms, so that is its resolution -- and the order form's
1021
+ * create answer was 234 to 236 bytes as JSON. Ten minutes is a margin over
1022
+ * those milliseconds for a slower client, not a measurement of one; ten
1023
+ * thousand such answers are about 2.4 MB, and the byte cap is there for a
1024
+ * form whose records are large. Oldest dropped first, so a person sending
1025
+ * large records under fresh ids cannot make this server hold more: a resend
1026
+ * that arrives after its answer was dropped is applied again.
1027
+ */
1028
+ const KEEP_MS = 6e5;
1029
+ /** What a write id must look like: the client's are 32 hex characters; anything of 16 to 64 URL-safe characters is taken. */
1030
+ const WRITE_ID = /^[A-Za-z0-9_-]{16,64}$/;
1031
+ /** The request's write id; undefined when it has none; null when it has one that is not one. */
1032
+ function writeId(request) {
1033
+ const value = request.headers[WRITE_ID_HEADER];
1034
+ if (value === void 0) return void 0;
1035
+ return typeof value === "string" && WRITE_ID.test(value) ? value : null;
1036
+ }
1037
+ /** The refusal of a malformed id, or of an id that came back with another write. Nothing was sent. */
1038
+ const INVALID_WRITE_ID = {
1039
+ status: 400,
1040
+ body: {
1041
+ code: "invalid-request",
1042
+ message: `The ${WRITE_ID_HEADER} header is not one write's id. Nothing was saved.`
1043
+ }
1044
+ };
1045
+ const REUSED_WRITE_ID = {
1046
+ status: 400,
1047
+ body: {
1048
+ code: "invalid-request",
1049
+ message: "This write id was sent before with a different write. Nothing was saved."
1050
+ }
1051
+ };
1052
+ function createWriteOnce(now = Date.now) {
1053
+ const kept = /* @__PURE__ */ new Map();
1054
+ let bytes = 0;
1055
+ /** Drops what has expired, and the oldest past either bound. Insertion order is expiry order. */
1056
+ function prune() {
1057
+ for (const [key, entry] of kept) {
1058
+ if (entry.until > now() && kept.size <= 1e4 && bytes <= 33554432) break;
1059
+ kept.delete(key);
1060
+ bytes -= entry.bytes;
1061
+ }
1062
+ }
1063
+ return {
1064
+ /**
1065
+ * `perform`'s answer, the first time this id is sent for this write; the
1066
+ * same answer, with `repeated`, every later time within KEEP_MS. The
1067
+ * check and the claim are one synchronous step, so two sendings that
1068
+ * arrive together cannot both perform.
1069
+ */
1070
+ async once(scope, perform) {
1071
+ prune();
1072
+ const key = JSON.stringify([
1073
+ scope.actor,
1074
+ scope.form,
1075
+ scope.operation,
1076
+ scope.id
1077
+ ]);
1078
+ const digest = createHash("sha256").update(JSON.stringify(scope.body) ?? "").digest("hex");
1079
+ const earlier = kept.get(key);
1080
+ if (earlier !== void 0) {
1081
+ if (earlier.digest !== digest) return {
1082
+ ...REUSED_WRITE_ID,
1083
+ repeated: false
1084
+ };
1085
+ return {
1086
+ ...await earlier.answer,
1087
+ repeated: true
1088
+ };
1089
+ }
1090
+ const answer = perform();
1091
+ const entry = {
1092
+ digest,
1093
+ until: now() + KEEP_MS,
1094
+ answer,
1095
+ bytes: 0
1096
+ };
1097
+ kept.set(key, entry);
1098
+ answer.then((settled) => {
1099
+ if (kept.get(key) !== entry) return;
1100
+ entry.bytes = Buffer.byteLength(JSON.stringify(settled.body) ?? "", "utf8");
1101
+ bytes += entry.bytes;
1102
+ }, () => {});
1103
+ return {
1104
+ ...await answer,
1105
+ repeated: false
1106
+ };
1107
+ } };
1108
+ }
1109
+ //#endregion
1110
+ //#region src/routes/runtime-deletes.ts
1111
+ function isRecord$6(value) {
1112
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1113
+ }
1114
+ /**
1115
+ * One delete, from the description it is decided over to the statement,
1116
+ * answered rather than sent: the route runs it once per sending, so a write
1117
+ * id that arrives again is answered with this answer and asks the database
1118
+ * nothing (0031).
1119
+ *
1120
+ * The policy is asked first, before any statement (0041); then the root is
1121
+ * described, and the delete is planned over that description and sent with
1122
+ * its definition. The answer names the row the statement deleted, by the
1123
+ * token its key makes as the database held it -- which under a
1124
+ * case-insensitive key may be spelled otherwise than the token sent (0028)
1125
+ * -- and holds no value of it, so a resend's kept answer holds none either.
1126
+ */
1127
+ async function deleted(steps, request, bundle, open, record, version) {
1128
+ const actor = steps.context(request);
1129
+ const authorized = authorizeOperation(bundle.policy, actor, "delete");
1130
+ if (!authorized.ok) return planRefusal(authorized.code, authorized.message);
1131
+ const root = await open.records.describe(bundle.bindings.root);
1132
+ if (!root.ok) return deleteFailure(root);
1133
+ const plan = planDelete(bundle.snapshot, bundle.bindings, bundle.policy, actor, record, version, root.described);
1134
+ if (!plan.ok) return steps.refusal(request, "delete", plan);
1135
+ const outcome = await open.records.delete(plan.request);
1136
+ if (!outcome.ok && outcome.code === "unknown-outcome") return steps.lost(request, "delete", record, version, outcome.message);
1137
+ if (!outcome.ok && outcome.code === "foreign-key-violation") request.log.info({
1138
+ form: request.params.id,
1139
+ constraint: outcome.constraint ?? null
1140
+ }, "a delete was refused: other records still refer to this one");
1141
+ if (!outcome.ok) return deleteFailure(outcome);
1142
+ const token = recordToken(plan.request.target.identity.map((column) => column.name), outcome.deleted.values);
1143
+ const removed = token.ok ? token.token : record;
1144
+ return {
1145
+ status: 200,
1146
+ body: {
1147
+ record: removed,
1148
+ deleted: true
1149
+ },
1150
+ record: removed
1151
+ };
1152
+ }
1153
+ /**
1154
+ * `POST /v1/forms/:id/records/delete` (0050): one record, by its token and
1155
+ * the version its person read, under the form's policy. A POST with a
1156
+ * body, as every runtime write is: a token in a DELETE's URL would land in
1157
+ * every proxy's log (0044), and RFC 9110 gives a DELETE's body no meaning.
1158
+ *
1159
+ * The body is checked first and the audit trail names the record; then the
1160
+ * write id, as an update's (0031). A 200 with `{ record, deleted: true }`,
1161
+ * never a 204: a client counts a write done only when the answer says what
1162
+ * happened to it. Every refusal is a delete's (`deleteFailure`), and none
1163
+ * carries a field error. The audit names the operation `delete` and the
1164
+ * record by its keyed hash, the only trace of which record went (0023).
1165
+ */
1166
+ function registerDeleteRoute(app, steps) {
1167
+ app.post("/v1/forms/:id/records/delete", async (request, reply) => {
1168
+ const body = request.body;
1169
+ if (!isRecord$6(body) || typeof body["record"] !== "string" || typeof body["version"] !== "string") return reply.code(400).send({
1170
+ code: "invalid-request",
1171
+ message: "Expected { record, version }."
1172
+ });
1173
+ if (request.auditTrail !== void 0) request.auditTrail.record = body["record"];
1174
+ const id = writeId(request);
1175
+ if (id === null) return steps.answered(reply, INVALID_WRITE_ID);
1176
+ const bundle = await steps.published(request.params.id, reply);
1177
+ if (bundle === void 0) return reply;
1178
+ const open = await steps.connection(bundle, reply);
1179
+ if (open === void 0) return reply;
1180
+ const { record, version } = body;
1181
+ return steps.answered(reply, await steps.sendOnce(request, "delete", id, () => deleted(steps, request, bundle, open, record, version)));
1182
+ });
1183
+ }
1184
+ //#endregion
1185
+ //#region src/routes/runtime-lists.ts
1186
+ /**
1187
+ * `POST /v1/forms/:id/records/list` (0044): one page of the records this
1188
+ * person may read, each exactly what `records/read` returns for its token,
1189
+ * in a total order, with whether more follow and no total.
1190
+ *
1191
+ * A POST, so the search never appears in a URL a proxy logs. The body is
1192
+ * checked first, as the read route checks its token; then the planner asks
1193
+ * the policy -- a list is a read, under the read filter and every through,
1194
+ * with no grant of its own -- before any statement. The page and the table
1195
+ * it was read from come from one statement, and the page is decided over
1196
+ * that description after it ran, as a read is (0041), and also refused
1197
+ * when the table no longer holds the key to a total order: refused, its
1198
+ * rows are discarded and never reach the body, and the log says `list`, as
1199
+ * the audit does. The audit names the operation `list` and no record, never
1200
+ * the search, the sort or the parent (0023).
1201
+ *
1202
+ * An adapter that answered `unknown-outcome` for a list -- which writes
1203
+ * nothing -- is a bug, and is answered 500, as for a read (0031).
1204
+ */
1205
+ function registerListRoute(app, steps) {
1206
+ app.post("/v1/forms/:id/records/list", async (request, reply) => {
1207
+ const query = validateListQuery(request.body === void 0 ? {} : request.body);
1208
+ if (!query.ok) return reply.code(400).send({
1209
+ code: query.code,
1210
+ message: query.message
1211
+ });
1212
+ const bundle = await steps.published(request.params.id, reply);
1213
+ if (bundle === void 0) return reply;
1214
+ const plan = planList(bundle.snapshot, bundle.bindings, bundle.policy, steps.context(request), query.query);
1215
+ if (!plan.ok) return steps.answered(reply, planRefusal(plan.code, plan.message));
1216
+ const open = await steps.connection(bundle, reply);
1217
+ if (open === void 0) return reply;
1218
+ const outcome = await open.records.list(plan.request);
1219
+ if (!outcome.ok) return steps.answered(reply, await steps.unread(request, bundle, open, outcome, "list"));
1220
+ const drifted = steps.undecided(request, bundle, outcome.described, "list");
1221
+ if (drifted !== void 0) return steps.answered(reply, drifted);
1222
+ return recordPage(bundle.bindings, plan.fields, outcome.rows, query.query.limit);
1223
+ });
1224
+ }
1225
+ //#endregion
1226
+ //#region src/routes/abandoned.ts
1227
+ /** What the signal is aborted with: an AbortError, so a route tells it from a database's failure by identity. */
1228
+ const LEFT = "The client closed the connection before the answer.";
1229
+ /**
1230
+ * Whether the browser gave up on this request before it was answered (0061).
1231
+ *
1232
+ * Two events say so, measured on Fastify 5.12.5 with Node 24 and Node 22 on
1233
+ * 2026-10-14: the response's `close` before it finished writing, and the
1234
+ * socket's `close`. The socket is needed for a request pipelined behind
1235
+ * another, whose response has no socket of its own until the answer before
1236
+ * it is written: on Node 22 such a response emits no `close` when the
1237
+ * connection goes (on Node 24 it does), as audit.ts found for its event. Two
1238
+ * events that look as if they say so do not: the request's `close` fires as
1239
+ * soon as its body is read, and Fastify's `onRequestAbort` never runs once
1240
+ * it was. A socket already destroyed has gone already.
1241
+ *
1242
+ * `settle()` removes both listeners: a keep-alive socket carries request
1243
+ * after request, and would otherwise gather one per request.
1244
+ */
1245
+ function abandonment(request, reply) {
1246
+ const controller = new AbortController();
1247
+ const response = reply.raw;
1248
+ const socket = request.raw.socket;
1249
+ const gone = () => {
1250
+ if (!response.writableFinished) controller.abort(new DOMException(LEFT, "AbortError"));
1251
+ };
1252
+ response.once("close", gone);
1253
+ socket.once("close", gone);
1254
+ if (socket.destroyed) gone();
1255
+ return {
1256
+ signal: controller.signal,
1257
+ settle() {
1258
+ response.off("close", gone);
1259
+ socket.off("close", gone);
1260
+ }
1261
+ };
1262
+ }
1263
+ const OPERATIONS = /* @__PURE__ */ new Set([
1264
+ "read",
1265
+ "create",
1266
+ "update"
1267
+ ]);
1268
+ function isRecord$5(value) {
1269
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1270
+ }
1271
+ /** The lookup field a source name stands for, its config and the actor's filter for it — or a reply already sent. */
1272
+ async function lookupFor(steps, request, reply, operation) {
1273
+ if (typeof operation !== "string" || !OPERATIONS.has(operation)) {
1274
+ await reply.code(400).send({
1275
+ code: "invalid-request",
1276
+ message: "Say which operation the form is open for: read, create or update."
1277
+ });
1278
+ return;
1279
+ }
1280
+ const bundle = await steps.published(request.params.id, reply);
1281
+ if (bundle === void 0) return void 0;
1282
+ const binding = bundle.bindings.fields.find((entry) => entry.kind === "lookup" && entry.source === request.params.source);
1283
+ if (binding === void 0) {
1284
+ await reply.code(404).send({
1285
+ code: "unknown-lookup",
1286
+ message: `This form has no lookup called ${request.params.source}.`
1287
+ });
1288
+ return;
1289
+ }
1290
+ const filter = lookupRowFilter(bundle.policy, steps.context(request), bundle.bindings, operation, binding.field);
1291
+ if (!filter.ok) {
1292
+ const refusal = planRefusal(filter.code, filter.message);
1293
+ await reply.code(refusal.status).send(refusal.body);
1294
+ return;
1295
+ }
1296
+ const config = buildLookupConfig(bundle.bindings, binding.field, { snapshot: bundle.snapshot });
1297
+ const scoped = scopeRowFilters(findObject(bundle.snapshot, config.target), filter.filter, `lookups.${binding.field}`);
1298
+ if (!scoped.ok) {
1299
+ const refusal = planRefusal(scoped.code, scoped.message);
1300
+ await reply.code(refusal.status).send(refusal.body);
1301
+ return;
1302
+ }
1303
+ const open = await steps.connection(bundle, reply);
1304
+ if (open === void 0) return void 0;
1305
+ return {
1306
+ open,
1307
+ config,
1308
+ filters: scoped.filters
1309
+ };
1310
+ }
1311
+ /** What each lookup says when it fails, or its resolve passes its deadline: `unavailable`, which invites another try. */
1312
+ const UNAVAILABLE = {
1313
+ search: "The options could not be loaded.",
1314
+ resolve: "The selected options could not be loaded."
1315
+ };
1316
+ /**
1317
+ * One lookup statement under the request's signal, answered (0061).
1318
+ *
1319
+ * The signal aborts when the client leaves before the answer, or when the
1320
+ * deadline passes. The deadline starts when the adapter is asked, so a wait
1321
+ * for a pooled connection, inside the driver, counts, and reading the form
1322
+ * and opening its connection do not: a deadline that passed there would be
1323
+ * answered as a statement stopped when none was sent. The adapter stops the
1324
+ * statement and rejects with the signal's reason, untranslated; anything
1325
+ * else it throws is the database's.
1326
+ *
1327
+ * A search past the deadline is `503 lookup-timeout`, not `unavailable`,
1328
+ * because 0028 says a refusal says whether trying again helps, and a
1329
+ * contains search that timed out reads the same rows again. A resolve reads
1330
+ * by key, at most a row a token, so one past the deadline waited rather
1331
+ * than read too much, and trying again helps: it is `unavailable`, and the
1332
+ * log names the deadline. A client that left is answered `499 abandoned`:
1333
+ * nobody reads it, and it exists so the audit trail's event for the request
1334
+ * says what happened (0023, 0033). The person reads the renderer's own
1335
+ * sentence, "could not be loaded"; at formancy 0.5.0 a source's message has
1336
+ * nowhere else to go.
1337
+ */
1338
+ async function answer(steps, request, reply, kind, run, operation) {
1339
+ const abandoned = abandonment(request, reply);
1340
+ try {
1341
+ const found = await lookupFor(steps, request, reply, operation);
1342
+ if (found === void 0) return reply;
1343
+ const deadline = lookupDeadline(steps.lookupDeadlineMs, abandoned.signal);
1344
+ const { signal } = deadline;
1345
+ try {
1346
+ return await run(found.open, found.config, found.filters, signal);
1347
+ } catch (error) {
1348
+ if (signal.aborted && error === signal.reason) return stopped(steps, request, reply, kind, signal.reason === abandoned.signal.reason);
1349
+ request.log.warn({ error: error.message }, kind === "search" ? "a lookup search failed" : "a lookup resolve failed");
1350
+ return reply.code(503).send({
1351
+ code: "unavailable",
1352
+ message: UNAVAILABLE[kind]
1353
+ });
1354
+ } finally {
1355
+ deadline.clear();
1356
+ }
1357
+ } finally {
1358
+ abandoned.settle();
1359
+ }
1360
+ }
1361
+ /** A lookup the adapter stopped at the signal: its client left, or its deadline passed. */
1362
+ function stopped(steps, request, reply, kind, left) {
1363
+ if (left) {
1364
+ request.log.info({ lookup: kind }, "a lookup was abandoned by its client; the database was told to stop it");
1365
+ return reply.code(499).send({
1366
+ code: "abandoned",
1367
+ message: "The client closed the connection before the answer; the database was told to stop the statement."
1368
+ });
1369
+ }
1370
+ const ms = steps.lookupDeadlineMs;
1371
+ request.log.warn({
1372
+ lookup: kind,
1373
+ deadlineMs: ms
1374
+ }, "a lookup passed its deadline; the database was told to stop it");
1375
+ if (kind === "resolve") return reply.code(503).send({
1376
+ code: "unavailable",
1377
+ message: UNAVAILABLE.resolve
1378
+ });
1379
+ return reply.code(503).send({
1380
+ code: "lookup-timeout",
1381
+ message: `The search took longer than this server allows (${String(ms)} ms), and the database was told to stop it. The same search will take as long again.`
1382
+ });
1383
+ }
1384
+ /**
1385
+ * `POST /v1/forms/:id/lookups/:source/query` and `…/resolve`: one page of a
1386
+ * lookup's options, or the labels of tokens a form holds, for the operation
1387
+ * the form is open for and under the actor's filter for that lookup (0012).
1388
+ * Each runs one statement that stops when its request is abandoned or its
1389
+ * deadline passes (0061); one that completes is answered exactly as before.
1390
+ */
1391
+ function registerLookupRoutes(app, steps) {
1392
+ app.post("/v1/forms/:id/lookups/:source/query", async (request, reply) => {
1393
+ const body = isRecord$5(request.body) ? request.body : {};
1394
+ return answer(steps, request, reply, "search", async (open, config, filters, signal) => {
1395
+ const query = validateLookupQuery(config, {
1396
+ search: body["search"] ?? "",
1397
+ offset: body["offset"] ?? 0,
1398
+ limit: body["limit"] ?? config.maxPageSize
1399
+ });
1400
+ if (!query.ok) return reply.code(400).send({
1401
+ code: query.code,
1402
+ message: query.message
1403
+ });
1404
+ return open.lookups.search(config, query.query, filters, signal);
1405
+ }, body["operation"]);
1406
+ });
1407
+ app.post("/v1/forms/:id/lookups/:source/resolve", async (request, reply) => {
1408
+ const body = isRecord$5(request.body) ? request.body : {};
1409
+ const tokens = body["tokens"];
1410
+ if (!Array.isArray(tokens) || tokens.length > 100 || !tokens.every((token) => typeof token === "string")) return reply.code(400).send({
1411
+ code: "invalid-request",
1412
+ message: `Expected { operation, tokens } with at most ${String(100)} tokens.`
1413
+ });
1414
+ return answer(steps, request, reply, "resolve", async (open, config, filters, signal) => ({ rows: await open.lookups.resolve(config, tokens, filters, signal) }), body["operation"]);
1415
+ });
1416
+ }
1417
+ //#endregion
1418
+ //#region src/routes/runtime-menu.ts
1419
+ /** What the menu answers when the store cannot list its forms: nothing it was told, no path. */
1420
+ const STORE_UNAVAILABLE = {
1421
+ code: "store-unavailable",
1422
+ message: "The forms cannot be listed now."
1423
+ };
1424
+ /**
1425
+ * The newest version of `id` as this person's menu reads it, or `undefined`
1426
+ * when it is not served -- validated as every read is (0019), bound to a
1427
+ * connection the allowlist has, and read alone (`menuForm`). Whatever stops
1428
+ * one form -- a version that no longer validates, does not parse or cannot
1429
+ * be read, a connection the allowlist does not have, bindings a hand edit
1430
+ * left in a shape the check passes and the menu cannot read -- leaves that
1431
+ * form out, and the log says which and why; never a 500, which would let one
1432
+ * damaged file hide every form from everybody, and answer with what the
1433
+ * error said.
1434
+ */
1435
+ async function entry(store, allowed, id, context, log) {
1436
+ try {
1437
+ const loaded = await loadPublished(store, id, log);
1438
+ if (!loaded.ok) return void 0;
1439
+ const { bundle } = loaded;
1440
+ if (!allowed.includes(bundle.connection)) {
1441
+ log.error({
1442
+ form: id,
1443
+ connection: bundle.connection
1444
+ }, "a published form names a connection the allowlist does not have, and is not served");
1445
+ return;
1446
+ }
1447
+ return menuForm({
1448
+ id,
1449
+ title: bundle.form.title,
1450
+ connection: bundle.connection,
1451
+ snapshot: bundle.snapshot,
1452
+ bindings: bundle.bindings,
1453
+ policy: bundle.policy
1454
+ }, context);
1455
+ } catch (error) {
1456
+ log.error({
1457
+ form: id,
1458
+ error: error.message
1459
+ }, "a published form cannot be read, and is not served");
1460
+ return;
1461
+ }
1462
+ }
1463
+ /**
1464
+ * `GET /v1/forms` (0045): the published forms this person may read, in
1465
+ * codepoint order of their ids, each with its title, the operations the
1466
+ * policy grants them of what it offers as published, and the children a
1467
+ * page may show under one of its records -- and nothing about any other
1468
+ * form, so the answer is the same to the byte whatever else is published.
1469
+ *
1470
+ * Every request reads and validates every published form's newest version,
1471
+ * and asks the policy of each (0019, 0022): nothing is cached. It asks no
1472
+ * database. Opening a form asks what the database as it is now allows
1473
+ * (`GET /v1/forms/:id`, 0041); the menu, over every form, would otherwise
1474
+ * be a statement per form, and one database out of reach would hide forms
1475
+ * on every other. A store that cannot list its forms is a 500 that says
1476
+ * only that. The audit names the operation `form-list`, and no form.
1477
+ */
1478
+ function registerMenuRoute(app, steps) {
1479
+ app.get("/v1/forms", async (request, reply) => {
1480
+ let ids;
1481
+ try {
1482
+ ids = await steps.store.list();
1483
+ } catch (error) {
1484
+ request.log.error({ error: error.message }, "the configuration store cannot list its forms");
1485
+ return reply.code(500).send(STORE_UNAVAILABLE);
1486
+ }
1487
+ const context = steps.context(request);
1488
+ const allowed = steps.connections();
1489
+ const forms = [];
1490
+ for (const id of ids) {
1491
+ const read = await entry(steps.store, allowed, id, context, request.log);
1492
+ if (read !== void 0) forms.push(read);
1493
+ }
1494
+ return formMenu(forms);
1495
+ });
1496
+ }
1497
+ //#endregion
1498
+ //#region src/routes/runtime.ts
1499
+ /**
1500
+ * The audit name of each runtime route, keyed `METHOD url`; a route without
1501
+ * one stops the server, or never answers (0033). `admin-audit.test.ts` sends
1502
+ * every route the router holds and fails on an entry no route has. Exported
1503
+ * from the package: the host page's suite reads the runtime plane's routes
1504
+ * here rather than keeping a list of its own (0047).
1505
+ */
1506
+ const ROUTE_OPERATIONS = {
1507
+ "GET /v1/forms": "form-list",
1508
+ "GET /v1/forms/:id": "form",
1509
+ "POST /v1/forms/:id/records/read": "read",
1510
+ "POST /v1/forms/:id/records/list": "list",
1511
+ "POST /v1/forms/:id/records/create": "create",
1512
+ "POST /v1/forms/:id/records/update": "update",
1513
+ "POST /v1/forms/:id/records/delete": "delete",
1514
+ "POST /v1/forms/:id/lookups/:source/query": "lookup-query",
1515
+ "POST /v1/forms/:id/lookups/:source/resolve": "lookup-resolve"
1516
+ };
1517
+ /** A read that found no record inside the filters, as the port's failure, for the answers that need one. */
1518
+ const NOT_FOUND = {
1519
+ ok: false,
1520
+ code: "not-found",
1521
+ message: "No such record exists inside the filters."
1522
+ };
1523
+ function isRecord$4(value) {
1524
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1525
+ }
1526
+ function contextOf(identity) {
1527
+ return {
1528
+ actor: {
1529
+ id: identity.actor.id,
1530
+ roles: [...identity.actor.roles]
1531
+ },
1532
+ attributes: { ...identity.attributes }
1533
+ };
1534
+ }
1535
+ /**
1536
+ * The answers a renderer submits, minus the fields it echoed back that this
1537
+ * actor may not write.
1538
+ *
1539
+ * A formancy renderer submits every field, disabled ones included, and the
1540
+ * policy calls a submitted field the actor may not write over-posting (0011).
1541
+ * Whether the actor may write a field is the policy's decision, so it is asked
1542
+ * — one key at a time, through `checkSubmittedFields` — rather than restated
1543
+ * here: a defaulted column the generator left writable can still be one the
1544
+ * policy grants nobody. The echo is removed on create when it is empty, and on
1545
+ * update when it equals what the record holds. Anything else in such a field
1546
+ * still reaches the planner and is refused: a changed value there is tampering
1547
+ * or a stale client, and either way is not saved (0022). An unedited instant
1548
+ * or time the actor may write is the planner's to remove, against the same
1549
+ * read (0040).
1550
+ */
1551
+ function withoutEchoes(bundle, actor, operation, answers, current) {
1552
+ if (!isRecord$4(answers)) return answers;
1553
+ const kept = { ...answers };
1554
+ for (const binding of bundle.bindings.fields) {
1555
+ if (!Object.hasOwn(kept, binding.field)) continue;
1556
+ if (checkSubmittedFields(bundle.policy, actor, bundle.bindings, operation, [binding.field]).ok) continue;
1557
+ const value = kept[binding.field];
1558
+ if (current === void 0 ? value === null || value === void 0 || value === "" : Object.hasOwn(current, binding.field) && canonicalize(value ?? null) === canonicalize(current[binding.field] ?? null)) delete kept[binding.field];
1559
+ }
1560
+ return kept;
1561
+ }
1562
+ /**
1563
+ * The runtime plane: a published form, its records and its lookups, for the
1564
+ * host application's people (plan section 13). Every route needs a verified
1565
+ * host token; every decision about what that person may do is the published
1566
+ * policy's, through the planner (0018), and every database answer is the
1567
+ * adapter's, through the ports (0015).
1568
+ */
1569
+ async function runtimeRoutes(app, options) {
1570
+ const { registry, store } = options;
1571
+ const writes = createWriteOnce();
1572
+ app.addHook("onRequest", async (request) => {
1573
+ request.auditTrail = { formVersion: null };
1574
+ });
1575
+ const key = options.audit?.key;
1576
+ auditRequests(app, {
1577
+ names: ROUTE_OPERATIONS,
1578
+ sink: options.audit?.sink,
1579
+ now: options.audit?.now,
1580
+ describe: (request, base) => {
1581
+ const trail = request.auditTrail ?? { formVersion: null };
1582
+ return {
1583
+ at: base.at,
1584
+ plane: "runtime",
1585
+ actor: base.actor,
1586
+ operation: base.operation,
1587
+ form: auditedForm(request.params.id),
1588
+ formVersion: trail.formVersion,
1589
+ status: base.status,
1590
+ outcome: trail.repeated === true ? "repeated" : base.outcome,
1591
+ record: recordReference(key, trail.record)
1592
+ };
1593
+ }
1594
+ });
1595
+ app.addHook("preHandler", async (request, reply) => {
1596
+ const identity = await options.authenticate(request);
1597
+ if (identity === void 0) return reply.code(401).header("www-authenticate", "Bearer").send({
1598
+ code: "unauthenticated",
1599
+ message: "A valid host token is required."
1600
+ });
1601
+ request.identity = identity;
1602
+ });
1603
+ const context = (request) => {
1604
+ if (request.identity === void 0) throw new Error("the preHandler sets the identity before any route runs");
1605
+ return contextOf(request.identity);
1606
+ };
1607
+ async function published(id, reply) {
1608
+ const loaded = await loadPublished(store, id, reply.log);
1609
+ if (loaded.ok) {
1610
+ if (reply.request.auditTrail !== void 0) reply.request.auditTrail.formVersion = loaded.version;
1611
+ return loaded.bundle;
1612
+ }
1613
+ await reply.code(loaded.status).send({
1614
+ code: loaded.code,
1615
+ message: loaded.message
1616
+ });
1617
+ }
1618
+ async function connection(bundle, reply) {
1619
+ try {
1620
+ const open = await registry.open(bundle.connection);
1621
+ if (open !== void 0) return open;
1622
+ reply.log.error({ connection: bundle.connection }, "a published form names a connection the allowlist does not have");
1623
+ await reply.code(500).send({
1624
+ code: "unknown-connection",
1625
+ message: "This form is published against a connection this server does not have."
1626
+ });
1627
+ } catch (error) {
1628
+ reply.log.warn({
1629
+ connection: bundle.connection,
1630
+ error: error.message
1631
+ }, "connection could not be opened");
1632
+ await reply.code(503).send({
1633
+ code: "unavailable",
1634
+ message: "The database cannot be reached. Nothing was saved."
1635
+ });
1636
+ }
1637
+ }
1638
+ /**
1639
+ * A write that was sent and whose answer was lost (0031): said once in the
1640
+ * log, with the adapter's sentence, which names no value; answered with what
1641
+ * was addressed; and never sent again -- each route asks the adapter once.
1642
+ */
1643
+ function lost(request, operation, record, version, failure) {
1644
+ request.log.warn({
1645
+ form: request.params.id,
1646
+ operation,
1647
+ failure
1648
+ }, "a write was sent and its answer lost; it is not retried");
1649
+ return {
1650
+ ...unknownOutcome(operation, record, version),
1651
+ record: record ?? void 0
1652
+ };
1653
+ }
1654
+ /**
1655
+ * Sends a write to the database once per sending (write-once.ts): `perform`
1656
+ * runs the first time its write id arrives, and a later arrival is answered
1657
+ * with that answer. A request without an id is performed every time.
1658
+ */
1659
+ async function sendOnce(request, operation, id, perform) {
1660
+ const actor = request.identity?.actor.id;
1661
+ if (id === void 0 || actor === void 0) return perform();
1662
+ return writes.once({
1663
+ actor,
1664
+ form: request.params.id,
1665
+ operation,
1666
+ id,
1667
+ body: request.body
1668
+ }, perform);
1669
+ }
1670
+ /**
1671
+ * A drift refusal of the database as it is now (0041), said once in the
1672
+ * log with what blocks, by kind and the fields it affects -- never a value,
1673
+ * and never the change's message, which names columns and types the person
1674
+ * is not told -- and answered with the planner's sentence.
1675
+ */
1676
+ function driftAnswer(request, operation, changes, message) {
1677
+ request.log.warn({
1678
+ form: request.params.id,
1679
+ version: request.auditTrail?.formVersion ?? null,
1680
+ operation,
1681
+ changes: (changes ?? []).map((change) => ({
1682
+ kind: change.kind,
1683
+ affects: change.affects
1684
+ }))
1685
+ }, "refused: the database no longer fits this form as published");
1686
+ return planRefusal("drift", message);
1687
+ }
1688
+ /** A planner's refusal as the answer, its drift said in the log first, under the operation the audit names. */
1689
+ function refusal(request, operation, plan) {
1690
+ return plan.code === "drift" && plan.drift !== void 0 ? driftAnswer(request, operation, plan.drift, plan.message) : planRefusal(plan.code, plan.message);
1691
+ }
1692
+ /** The root as the catalog describes it now, or the answer when it cannot be described: 503 when the database cannot say, 409 when the table is not one it describes. */
1693
+ async function describedRoot(bundle, open) {
1694
+ const found = await open.records.describe(bundle.bindings.root);
1695
+ return found.ok ? {
1696
+ ok: true,
1697
+ described: found.described
1698
+ } : {
1699
+ ok: false,
1700
+ answer: recordFailure(bundle.bindings, found)
1701
+ };
1702
+ }
1703
+ /** A write's answer, sent, with what the audit names: its record, and whether it was a repeat. */
1704
+ async function answered(reply, answer) {
1705
+ const trail = reply.request.auditTrail;
1706
+ if (trail !== void 0 && answer.record !== void 0) trail.record = answer.record;
1707
+ if (trail !== void 0 && answer.repeated === true) trail.repeated = true;
1708
+ return reply.code(answer.status).send(answer.body);
1709
+ }
1710
+ /**
1711
+ * Runs every membership check: undefined when every selection is a member,
1712
+ * otherwise the refusal to answer. Answered rather than sent, so an
1713
+ * update's checks run inside the sending they belong to (0031).
1714
+ *
1715
+ * Each check has a lookup deadline of its own, counted from when the
1716
+ * adapter is asked for it, and nothing else (0061): a write goes on after
1717
+ * its client leaves (0031), so no abandonment stops it. A check
1718
+ * past its deadline keeps the answer every failed check has, nothing
1719
+ * saved, and the log says the deadline passed.
1720
+ */
1721
+ async function memberships(open, checks, log) {
1722
+ const errors = [];
1723
+ for (const check of checks) {
1724
+ const deadline = lookupDeadline(options.lookupDeadlineMs);
1725
+ try {
1726
+ if ((await open.lookups.rejects(check.config, check.tokens, check.filters, deadline.signal)).length > 0) errors.push(rejectedSelection(check.field));
1727
+ } catch (error) {
1728
+ if (deadline.signal.aborted && error === deadline.signal.reason) log.warn({
1729
+ field: check.field,
1730
+ deadlineMs: options.lookupDeadlineMs
1731
+ }, "a lookup could not vouch for a selection: it passed its deadline, and the database was told to stop it");
1732
+ else log.warn({
1733
+ field: check.field,
1734
+ error: error.message
1735
+ }, "a lookup could not vouch for a selection");
1736
+ return {
1737
+ status: 503,
1738
+ body: {
1739
+ code: "unavailable",
1740
+ message: "A selection could not be checked. Nothing was saved."
1741
+ }
1742
+ };
1743
+ } finally {
1744
+ deadline.clear();
1745
+ }
1746
+ }
1747
+ return errors.length === 0 ? void 0 : {
1748
+ status: 422,
1749
+ body: {
1750
+ code: "invalid-values",
1751
+ message: "A selection is not one of the options.",
1752
+ fieldErrors: errors
1753
+ }
1754
+ };
1755
+ }
1756
+ /**
1757
+ * One create, from the description it is decided over to the insert,
1758
+ * answered rather than sent: the create route runs it once per sending, so
1759
+ * a write id that arrives again is answered with this answer and asks the
1760
+ * database nothing (0031). The mirror of `updated`.
1761
+ *
1762
+ * The policy is asked before any statement, so an actor it refuses reaches
1763
+ * none (0041). Then the root is described, the one statement before the
1764
+ * planner: a create the description stops is refused there, with no
1765
+ * membership check and no insert sent, and the insert carries the
1766
+ * definition it was decided over.
1767
+ */
1768
+ async function created(request, bundle, open, answers) {
1769
+ const actor = context(request);
1770
+ const authorized = authorizeOperation(bundle.policy, actor, "create");
1771
+ if (!authorized.ok) return planRefusal(authorized.code, authorized.message);
1772
+ const root = await describedRoot(bundle, open);
1773
+ if (!root.ok) return root.answer;
1774
+ const plan = planCreate(bundle.snapshot, bundle.bindings, bundle.policy, actor, withoutEchoes(bundle, actor, "create", answers), root.described);
1775
+ if (!plan.ok) {
1776
+ if (plan.code === "invalid-values") return {
1777
+ status: 422,
1778
+ body: {
1779
+ code: plan.code,
1780
+ message: plan.message,
1781
+ fieldErrors: plan.fieldErrors
1782
+ }
1783
+ };
1784
+ return refusal(request, "create", plan);
1785
+ }
1786
+ const refused = await memberships(open, plan.memberships, request.log);
1787
+ if (refused !== void 0) return refused;
1788
+ const outcome = await open.records.insert(plan.request);
1789
+ if (!outcome.ok && outcome.code === "unknown-outcome") return lost(request, "create", intendedRecord(plan.request), null, outcome.message);
1790
+ if (!outcome.ok) return recordFailure(bundle.bindings, outcome);
1791
+ const done = toFormAnswers(bundle.bindings, plan.fields, outcome);
1792
+ return {
1793
+ status: 201,
1794
+ body: done,
1795
+ record: done.record ?? void 0
1796
+ };
1797
+ }
1798
+ /**
1799
+ * One update, from the read that tells an echo from a change to the write,
1800
+ * answered rather than sent: the update route runs it once per sending, so
1801
+ * a write id that arrives again is answered with this answer and asks the
1802
+ * database nothing, its read included (0031).
1803
+ *
1804
+ * The policy is asked first, before any statement (0041). The record as it
1805
+ * stands is read next, and the read carries the table's description, which
1806
+ * the update is decided over. Against the record 0022 removes an unchanged
1807
+ * echo of a field the actor may not write, and the planner one of an
1808
+ * instant or a time they may (0040). An actor who may update and not read
1809
+ * gets no echo removed, and the planner needs no read for them: the table
1810
+ * is described for them in a statement of its own, as it is after a read
1811
+ * that failed. When the planner cannot tell an echo from a change because
1812
+ * the read failed, whatever the failure, that failure is the answer: sent
1813
+ * anyway, a read that failed and a write that did not would store the cut
1814
+ * value.
1815
+ */
1816
+ async function updated(request, bundle, open, record, version, answers) {
1817
+ const actor = context(request);
1818
+ const authorized = authorizeOperation(bundle.policy, actor, "update");
1819
+ if (!authorized.ok) return planRefusal(authorized.code, authorized.message);
1820
+ let current;
1821
+ let unread;
1822
+ let described;
1823
+ const reading = planRead(bundle.snapshot, bundle.bindings, bundle.policy, actor, record);
1824
+ if (reading.ok) {
1825
+ const stored = await open.records.read(reading.request);
1826
+ if (!stored.ok) unread = stored;
1827
+ else {
1828
+ described = stored.described;
1829
+ if (stored.record === null) unread = NOT_FOUND;
1830
+ else current = toFormAnswers(bundle.bindings, reading.fields, {
1831
+ ok: true,
1832
+ ...stored.record
1833
+ });
1834
+ }
1835
+ }
1836
+ if (described === void 0) {
1837
+ const root = await describedRoot(bundle, open);
1838
+ if (!root.ok) return root.answer;
1839
+ described = root.described;
1840
+ }
1841
+ const plan = planUpdate(bundle.snapshot, bundle.bindings, bundle.policy, actor, record, version, withoutEchoes(bundle, actor, "update", answers, current?.answers), current, described);
1842
+ if (!plan.ok) {
1843
+ if (plan.code === "invalid-values") return {
1844
+ status: 422,
1845
+ body: {
1846
+ code: plan.code,
1847
+ message: plan.message,
1848
+ fieldErrors: plan.fieldErrors
1849
+ }
1850
+ };
1851
+ if (plan.code === "record-not-read" && unread !== void 0) return recordFailure(bundle.bindings, unread);
1852
+ return refusal(request, "update", plan);
1853
+ }
1854
+ const refused = await memberships(open, plan.memberships, request.log);
1855
+ if (refused !== void 0) return refused;
1856
+ const outcome = await open.records.update(plan.request);
1857
+ if (!outcome.ok && outcome.code === "unknown-outcome") return lost(request, "update", record, version, outcome.message);
1858
+ if (!outcome.ok) return recordFailure(bundle.bindings, outcome);
1859
+ return {
1860
+ status: 200,
1861
+ body: toFormAnswers(bundle.bindings, plan.fields, outcome)
1862
+ };
1863
+ }
1864
+ /**
1865
+ * A read the database refused, a record's or a page's: one that names a
1866
+ * column that is gone -- `schema-changed` -- returned no description, so
1867
+ * the table is described once more and the read decided over that, and a
1868
+ * form drift stops is answered as drift whichever statement found it out
1869
+ * (0041). Any other failure is the database's. A page is decided as a
1870
+ * list (0044), and said in the log as one.
1871
+ */
1872
+ async function unread(request, bundle, open, failure, operation) {
1873
+ const root = failure.code === "schema-changed" ? await describedRoot(bundle, open) : void 0;
1874
+ const refused = root?.ok === true ? driftRefusal(bundle.snapshot, bundle.bindings, bundle.policy, root.described, operation) : void 0;
1875
+ return refused === void 0 ? recordFailure(bundle.bindings, failure) : refusal(request, operation, refused);
1876
+ }
1877
+ /**
1878
+ * A read decided over the description its own statement carried (0041):
1879
+ * the refusal when drift stops reading this form -- or, for a page, when
1880
+ * the table no longer holds its key to a total order (0044) -- or
1881
+ * `undefined`.
1882
+ */
1883
+ function undecided(request, bundle, described, operation) {
1884
+ const refused = driftRefusal(bundle.snapshot, bundle.bindings, bundle.policy, described, operation);
1885
+ return refused === void 0 ? void 0 : refusal(request, operation, refused);
1886
+ }
1887
+ registerMenuRoute(app, {
1888
+ store,
1889
+ connections: () => registry.ids(),
1890
+ context
1891
+ });
1892
+ /**
1893
+ * The form, what this person may do with it now, and which fields they may
1894
+ * see. The policy first, before any statement; then the root is described,
1895
+ * and only what the database as it is now still allows is offered (0041):
1896
+ * a form whose records it can no longer show faithfully is refused, and so
1897
+ * is one that leaves this person nothing. Opening a form therefore asks the
1898
+ * database, and one that cannot be reached is 503.
1899
+ */
1900
+ app.get("/v1/forms/:id", async (request, reply) => {
1901
+ const bundle = await published(request.params.id, reply);
1902
+ if (bundle === void 0) return reply;
1903
+ const actor = context(request);
1904
+ const allowed = grantedOperations(bundle.bindings, bundle.policy, actor);
1905
+ if (allowed.length === 0) return reply.code(403).send({
1906
+ code: "operation-denied",
1907
+ message: "This form is not available to you."
1908
+ });
1909
+ const open = await connection(bundle, reply);
1910
+ if (open === void 0) return reply;
1911
+ const root = await describedRoot(bundle, open);
1912
+ if (!root.ok) return answered(reply, root.answer);
1913
+ const decided = runtimeOperations(bundle.snapshot, bundle.bindings, bundle.policy, root.described);
1914
+ if (!decided.ok) return answered(reply, planRefusal(decided.code, decided.message));
1915
+ const { verdict } = decided;
1916
+ if (!verdict.readable) return answered(reply, driftAnswer(request, "form", decided.drift, READ_REFUSED));
1917
+ const operations = allowed.filter((operation) => operation === "read" || verdict.writable[operation]);
1918
+ if (operations.length === 0) return answered(reply, driftAnswer(request, "form", decided.drift, NOTHING_LEFT));
1919
+ const readable = readableFields(bundle.policy, actor, bundle.bindings);
1920
+ const listed = listable(bundle.snapshot, bundle.bindings, bundle.policy, actor);
1921
+ const list = listed.ok && driftRefusal(bundle.snapshot, bundle.bindings, bundle.policy, root.described, "list") === void 0 ? listColumns(bundle.bindings, listed.fields) : null;
1922
+ const deleting = operations.includes("delete") ? deleteEffects(bundle.snapshot, bundle.bindings.root, bundle.policy) : null;
1923
+ return {
1924
+ form: bundle.form,
1925
+ operations,
1926
+ readable: readable.ok ? readable.fields : [],
1927
+ list,
1928
+ deleting
1929
+ };
1930
+ });
1931
+ /**
1932
+ * One record. The policy and the token are decided before any statement;
1933
+ * the read statement returns the table's description beside the record,
1934
+ * and the read is decided over it after it ran (0041): refused, its values
1935
+ * are discarded and never reach the body. A read the database refused as
1936
+ * `schema-changed` -- it names a column that is gone -- returned no
1937
+ * description, so the table is described once more and the read decided
1938
+ * over that: a form drift stops is answered as drift, whichever statement
1939
+ * found it out.
1940
+ */
1941
+ app.post("/v1/forms/:id/records/read", async (request, reply) => {
1942
+ const body = request.body;
1943
+ if (!isRecord$4(body) || typeof body["record"] !== "string") return reply.code(400).send({
1944
+ code: "invalid-request",
1945
+ message: "Expected { record }."
1946
+ });
1947
+ if (request.auditTrail !== void 0) request.auditTrail.record = body["record"];
1948
+ const bundle = await published(request.params.id, reply);
1949
+ if (bundle === void 0) return reply;
1950
+ const plan = planRead(bundle.snapshot, bundle.bindings, bundle.policy, context(request), body["record"]);
1951
+ if (!plan.ok) return answered(reply, planRefusal(plan.code, plan.message));
1952
+ const open = await connection(bundle, reply);
1953
+ if (open === void 0) return reply;
1954
+ const outcome = await open.records.read(plan.request);
1955
+ if (!outcome.ok) return answered(reply, await unread(request, bundle, open, outcome, "read"));
1956
+ const drifted = undecided(request, bundle, outcome.described, "read");
1957
+ if (drifted !== void 0) return answered(reply, drifted);
1958
+ if (outcome.record === null) return answered(reply, recordFailure(bundle.bindings, NOT_FOUND));
1959
+ return toFormAnswers(bundle.bindings, plan.fields, {
1960
+ ok: true,
1961
+ ...outcome.record
1962
+ });
1963
+ });
1964
+ registerListRoute(app, {
1965
+ published,
1966
+ connection,
1967
+ context,
1968
+ unread,
1969
+ undecided,
1970
+ answered
1971
+ });
1972
+ app.post("/v1/forms/:id/records/create", async (request, reply) => {
1973
+ const body = request.body;
1974
+ if (!isRecord$4(body) || !isRecord$4(body["answers"])) return reply.code(400).send({
1975
+ code: "invalid-request",
1976
+ message: "Expected { answers }."
1977
+ });
1978
+ const id = writeId(request);
1979
+ if (id === null) return answered(reply, INVALID_WRITE_ID);
1980
+ const bundle = await published(request.params.id, reply);
1981
+ if (bundle === void 0) return reply;
1982
+ const open = await connection(bundle, reply);
1983
+ if (open === void 0) return reply;
1984
+ const answers = body["answers"];
1985
+ return answered(reply, await sendOnce(request, "create", id, () => created(request, bundle, open, answers)));
1986
+ });
1987
+ app.post("/v1/forms/:id/records/update", async (request, reply) => {
1988
+ const body = request.body;
1989
+ if (!isRecord$4(body) || typeof body["record"] !== "string" || typeof body["version"] !== "string" || !isRecord$4(body["answers"])) return reply.code(400).send({
1990
+ code: "invalid-request",
1991
+ message: "Expected { record, version, answers }."
1992
+ });
1993
+ if (request.auditTrail !== void 0) request.auditTrail.record = body["record"];
1994
+ const id = writeId(request);
1995
+ if (id === null) return answered(reply, INVALID_WRITE_ID);
1996
+ const bundle = await published(request.params.id, reply);
1997
+ if (bundle === void 0) return reply;
1998
+ const open = await connection(bundle, reply);
1999
+ if (open === void 0) return reply;
2000
+ const { record, version, answers } = body;
2001
+ return answered(reply, await sendOnce(request, "update", id, () => updated(request, bundle, open, record, version, answers)));
2002
+ });
2003
+ registerDeleteRoute(app, {
2004
+ published,
2005
+ connection,
2006
+ context,
2007
+ sendOnce,
2008
+ lost,
2009
+ refusal,
2010
+ answered
2011
+ });
2012
+ registerLookupRoutes(app, {
2013
+ published,
2014
+ connection,
2015
+ context,
2016
+ lookupDeadlineMs: options.lookupDeadlineMs
2017
+ });
2018
+ }
2019
+ /** The answer to a refused preflight, and to a token-bearing request refused across origins: a code and a sentence, and nothing of the request. */
2020
+ const CROSS_ORIGIN_REFUSED = {
2021
+ code: "cross-origin-refused",
2022
+ message: "Across origins this server answers only the runtime plane’s routes, from an origin its operator lists, and this request is not one of those, so it was refused before any work."
2023
+ };
2024
+ const SETTING = "FORMANCY_DATA_CORS_ORIGINS";
2025
+ /** Plain HTTP is accepted on these hosts only, where a developer's page is: never across a network (0060). */
2026
+ const LOOPBACK = /* @__PURE__ */ new Set([
2027
+ "localhost",
2028
+ "127.0.0.1",
2029
+ "[::1]"
2030
+ ]);
2031
+ /** One label of a host name as a browser's URL parser leaves it: lower case, digits, `-` and `_`. Anchored, one class, so linear (CodeQL). */
2032
+ const LABEL = /^[a-z0-9_-]+$/;
2033
+ /**
2034
+ * Why `entry` is not an origin this server lists, as the end of a sentence
2035
+ * that never repeats it, or `undefined`. An origin is accepted only when
2036
+ * the URL parser gives it back unchanged as its origin -- so lower case, no
2037
+ * path, no trailing slash, no default port, no user information, a punycode
2038
+ * name -- with a host that is a DNS name or an IP address, over `https:`, or
2039
+ * `http:` on loopback.
2040
+ */
2041
+ function originProblem(entry) {
2042
+ if (typeof entry !== "string") return "is not a string";
2043
+ if (entry === "*" || entry === "null") return "is a wildcard or the opaque origin, which is never listed: each origin is named exactly";
2044
+ let url;
2045
+ try {
2046
+ url = new URL(entry);
2047
+ } catch {
2048
+ return "is not an origin: a scheme, a host and, where it is not the default, a port";
2049
+ }
2050
+ if (url.protocol !== "https:" && url.protocol !== "http:") return "is not an https: origin";
2051
+ if (url.origin !== entry) return "is not written as a browser sends Origin: lower case, a punycode name, no path or trailing slash, no default port and no user information";
2052
+ const host = url.hostname;
2053
+ if (!(host.startsWith("[") || host.split(".").every((label) => LABEL.test(label)))) return "names a pattern or a host that is no DNS name or IP address";
2054
+ if (url.protocol === "http:" && !LOOPBACK.has(host)) return "is plain HTTP on a host other than localhost, 127.0.0.1 or [::1]: anything else is listed as https:";
2055
+ }
2056
+ /**
2057
+ * `FORMANCY_DATA_CORS_ORIGINS` as written: origins separated by commas,
2058
+ * space around each ignored. Unset, empty, or nothing but commas and space
2059
+ * is no origin -- Compose passes `""` for a variable it was not given. Any
2060
+ * entry that is not an origin refuses the whole setting, named by its place
2061
+ * and never quoted; and a list needs the runtime plane, whose routes are all
2062
+ * it grants.
2063
+ */
2064
+ function corsOriginsSetting(text, plane) {
2065
+ const entries = (text ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
2066
+ for (const [index, entry] of entries.entries()) {
2067
+ const problem = originProblem(entry);
2068
+ if (problem !== void 0) return {
2069
+ ok: false,
2070
+ problem: `${SETTING}: entry ${String(index + 1)} ${problem}.`
2071
+ };
2072
+ }
2073
+ if (entries.length > 0 && !plane.runtime) return {
2074
+ ok: false,
2075
+ problem: `${SETTING} lists origins, and the runtime plane is off: set FORMANCY_DATA_STORE_DIR and FORMANCY_DATA_CONNECTIONS, or leave it empty.`
2076
+ };
2077
+ return {
2078
+ ok: true,
2079
+ origins: [...new Set(entries)]
2080
+ };
2081
+ }
2082
+ /** What is wrong with `runtime.origins` as an embedder passed it, by place, or `undefined`: the setting's rule, with no trimming. */
2083
+ function originsProblem(origins) {
2084
+ if (!Array.isArray(origins)) return "runtime.origins is not a list of origins.";
2085
+ for (const [index, entry] of origins.entries()) {
2086
+ const problem = originProblem(entry);
2087
+ if (problem !== void 0) return `runtime.origins[${String(index)}] ${problem}.`;
2088
+ }
2089
+ }
2090
+ /** `existing` Vary with `name` added once, by its token case-insensitively; `*` stays `*`. */
2091
+ function varyWith(existing, name) {
2092
+ const tokens = [existing ?? []].flat().flatMap((value) => String(value).split(",")).map((token) => token.trim()).filter((token) => token !== "");
2093
+ if (tokens.includes("*")) return "*";
2094
+ return tokens.some((token) => token.toLowerCase() === name.toLowerCase()) ? tokens.join(", ") : [...tokens, name].join(", ");
2095
+ }
2096
+ /**
2097
+ * The origin as a log line may name it: only when the URL parser gives it
2098
+ * back unchanged and it is no longer than an origin can be (`https://`, a
2099
+ * 253-character name and a port). Such an origin holds no user information,
2100
+ * path or query, so nothing a caller pasted into a URL rides into the log
2101
+ * with it, and it is what the operator would add to the list (0060).
2102
+ * Anything else is logged with no origin at all.
2103
+ */
2104
+ function loggable(origin) {
2105
+ if (typeof origin !== "string" || origin.length > 267) return {};
2106
+ try {
2107
+ return new URL(origin).origin === origin ? { origin } : {};
2108
+ } catch {
2109
+ return {};
2110
+ }
2111
+ }
2112
+ /** `METHOD url` as the runtime table keys it: HEAD is HEAD, and the table names none, so it is never offered across origins. */
2113
+ const routeKey = (request) => `${request.method} ${request.routeOptions.url ?? ""}`;
2114
+ const OFFERED = new Set(Object.keys(ROUTE_OPERATIONS));
2115
+ /** A segment of a pattern: a plain `:name`, static text, or something the overlap rule does not model. */
2116
+ function segmentKind(segment) {
2117
+ if (/^:[A-Za-z0-9_]+$/.test(segment)) return "parameter";
2118
+ return segment.includes(":") || segment.includes("*") ? "unmodelled" : "static";
2119
+ }
2120
+ /** Whether a path exists that both patterns, each of plain segments, match: the same number of segments, each equal static text or a parameter on either side. */
2121
+ function overlap(a, b) {
2122
+ return a.length === b.length && a.every((segment, index) => segment === b[index] || segmentKind(segment) === "parameter" || segmentKind(String(b[index])) === "parameter");
2123
+ }
2124
+ /**
2125
+ * The methods a browser sends on a URL's grant without asking again, whatever
2126
+ * method the grant named: the Fetch standard asks again only for another
2127
+ * method or for a header the grant does not name, and a grant names its
2128
+ * headers by URL. Measured in Chromium and WebKit, not Firefox (0060).
2129
+ */
2130
+ const SAFELISTED = /* @__PURE__ */ new Set([
2131
+ "GET",
2132
+ "HEAD",
2133
+ "POST"
2134
+ ]);
2135
+ /**
2136
+ * What stops a server with origins at ready, as sentences: a route outside
2137
+ * the runtime table whose pattern could match a path a runtime pattern
2138
+ * matches -- any runtime pattern for a GET, HEAD or POST, which ride the
2139
+ * grant of whatever method was granted at that URL, and one of the same
2140
+ * method otherwise. Those are the requests a grant for the runtime route
2141
+ * would let a browser send to the other, and the case Fastify's parameters
2142
+ * cannot tell apart. OPTIONS routes are this file's, and a HEAD at a
2143
+ * runtime GET's own pattern is the one Fastify adds for it: no other can
2144
+ * be registered there. Any route whose pattern holds a regex parameter, a
2145
+ * wildcard or two parameters in one segment, or that has a constraint,
2146
+ * stops it too: the rule does not model those, and refuses rather than
2147
+ * guesses.
2148
+ */
2149
+ function routeProblems(routes) {
2150
+ const problems = [];
2151
+ const runtime = [...OFFERED].map((key) => ({
2152
+ key,
2153
+ method: key.slice(0, key.indexOf(" ")),
2154
+ segments: key.slice(key.indexOf(" ") + 1).split("/")
2155
+ }));
2156
+ for (const route of routes) {
2157
+ const segments = route.url.split("/");
2158
+ if (typeof route.constraints === "object" && route.constraints !== null && Object.keys(route.constraints).length > 0 || segments.some((segment) => segmentKind(segment) === "unmodelled")) {
2159
+ problems.push(`${route.method} ${route.url} has a pattern or a constraint the check that keeps the planes apart across origins does not model`);
2160
+ continue;
2161
+ }
2162
+ const key = `${route.method} ${route.url}`;
2163
+ if (route.method === "OPTIONS" || OFFERED.has(key) || route.method === "HEAD" && OFFERED.has(`GET ${route.url}`)) continue;
2164
+ for (const pattern of runtime) if ((SAFELISTED.has(route.method) || pattern.method === route.method) && overlap(segments, pattern.segments)) problems.push(`${key} could answer a path the runtime route ${pattern.key} answers`);
2165
+ }
2166
+ return problems;
2167
+ }
2168
+ /** Whether two routers' parameters are the same names with the same values, in any order. */
2169
+ function sameParameters(a, b) {
2170
+ const names = Object.keys(a);
2171
+ return names.length === Object.keys(b).length && names.every((name) => Object.hasOwn(b, name) && a[name] === b[name]);
2172
+ }
2173
+ /**
2174
+ * Called by createDataServer on every server, after the path-not-plain
2175
+ * refusal and before any route, so its hooks cover every route registered
2176
+ * after it.
2177
+ */
2178
+ function crossOrigin(app, options) {
2179
+ const listed = new Set(options.origins);
2180
+ app.addHook("preHandler", async (request, reply) => {
2181
+ if (request.routeOptions.url === void 0 || request.headers.authorization === void 0) return void 0;
2182
+ const site = request.headers["sec-fetch-site"];
2183
+ if (site !== "cross-site" && site !== "same-site") return void 0;
2184
+ const origin = request.headers.origin;
2185
+ const fromListed = typeof origin === "string" && listed.has(origin);
2186
+ if (fromListed && OFFERED.has(routeKey(request))) return void 0;
2187
+ request.log.info({
2188
+ reason: fromListed ? "route not offered across origins" : "origin not listed",
2189
+ ...loggable(origin)
2190
+ }, "cross-origin request refused");
2191
+ return reply.code(403).send(CROSS_ORIGIN_REFUSED);
2192
+ });
2193
+ if (listed.size === 0) return;
2194
+ const routes = [];
2195
+ app.addHook("onRoute", (route) => {
2196
+ for (const method of [route.method].flat()) routes.push({
2197
+ method,
2198
+ url: route.url,
2199
+ constraints: route.constraints
2200
+ });
2201
+ });
2202
+ app.addHook("onReady", async () => {
2203
+ const problems = routeProblems(routes);
2204
+ if (problems.length > 0) throw new Error(`With origins listed, the server does not start beside a route another plane could answer at a runtime path (0060): ${problems.join("; ")}.`);
2205
+ });
2206
+ app.addHook("onSend", async (request, reply, payload) => {
2207
+ if (!OFFERED.has(routeKey(request))) return payload;
2208
+ reply.header("vary", varyWith(reply.getHeader("vary"), "Origin"));
2209
+ const origin = request.headers.origin;
2210
+ if (typeof origin === "string" && listed.has(origin)) reply.header("access-control-allow-origin", origin);
2211
+ return payload;
2212
+ });
2213
+ /** A preflight at `pattern`, decided: the origin, the method the runtime table names here and the router takes to the same parameters, and only the headers the server reads. */
2214
+ const decide = (request, pattern) => {
2215
+ const origin = request.headers.origin;
2216
+ const method = request.headers["access-control-request-method"];
2217
+ if (typeof origin !== "string" || typeof method !== "string") return {
2218
+ granted: false,
2219
+ reason: "not a preflight"
2220
+ };
2221
+ if (!listed.has(origin)) return {
2222
+ granted: false,
2223
+ reason: "origin not listed"
2224
+ };
2225
+ if (!OFFERED.has(`${method} ${pattern}`)) return {
2226
+ granted: false,
2227
+ reason: "method not offered on this route"
2228
+ };
2229
+ const reached = app.findRoute({
2230
+ method,
2231
+ url: request.url
2232
+ });
2233
+ if (reached === null || !sameParameters(reached.params, request.params)) return {
2234
+ granted: false,
2235
+ reason: "method reaches another route"
2236
+ };
2237
+ if (!String(request.headers["access-control-request-headers"] ?? "").split(",").map((name) => name.trim().toLowerCase()).filter((name) => name !== "").every((name) => CROSS_ORIGIN_REQUEST_HEADERS.includes(name))) return {
2238
+ granted: false,
2239
+ reason: "header not allowed"
2240
+ };
2241
+ return {
2242
+ granted: true,
2243
+ origin,
2244
+ method
2245
+ };
2246
+ };
2247
+ const vary = "Origin, Access-Control-Request-Method, Access-Control-Request-Headers";
2248
+ for (const pattern of new Set([...OFFERED].map((key) => key.slice(key.indexOf(" ") + 1)))) app.options(pattern, { config: { rateLimit: false } }, async (request, reply) => {
2249
+ const decided = decide(request, pattern);
2250
+ if (!decided.granted) {
2251
+ request.log.info({
2252
+ reason: decided.reason,
2253
+ ...loggable(request.headers.origin)
2254
+ }, "preflight refused");
2255
+ return reply.code(403).header("vary", vary).send(CROSS_ORIGIN_REFUSED);
2256
+ }
2257
+ return reply.code(204).headers({
2258
+ "access-control-allow-origin": decided.origin,
2259
+ "access-control-allow-methods": decided.method,
2260
+ "access-control-allow-headers": CROSS_ORIGIN_REQUEST_HEADERS.join(", "),
2261
+ "access-control-max-age": String(600),
2262
+ vary
2263
+ }).send();
2264
+ });
2265
+ }
2266
+ //#endregion
2267
+ //#region src/paths.ts
2268
+ /**
2269
+ * The paths this server routes (0037).
2270
+ *
2271
+ * A proxy in front of the server may choose where to forward a request by its
2272
+ * path after decoding it, resolving its `.` and `..` segments and collapsing
2273
+ * its slashes. cloudflared 2026.10.0 was measured doing all three, so that
2274
+ * `/v1/forms/a/versions/..%2F..%2Fb` matched a door meant for the runtime
2275
+ * route `/v1/forms/b`, while Fastify, which routes the path as sent, ran the
2276
+ * administrator's version-read for it. Rather than model anybody's
2277
+ * normalisation, the server routes only a path in which there is nothing to
2278
+ * normalise, and refuses every other before any route's work.
2279
+ *
2280
+ * No name the server accepts needs more. A form id and a connection id start
2281
+ * with a lower-case letter or digit and hold only those, `.`, `-` and `_`; a
2282
+ * version is digits or `latest`; a lookup source is formancy's option-source
2283
+ * name, lower case, digits and hyphens. None can be `.` or `..`, and
2284
+ * `encodeURIComponent`, which the studio and data-client send names through,
2285
+ * leaves each as it is. paths.test.ts derives that for form and connection
2286
+ * ids from the store's and the allowlist's own checks; for versions and
2287
+ * sources it holds a sample.
2288
+ */
2289
+ /** One segment: RFC 3986's unreserved characters, its sub-delims, `:` and `@`. No `%`, no `\`, nothing outside ASCII. */
2290
+ const SEGMENT = /^[A-Za-z0-9\-._~!$&'()*+,;=:@]+$/;
2291
+ /**
2292
+ * Whether a request target is a plain path: before any `?`, a `/` and then
2293
+ * one or more segments, each non-empty, neither `.` nor `..`, and made only of
2294
+ * characters a path carries as themselves. So no escape, no backslash, no
2295
+ * empty segment (a doubled or trailing slash, or `/` alone), no dot segment
2296
+ * and no `#`. The query is not looked at: neither the router nor a door
2297
+ * routes by it.
2298
+ */
2299
+ function plainPath(target) {
2300
+ const query = target.indexOf("?");
2301
+ const path = query === -1 ? target : target.slice(0, query);
2302
+ if (!path.startsWith("/")) return false;
2303
+ return path.slice(1).split("/").every((segment) => segment !== "." && segment !== ".." && SEGMENT.test(segment));
2304
+ }
2305
+ /** The answer to a path that is not plain, with 400: a code and a sentence, and nothing of the path, which is whatever the caller typed. */
2306
+ const PATH_NOT_PLAIN = {
2307
+ code: "path-not-plain",
2308
+ message: "This path holds an encoded character, an empty or a dot segment, or a character a path does not carry as itself. No name here is written that way, and a proxy in front of this server may read such a path differently, so it was not routed to any route's work."
2309
+ };
2310
+ //#endregion
2311
+ //#region src/rate-limit.ts
2312
+ /**
2313
+ * The per-address request limit, and the setting that changes it.
2314
+ *
2315
+ * `FORMANCY_DATA_RATE_LIMIT` is read by the composition root (main.ts) and
2316
+ * parsed here, so the rule is tested without starting a process.
2317
+ */
2318
+ /** Requests per client address per window, unless the operator says otherwise: 600 a minute, as every earlier release had. */
2319
+ const DEFAULT_RATE_LIMIT = {
2320
+ max: 600,
2321
+ timeWindowMs: 6e4
2322
+ };
2323
+ /** What main.ts prints, and stops, when the setting is anything but a whole number of at least one. */
2324
+ const RATE_LIMIT_REFUSAL = "FORMANCY_DATA_RATE_LIMIT is a whole number of requests a minute per client address, at least 1.";
2325
+ /**
2326
+ * `FORMANCY_DATA_RATE_LIMIT` as written: unset is the default; otherwise
2327
+ * decimal digits only, no sign, no fraction, no exponent, no space, and a
2328
+ * value a JavaScript number holds exactly. The window is always a minute, so
2329
+ * the number an operator writes is the number of requests.
2330
+ */
2331
+ function rateLimitSetting(text) {
2332
+ if (text === void 0) return {
2333
+ ok: true,
2334
+ rateLimit: { ...DEFAULT_RATE_LIMIT }
2335
+ };
2336
+ const max = /^[0-9]+$/.test(text) ? Number(text) : NaN;
2337
+ if (!Number.isSafeInteger(max) || max < 1) return {
2338
+ ok: false,
2339
+ problem: RATE_LIMIT_REFUSAL
2340
+ };
2341
+ return {
2342
+ ok: true,
2343
+ rateLimit: {
2344
+ max,
2345
+ timeWindowMs: DEFAULT_RATE_LIMIT.timeWindowMs
2346
+ }
2347
+ };
2348
+ }
2349
+ //#endregion
2350
+ //#region src/reassigned.ts
2351
+ function isRecord$3(value) {
2352
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2353
+ }
2354
+ function anchorOf(value) {
2355
+ if (!isRecord$3(value)) return void 0;
2356
+ if (value["kind"] === "column" && typeof value["column"] === "string") return {
2357
+ kind: "column",
2358
+ column: value["column"]
2359
+ };
2360
+ if (value["kind"] === "lookup" && typeof value["foreignKey"] === "string") return {
2361
+ kind: "lookup",
2362
+ foreignKey: value["foreignKey"]
2363
+ };
2364
+ }
2365
+ /**
2366
+ * A publish's `keysConfirmed`: absent is none, and anything but a list of
2367
+ * `{ field, was, now }`, each anchor a column or a lookup's foreign key, is
2368
+ * `undefined`. Each entry is rebuilt from what it names, so nothing else a
2369
+ * client sent is carried.
2370
+ */
2371
+ function readKeysConfirmed(value) {
2372
+ if (value === void 0) return [];
2373
+ if (!Array.isArray(value)) return void 0;
2374
+ const keys = [];
2375
+ for (const entry of value) {
2376
+ if (!isRecord$3(entry) || typeof entry["field"] !== "string") return void 0;
2377
+ const was = anchorOf(entry["was"]);
2378
+ const now = anchorOf(entry["now"]);
2379
+ if (was === void 0 || now === void 0) return void 0;
2380
+ keys.push({
2381
+ field: entry["field"],
2382
+ was,
2383
+ now
2384
+ });
2385
+ }
2386
+ return keys;
2387
+ }
2388
+ const sameAnchor = (left, right) => left.kind === "column" ? right.kind === "column" && left.column === right.column : right.kind === "lookup" && left.foreignKey === right.foreignKey;
2389
+ /**
2390
+ * The keys `replaced` bound to another column or lookup than `published`
2391
+ * does, that `published`'s policy grants on -- a role on the key's field or
2392
+ * a through that names it (0043), `grantsOnKey`, the function the studio
2393
+ * asks with -- and that `confirmed` does not name exactly as reassigned: the
2394
+ * same key, the column or lookup it stood for, and the one it stands for
2395
+ * now. A confirmation matched by key
2396
+ * alone would carry a decision to a column nobody was shown -- a studio
2397
+ * draft generated again after its keys were decided can renumber one again.
2398
+ * One for a key that was not reassigned grants nothing, and is ignored.
2399
+ *
2400
+ * An anchor is a column's or a foreign key's name, not its table's: a
2401
+ * publish that moves the form to another root or connection is compared by
2402
+ * name, and its grants follow their keys unasked (0039's costs).
2403
+ */
2404
+ function unconfirmedKeys(replaced, published, confirmed) {
2405
+ return reassignedKeys(replaced, published.bindings).filter((key) => grantsOnKey(published.policy, key.field) && !confirmed.some((entry) => entry.field === key.field && sameAnchor(entry.was, key.was) && sameAnchor(entry.now, key.now)));
2406
+ }
2407
+ //#endregion
2408
+ //#region src/publishing.ts
2409
+ const INVALID = "The bundle cannot be published.";
2410
+ /**
2411
+ * Whether `document` may be written as a version of form `id`, in the order
2412
+ * the publish route has always asked: it validates (0019); it is format 2,
2413
+ * which keeps its generation request (0030); its form is `id`; its
2414
+ * connection is one `allowlisted` knows; and this server's generator writes
2415
+ * its base and bindings from its snapshot and request (0030) -- asked here,
2416
+ * at the write, and never on read, so a later generator cannot make a stored
2417
+ * version corrupt.
2418
+ *
2419
+ * `unknown` is the sentence for a connection the allowlist does not know:
2420
+ * the import says why a name has to match across deployments.
2421
+ */
2422
+ function storable(document, id, allowlisted, unknown = (connection) => `No connection is called ${connection}.`) {
2423
+ const checked = validateBundle(document);
2424
+ if (!checked.ok) return {
2425
+ ok: false,
2426
+ refusal: {
2427
+ status: 422,
2428
+ code: "invalid-bundle",
2429
+ message: INVALID,
2430
+ extra: { problems: checked.problems }
2431
+ }
2432
+ };
2433
+ const bundle = checked.bundle;
2434
+ const refused = (message, extra) => ({
2435
+ ok: false,
2436
+ refusal: {
2437
+ status: 422,
2438
+ code: "invalid-bundle",
2439
+ message,
2440
+ ...extra === void 0 ? {} : { extra }
2441
+ },
2442
+ connection: bundle.connection
2443
+ });
2444
+ if (bundle.format === 1) return refused(INVALID, { problems: ["publish format 2: since 0030 a version keeps its generation request, generated base and presentation"] });
2445
+ if (bundle.form.id !== id) return refused(`The form's id is ${bundle.form.id}, not ${id}.`);
2446
+ if (!allowlisted(bundle.connection)) return refused(unknown(bundle.connection));
2447
+ const generated = generatedProblems(bundle);
2448
+ if (generated.length > 0) return refused(INVALID, { problems: generated });
2449
+ return {
2450
+ ok: true,
2451
+ bundle
2452
+ };
2453
+ }
2454
+ /**
2455
+ * The keys a write over `expectedBase` grants on that the version it
2456
+ * replaces bound to something else, and that it does not confirm (0039).
2457
+ * Nothing to compare for a first version, or for a base that is not there
2458
+ * -- below 1, or past the newest -- which the store's compare-and-swap
2459
+ * refuses; nor for a version that does not validate, or whose file does
2460
+ * not parse, which is not served (0019), so none of its grants is in
2461
+ * effect -- and publishing over it is how it is replaced. Any other
2462
+ * failure to read the base throws: a check that cannot answer refuses.
2463
+ */
2464
+ async function unconfirmedAgainst(store, id, expectedBase, bundle, confirmed, log) {
2465
+ if (expectedBase === null || expectedBase < 1) return [];
2466
+ let replaced;
2467
+ try {
2468
+ replaced = await loadVersion(store, id, expectedBase, log);
2469
+ } catch (error) {
2470
+ if (!(error instanceof UnparsableVersionError)) throw error;
2471
+ log.error({
2472
+ form: id,
2473
+ version: expectedBase
2474
+ }, "a published version does not parse, and is not compared");
2475
+ return [];
2476
+ }
2477
+ return replaced.ok ? unconfirmedKeys(replaced.bundle.bindings, bundle, confirmed) : [];
2478
+ }
2479
+ /** The conflict a compare-and-swap answers with: somebody wrote `current` first. */
2480
+ function conflictOver(current) {
2481
+ return {
2482
+ status: 409,
2483
+ code: "conflict",
2484
+ message: "Somebody published first. Rebase on the current version and try again.",
2485
+ extra: { current }
2486
+ };
2487
+ }
2488
+ /**
2489
+ * The refusal for a write over `expectedBase` whose keys are not all
2490
+ * confirmed (0039), or `undefined` when there is none. Compared with a
2491
+ * version somebody has replaced, the keys may say something else, so the
2492
+ * conflict is the answer then.
2493
+ */
2494
+ async function keysRefusal(store, id, expectedBase, bundle, confirmed, log) {
2495
+ const keys = await unconfirmedAgainst(store, id, expectedBase, bundle, confirmed, log);
2496
+ if (keys.length === 0) return void 0;
2497
+ const current = await store.latest(id);
2498
+ if (current !== expectedBase) return conflictOver(current);
2499
+ return {
2500
+ status: 422,
2501
+ code: "keys-reassigned",
2502
+ message: `Version ${String(expectedBase)} bound ${keys.map((key) => key.field).join(", ")} to other columns or lookups: confirm the grants on each for what it stands for now, or remove them. A grant is a role on the key's field, or a through that scopes this form's rows by it.`,
2503
+ extra: {
2504
+ keys,
2505
+ problems: keys.map(describeReassigned)
2506
+ }
2507
+ };
2508
+ }
2509
+ /**
2510
+ * Whether the database as `current` describes it can serve `bundle`, by
2511
+ * drift's rules (0010): a blocking change refuses, and a change to review or
2512
+ * note is reported and allowed. One gate for a restore and an import, which
2513
+ * both put a version already reviewed in front of a database it was not
2514
+ * generated from.
2515
+ *
2516
+ * Another engine is asked first, as its own answer: drift does not compare
2517
+ * across engines, and a connection pointed at another engine is the
2518
+ * administrator's to put right, not the server's failure. `diffSnapshots`
2519
+ * throws for nothing else a validated bundle can hold; anything it does
2520
+ * throw is the caller's 500.
2521
+ */
2522
+ function servable(bundle, current) {
2523
+ if (bundle.snapshot.kind !== current.kind) return {
2524
+ ok: false,
2525
+ code: "other-engine",
2526
+ bound: bundle.snapshot.kind,
2527
+ database: current.kind
2528
+ };
2529
+ const drift = diffSnapshots(bundle.snapshot, current, bundle.bindings, bundle.policy);
2530
+ if (drift.blocking) return {
2531
+ ok: false,
2532
+ code: "incompatible",
2533
+ changes: drift.changes.filter((change) => change.severity === "blocking")
2534
+ };
2535
+ return {
2536
+ ok: true,
2537
+ drift
2538
+ };
2539
+ }
2540
+ //#endregion
2541
+ //#region src/routes/admin-audit.ts
2542
+ /**
2543
+ * The audit name of every administrator route, keyed `METHOD url` (0033).
2544
+ * A route registered on the plane without an entry here stops the server
2545
+ * from starting; `admin-audit.test.ts` sends every route the router holds
2546
+ * and fails on an entry no route has.
2547
+ */
2548
+ const ADMIN_OPERATIONS = {
2549
+ "GET /v1/connections": "connection-list",
2550
+ "POST /v1/connections/:id/test": "connection-test",
2551
+ "GET /v1/connections/:id/metadata": "discovery",
2552
+ "POST /v1/form-proposals": "proposal",
2553
+ "POST /v1/forms/:id/versions": "publish",
2554
+ "GET /v1/forms/:id/versions": "version-list",
2555
+ "GET /v1/forms/:id/versions/latest": "version-latest",
2556
+ "GET /v1/forms/:id/versions/:version": "version-read",
2557
+ "POST /v1/forms/:id/drift": "drift",
2558
+ "POST /v1/forms/:id/regenerations": "regeneration",
2559
+ "POST /v1/forms/:id/restorations": "restore",
2560
+ "POST /v1/forms/:id/imports": "import"
2561
+ };
2562
+ /** The request's trail, begun on first use: a refusal before any route ran has none, and needs none. */
2563
+ function adminTrail(request) {
2564
+ request.adminAudit ??= {};
2565
+ return request.adminAudit;
2566
+ }
2567
+ /**
2568
+ * The administrator's event: the shared base, the trail, and the path's own
2569
+ * names, each recorded only once it is known to be a name.
2570
+ *
2571
+ * A connection, from the path, a body or a stored bundle, is recorded only
2572
+ * when `known` -- the allowlist -- has it: a connection id is a name, never an
2573
+ * address (0019), and a connection string pasted where a name goes, with a
2574
+ * token or without, would otherwise put a host and a password in the trail.
2575
+ * Checked here, where every event is made, so no route can forget it.
2576
+ */
2577
+ function describeAdmin(known) {
2578
+ return (request, base) => {
2579
+ const trail = request.adminAudit ?? {};
2580
+ const url = request.routeOptions.url ?? "";
2581
+ const id = request.params.id;
2582
+ const path = typeof id === "string" ? id : void 0;
2583
+ const connection = trail.connection ?? (url.startsWith("/v1/connections/:id") ? path : void 0);
2584
+ return {
2585
+ at: base.at,
2586
+ plane: "admin",
2587
+ actor: base.actor,
2588
+ operation: base.operation,
2589
+ connection: connection !== void 0 && known(connection) ? connection : null,
2590
+ form: auditedForm(trail.form ?? (url.startsWith("/v1/forms/:id") ? path : void 0)),
2591
+ formVersion: trail.formVersion ?? null,
2592
+ expectedBase: trail.expectedBase ?? null,
2593
+ restoredFrom: trail.restoredFrom ?? null,
2594
+ status: base.status,
2595
+ outcome: base.outcome
2596
+ };
2597
+ };
2598
+ }
2599
+ //#endregion
2600
+ //#region src/routes/admin-evolution.ts
2601
+ /** A version in a path or a body: a whole number from 1, at most sixteen digits, as the store names its files. */
2602
+ const VERSION = /^[1-9][0-9]{0,15}$/;
2603
+ function versionOf(text) {
2604
+ if (!VERSION.test(text)) return void 0;
2605
+ const version = Number(text);
2606
+ return Number.isSafeInteger(version) ? version : void 0;
2607
+ }
2608
+ function isVersion(value) {
2609
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
2610
+ }
2611
+ function isRecord$2(value) {
2612
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2613
+ }
2614
+ /**
2615
+ * The form generated again from the stored request against the database as
2616
+ * it is now, keeping each lookup the runtime would still build.
2617
+ *
2618
+ * First without lookups: a failure there is not a lookup's — the root, a
2619
+ * pinned or version column — and nothing can be generated. Then each lookup
2620
+ * alone, put through `buildLookupConfig` exactly as the runtime builds it
2621
+ * (0028), which refuses more than the generator does: a display column with
2622
+ * no text form, a float key. A lookup either refuses is left out with the
2623
+ * sentence that refused it, which names only approved metadata.
2624
+ */
2625
+ function regenerate(bundle, current) {
2626
+ const attempt = (lookups) => generateForm(current, {
2627
+ ...bundle.generation,
2628
+ lookups
2629
+ });
2630
+ try {
2631
+ attempt([]);
2632
+ } catch (error) {
2633
+ return {
2634
+ ok: false,
2635
+ message: error.message
2636
+ };
2637
+ }
2638
+ const kept = [];
2639
+ const dropped = [];
2640
+ for (const choice of bundle.generation.lookups) try {
2641
+ const trial = attempt([choice]);
2642
+ const field = trial.bindings.fields.find((binding) => binding.kind === "lookup" && binding.foreignKey === choice.foreignKey);
2643
+ if (field === void 0) throw new Error(`${choice.foreignKey} was not generated as a lookup`);
2644
+ buildLookupConfig(trial.bindings, field.field, { snapshot: current });
2645
+ kept.push(choice);
2646
+ } catch (error) {
2647
+ dropped.push({
2648
+ foreignKey: choice.foreignKey,
2649
+ message: error.message
2650
+ });
2651
+ }
2652
+ const generation = {
2653
+ ...bundle.generation,
2654
+ lookups: kept
2655
+ };
2656
+ try {
2657
+ return {
2658
+ ok: true,
2659
+ generated: generateForm(current, generation),
2660
+ generation,
2661
+ dropped
2662
+ };
2663
+ } catch (error) {
2664
+ return {
2665
+ ok: false,
2666
+ message: error.message
2667
+ };
2668
+ }
2669
+ }
2670
+ /**
2671
+ * Versions, regeneration and restore (0030), on the administrator plane:
2672
+ * registered by `adminRoutes` after its role check, so the check covers them.
2673
+ */
2674
+ function evolutionRoutes(app, { store, discover, refuse }) {
2675
+ /** Something that cannot happen for a stored, validated bundle: logged for the operator, 500 for the caller. */
2676
+ function internal(reply, id, error) {
2677
+ reply.log.error({
2678
+ form: id,
2679
+ error: error.message
2680
+ }, "a regeneration or restore failed where it cannot");
2681
+ return refuse(reply, 500, "internal", "This could not be completed; the operator's log says why.");
2682
+ }
2683
+ app.get("/v1/forms/:id/versions", async (request, reply) => {
2684
+ const id = request.params.id;
2685
+ const versions = FORM_ID.test(id) ? await store.versions(id) : [];
2686
+ if (versions.length === 0) return refuse(reply, 404, "unknown-form", `No form is called ${id}.`);
2687
+ return { versions };
2688
+ });
2689
+ app.get("/v1/forms/:id/versions/:version", async (request, reply) => {
2690
+ const version = versionOf(request.params.version);
2691
+ if (version === void 0) return refuse(reply, 400, "invalid-request", "A version is a whole number from 1.");
2692
+ const loaded = await loadVersion(store, request.params.id, version, reply.log);
2693
+ if (!loaded.ok) return refuse(reply, loaded.status, loaded.code, loaded.message);
2694
+ const trail = adminTrail(request);
2695
+ trail.formVersion = version;
2696
+ trail.connection = loaded.bundle.connection;
2697
+ return {
2698
+ version,
2699
+ bundle: loaded.bundle
2700
+ };
2701
+ });
2702
+ app.post("/v1/forms/:id/regenerations", async (request, reply) => {
2703
+ const id = request.params.id;
2704
+ const loaded = await loadPublished(store, id, reply.log);
2705
+ if (!loaded.ok) return refuse(reply, loaded.status, loaded.code, loaded.message);
2706
+ const bundle = loaded.bundle;
2707
+ const trail = adminTrail(request);
2708
+ trail.formVersion = loaded.version;
2709
+ trail.connection = bundle.connection;
2710
+ if (bundle.format === 1) return refuse(reply, 409, "published-before-0030", `Version ${String(loaded.version)} was published before 0030 and kept no generation request: propose the form again.`);
2711
+ const current = await discover(bundle.connection, reply);
2712
+ if (current === void 0) return reply;
2713
+ let drift;
2714
+ try {
2715
+ drift = diffSnapshots(bundle.snapshot, current, bundle.bindings, bundle.policy);
2716
+ } catch (error) {
2717
+ return internal(reply, id, error);
2718
+ }
2719
+ const outcome = regenerate(bundle, current);
2720
+ if (!outcome.ok) return refuse(reply, 422, "cannot-generate", outcome.message, { drift });
2721
+ const { generated, generation, dropped } = outcome;
2722
+ let carried;
2723
+ try {
2724
+ carried = rebasePresentation({
2725
+ base: bundle.base,
2726
+ presentation: bundle.presentation,
2727
+ bindings: bundle.bindings
2728
+ }, {
2729
+ base: generated.form,
2730
+ bindings: generated.bindings
2731
+ });
2732
+ } catch (error) {
2733
+ return internal(reply, id, error);
2734
+ }
2735
+ const valid = validateSchema(carried.form);
2736
+ if (!valid.valid) return internal(reply, id, /* @__PURE__ */ new Error(`the carried form is not one formancy accepts: ${valid.errors.map((entry) => entry.message).join("; ")}`));
2737
+ return {
2738
+ version: loaded.version,
2739
+ drift,
2740
+ generation,
2741
+ base: generated.form,
2742
+ bindings: generated.bindings,
2743
+ notes: generated.notes,
2744
+ snapshot: current,
2745
+ policy: bundle.policy,
2746
+ policyProblems: policyProblems(current, generated.bindings, bundle.policy),
2747
+ presentation: carried.presentation,
2748
+ form: carried.form,
2749
+ conflicts: carried.conflicts,
2750
+ lookupsDropped: dropped,
2751
+ keysReassigned: reassignedKeys(bundle.bindings, generated.bindings)
2752
+ };
2753
+ });
2754
+ app.post("/v1/forms/:id/restorations", async (request, reply) => {
2755
+ const id = request.params.id;
2756
+ const body = request.body;
2757
+ if (!isRecord$2(body) || !isVersion(body["version"]) || !isVersion(body["expectedBase"])) return refuse(reply, 400, "invalid-request", "Expected { version: the version to restore, expectedBase: the newest version you saw }.");
2758
+ const version = body["version"];
2759
+ const trail = adminTrail(request);
2760
+ trail.restoredFrom = version;
2761
+ trail.expectedBase = body["expectedBase"];
2762
+ const loaded = await loadVersion(store, id, version, reply.log);
2763
+ if (!loaded.ok) return refuse(reply, loaded.status, loaded.code, loaded.message);
2764
+ trail.connection = loaded.bundle.connection;
2765
+ const current = await discover(loaded.bundle.connection, reply);
2766
+ if (current === void 0) return reply;
2767
+ let served;
2768
+ try {
2769
+ served = servable(loaded.bundle, current);
2770
+ } catch (error) {
2771
+ return internal(reply, id, error);
2772
+ }
2773
+ if (!served.ok && served.code === "other-engine") return refuse(reply, 422, "other-engine", `Version ${String(version)} cannot be restored: connection ${loaded.bundle.connection} is a ${served.database} database now, and the version is bound to ${served.bound}. A form's bindings belong to one engine.`);
2774
+ if (!served.ok) return refuse(reply, 409, "incompatible", `Version ${String(version)} cannot be restored: the database has changed in a way it cannot serve.`, { changes: served.changes });
2775
+ const drift = served.drift;
2776
+ const outcome = await store.publish(id, body["expectedBase"], loaded.bundle);
2777
+ if (!outcome.ok) return refuse(reply, 409, "conflict", "Somebody published first. Look at the current version and try again.", { current: outcome.current });
2778
+ trail.formVersion = outcome.version;
2779
+ const restoration = {
2780
+ version: outcome.version,
2781
+ restoredFrom: version,
2782
+ drift
2783
+ };
2784
+ return reply.code(201).send(restoration);
2785
+ });
2786
+ }
2787
+ //#endregion
2788
+ //#region src/routes/admin-promotion.ts
2789
+ function isRecord$1(value) {
2790
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2791
+ }
2792
+ /**
2793
+ * A published version moved between deployments (0062): the version file
2794
+ * another deployment's store wrote -- read there as `GET
2795
+ * /v1/forms/:id/versions/:version`, the export -- written here as a new
2796
+ * version, after the checks a publish makes and the gate a restore passes.
2797
+ * Registered by `adminRoutes` after its role check, so the check covers it.
2798
+ *
2799
+ * The bundle is written as it came, snapshot and all: what was reviewed
2800
+ * there is what runs here, and the runtime's root check (0041) and this
2801
+ * deployment's drift compare against that snapshot until a regeneration
2802
+ * replaces it. Nothing is renamed: the connection and the form keep their
2803
+ * names, because a lookup's source name is made from the connection's, so
2804
+ * a renamed connection is another generated base.
2805
+ */
2806
+ function promotionRoutes(app, { store, allowlisted, discover, refuse }) {
2807
+ const send = (reply, refusal) => refuse(reply, refusal.status, refusal.code, refusal.message, refusal.extra);
2808
+ app.post("/v1/forms/:id/imports", async (request, reply) => {
2809
+ const id = request.params.id;
2810
+ const body = request.body;
2811
+ if (!FORM_ID.test(id)) return refuse(reply, 400, "invalid-request", "A form id is lower-case letters, digits, dot, hyphen or underscore.");
2812
+ const shape = "Expected { expectedBase: the newest version here, or null for the first, bundle: the version file, keysConfirmed? }.";
2813
+ if (!isRecord$1(body) || !("expectedBase" in body) || !(body["expectedBase"] === null || Number.isSafeInteger(body["expectedBase"])) || !isRecord$1(body["bundle"])) return refuse(reply, 400, "invalid-request", shape);
2814
+ const expectedBase = body["expectedBase"];
2815
+ const trail = adminTrail(request);
2816
+ trail.expectedBase = expectedBase;
2817
+ const confirmed = readKeysConfirmed(body["keysConfirmed"]);
2818
+ if (confirmed === void 0) return refuse(reply, 400, "invalid-request", "Expected keysConfirmed, when sent, to list { field, was, now } as an import refused 422 keys-reassigned lists them in keys.");
2819
+ const checked = storable(body["bundle"], id, allowlisted, (name) => `No connection is called ${name} in this deployment: an import keeps the connection's name, which each deployment's allowlist resolves to its own database.`);
2820
+ if (!checked.ok) {
2821
+ if (checked.connection !== void 0) trail.connection = checked.connection;
2822
+ return send(reply, checked.refusal);
2823
+ }
2824
+ const bundle = checked.bundle;
2825
+ trail.connection = bundle.connection;
2826
+ const current = await discover(bundle.connection, reply);
2827
+ if (current === void 0) return reply;
2828
+ let served;
2829
+ try {
2830
+ served = servable(bundle, current);
2831
+ } catch (error) {
2832
+ reply.log.error({
2833
+ form: id,
2834
+ error: error.message
2835
+ }, "an import failed where it cannot");
2836
+ return refuse(reply, 500, "internal", "This could not be completed; the operator's log says why.");
2837
+ }
2838
+ if (!served.ok && served.code === "other-engine") return refuse(reply, 422, "other-engine", `This version cannot be imported: connection ${bundle.connection} is a ${served.database} database here, and the version is bound to ${served.bound}. A form's bindings belong to one engine.`);
2839
+ if (!served.ok) return refuse(reply, 409, "incompatible", "This version cannot be imported: this deployment's database differs from the one it was published from in a way it cannot serve.", { changes: served.changes });
2840
+ const keys = await keysRefusal(store, id, expectedBase, bundle, confirmed, reply.log);
2841
+ if (keys !== void 0) return send(reply, keys);
2842
+ const outcome = await store.publish(id, expectedBase, bundle);
2843
+ if (!outcome.ok) return send(reply, conflictOver(outcome.current));
2844
+ trail.formVersion = outcome.version;
2845
+ const importation = {
2846
+ version: outcome.version,
2847
+ drift: served.drift
2848
+ };
2849
+ return reply.code(201).send(importation);
2850
+ });
2851
+ }
2852
+ //#endregion
2853
+ //#region src/routes/admin.ts
2854
+ function refuse(reply, status, code, message, extra = {}) {
2855
+ return reply.code(status).send({
2856
+ code,
2857
+ message,
2858
+ ...extra
2859
+ });
2860
+ }
2861
+ /** Sends a refusal `publishing.ts` decided. */
2862
+ function send(reply, refusal) {
2863
+ return refuse(reply, refusal.status, refusal.code, refusal.message, refusal.extra);
2864
+ }
2865
+ function isRecord(value) {
2866
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2867
+ }
2868
+ /**
2869
+ * The administrator's plane: connections, discovery, proposals, publication,
2870
+ * drift, and since 0030 versions, regeneration and restore (plan section 13).
2871
+ * Every route requires a host identity holding one of `adminRoles` —
2872
+ * publishing a form and writing a business record are
2873
+ * different permissions, and nothing here touches a record.
2874
+ *
2875
+ * A database that cannot be reached is 503 with no detail: a driver's message
2876
+ * names hosts and ports, which is the operator's business and not the caller's.
2877
+ *
2878
+ * Every request is audited, one event each (0033), by hooks installed before
2879
+ * any route, so a route added here without an audit name stops the server.
2880
+ */
2881
+ async function adminRoutes(app, options) {
2882
+ const { registry, store } = options;
2883
+ const admins = new Set(options.adminRoles);
2884
+ if (typeof options.audit?.sink !== "function") throw new Error("The administrator's plane needs an audit sink: audit: { sink } (0033).");
2885
+ auditRequests(app, {
2886
+ names: ADMIN_OPERATIONS,
2887
+ sink: options.audit.sink,
2888
+ now: options.audit.now,
2889
+ describe: describeAdmin((id) => registry.scope(id) !== void 0)
2890
+ });
2891
+ app.addHook("preHandler", async (request, reply) => {
2892
+ const identity = await options.authenticate(request);
2893
+ if (identity === void 0) return reply.code(401).header("www-authenticate", "Bearer").send({
2894
+ code: "unauthenticated",
2895
+ message: "A valid host token is required."
2896
+ });
2897
+ request.identity = identity;
2898
+ if (!identity.actor.roles.some((role) => admins.has(role))) return refuse(reply, 403, "forbidden", "This action needs an administrator role.");
2899
+ });
2900
+ /** The connection's ports, or a reply already sent. */
2901
+ async function connection(id, reply) {
2902
+ let open;
2903
+ try {
2904
+ open = await registry.open(id);
2905
+ } catch (error) {
2906
+ reply.log.warn({
2907
+ connection: id,
2908
+ error: error.message
2909
+ }, "connection could not be opened");
2910
+ await refuse(reply, 503, "unavailable", `Connection ${id} cannot be reached.`);
2911
+ return;
2912
+ }
2913
+ if (open === void 0) await refuse(reply, 404, "unknown-connection", `No connection is called ${id}.`);
2914
+ return open;
2915
+ }
2916
+ async function discover(id, reply) {
2917
+ const scope = registry.scope(id);
2918
+ const open = await connection(id, reply);
2919
+ if (open === void 0 || scope === void 0) return void 0;
2920
+ try {
2921
+ return await open.adapter.discover(scope);
2922
+ } catch (error) {
2923
+ reply.log.warn({
2924
+ connection: id,
2925
+ error: error.message
2926
+ }, "discovery failed");
2927
+ await refuse(reply, 503, "unavailable", `Connection ${id} could not be read.`);
2928
+ return;
2929
+ }
2930
+ }
2931
+ app.get("/v1/connections", async () => ({ connections: registry.ids() }));
2932
+ app.post("/v1/connections/:id/test", async (request, reply) => {
2933
+ const open = await connection(request.params.id, reply);
2934
+ if (open === void 0) return reply;
2935
+ try {
2936
+ return await open.adapter.ping();
2937
+ } catch {
2938
+ return refuse(reply, 503, "unavailable", `Connection ${request.params.id} did not answer.`);
2939
+ }
2940
+ });
2941
+ app.get("/v1/connections/:id/metadata", async (request, reply) => {
2942
+ return await discover(request.params.id, reply) ?? reply;
2943
+ });
2944
+ app.post("/v1/form-proposals", async (request, reply) => {
2945
+ const body = request.body;
2946
+ if (!isRecord(body)) return refuse(reply, 400, "invalid-request", "Expected a JSON object.");
2947
+ const read = readGeneration(body);
2948
+ if (!read.ok) return refuse(reply, 400, "invalid-request", "Expected connection, root { schema, name }, a lower-case formId, title, and optional lookups, versionColumn and pinned columns.");
2949
+ const trail = adminTrail(request);
2950
+ trail.form = read.generation.formId;
2951
+ trail.connection = read.generation.connection;
2952
+ const snapshot = await discover(read.generation.connection, reply);
2953
+ if (snapshot === void 0) return reply;
2954
+ try {
2955
+ return {
2956
+ ...generateForm(snapshot, read.generation),
2957
+ snapshot,
2958
+ generation: read.generation
2959
+ };
2960
+ } catch (error) {
2961
+ return refuse(reply, 422, "cannot-generate", error.message);
2962
+ }
2963
+ });
2964
+ app.post("/v1/forms/:id/versions", async (request, reply) => {
2965
+ const id = request.params.id;
2966
+ const body = request.body;
2967
+ if (!FORM_ID.test(id)) return refuse(reply, 400, "invalid-request", "A form id is lower-case letters, digits, dot, hyphen or underscore.");
2968
+ if (!isRecord(body) || !("expectedBase" in body) || !(body["expectedBase"] === null || Number.isSafeInteger(body["expectedBase"]))) return refuse(reply, 400, "invalid-request", "Expected { expectedBase: the version you edited, or null for the first, bundle }.");
2969
+ const expectedBase = body["expectedBase"];
2970
+ const trail = adminTrail(request);
2971
+ trail.expectedBase = expectedBase;
2972
+ const confirmed = readKeysConfirmed(body["keysConfirmed"]);
2973
+ if (confirmed === void 0) return refuse(reply, 400, "invalid-request", "Expected keysConfirmed, when sent, to list { field, was, now } as a regeneration reports them in keysReassigned.");
2974
+ const checked = storable(body["bundle"], id, (name) => registry.scope(name) !== void 0);
2975
+ if (!checked.ok) {
2976
+ if (checked.connection !== void 0) trail.connection = checked.connection;
2977
+ return send(reply, checked.refusal);
2978
+ }
2979
+ const bundle = checked.bundle;
2980
+ trail.connection = bundle.connection;
2981
+ const keys = await keysRefusal(store, id, expectedBase, bundle, confirmed, reply.log);
2982
+ if (keys !== void 0) return send(reply, keys);
2983
+ const outcome = await store.publish(id, expectedBase, bundle);
2984
+ if (!outcome.ok) return send(reply, conflictOver(outcome.current));
2985
+ trail.formVersion = outcome.version;
2986
+ return reply.code(201).send({ version: outcome.version });
2987
+ });
2988
+ /** The newest published bundle, re-validated, or a reply already sent. */
2989
+ async function latest(id, reply) {
2990
+ const loaded = await loadPublished(store, id, reply.log);
2991
+ if (loaded.ok) {
2992
+ const trail = adminTrail(reply.request);
2993
+ trail.formVersion = loaded.version;
2994
+ trail.connection = loaded.bundle.connection;
2995
+ return {
2996
+ version: loaded.version,
2997
+ bundle: loaded.bundle
2998
+ };
2999
+ }
3000
+ await refuse(reply, loaded.status, loaded.code, loaded.message);
3001
+ }
3002
+ app.get("/v1/forms/:id/versions/latest", async (request, reply) => {
3003
+ return await latest(request.params.id, reply) ?? reply;
3004
+ });
3005
+ app.post("/v1/forms/:id/drift", async (request, reply) => {
3006
+ const published = await latest(request.params.id, reply);
3007
+ if (published === void 0) return reply;
3008
+ const current = await discover(published.bundle.connection, reply);
3009
+ if (current === void 0) return reply;
3010
+ return {
3011
+ version: published.version,
3012
+ ...diffSnapshots(published.bundle.snapshot, current, published.bundle.bindings, published.bundle.policy)
3013
+ };
3014
+ });
3015
+ evolutionRoutes(app, {
3016
+ store,
3017
+ discover,
3018
+ refuse
3019
+ });
3020
+ promotionRoutes(app, {
3021
+ store,
3022
+ allowlisted: (name) => registry.scope(name) !== void 0,
3023
+ discover,
3024
+ refuse
3025
+ });
3026
+ }
3027
+ //#endregion
3028
+ //#region src/app.ts
3029
+ /**
3030
+ * The largest request body accepted. A record is a form's worth of fields; a
3031
+ * megabyte is generous for that and small enough that a request cannot be used
3032
+ * to make the server buffer something large.
3033
+ */
3034
+ const BODY_LIMIT = 1048576;
3035
+ /**
3036
+ * A request as the log names it: its method and the route that answered it,
3037
+ * never the path, the query or the Host header it was sent with. Those are
3038
+ * whatever a caller typed, token or none -- a connection string pasted where
3039
+ * a connection's name goes, a password in a query -- and the log is the
3040
+ * stream the audit trail is written to, read by whoever runs the collector
3041
+ * (0033). Fastify's default writes the URL and the Host as sent.
3042
+ */
3043
+ function requestForLog(request) {
3044
+ const port = request.socket.remotePort;
3045
+ return {
3046
+ method: request.method,
3047
+ route: request.routeOptions.url ?? null,
3048
+ remoteAddress: request.ip,
3049
+ ...port === void 0 ? {} : { remotePort: port }
3050
+ };
3051
+ }
3052
+ /** Fastify's request lines, but for its 404 line, which names the path it was sent: here, a 404 is named by its method. */
3053
+ var RouteOnlyLog = class extends LogController {
3054
+ routeNotFound(request) {
3055
+ if (this.isLogDisabled(request)) return;
3056
+ request.log.info({ req: request }, "no route matches");
3057
+ }
3058
+ };
3059
+ function bearer(request) {
3060
+ const header = request.headers.authorization;
3061
+ if (header === void 0) return void 0;
3062
+ return /^Bearer ([A-Za-z0-9._~+/=-]+)$/.exec(header)?.[1];
3063
+ }
3064
+ /**
3065
+ * The HTTP surface, as far as it exists.
3066
+ *
3067
+ * The administrator's plane is registered when the server is given a store,
3068
+ * a connection registry and the roles that administer. The record and lookup
3069
+ * routes arrive with the operations they call: a route that answered before
3070
+ * the adapters behind it could would be a documented-but-inert endpoint, which
3071
+ * is the failure this repository refuses.
3072
+ *
3073
+ * `GET /v1/whoami` is for the person wiring a host application in: it returns
3074
+ * exactly the identity the server derived from the host's token, so a wrong
3075
+ * issuer, audience or claim mapping is visible before any form depends on it.
3076
+ */
3077
+ async function createDataServer(options) {
3078
+ const given = options.runtime?.origins ?? [];
3079
+ const origins = Array.isArray(given) ? Object.freeze([...given]) : given;
3080
+ const refused = originsProblem(origins);
3081
+ if (refused !== void 0) throw new Error(refused);
3082
+ const lookupDeadlineMs = options.runtime?.lookupDeadlineMs ?? DEFAULT_LOOKUP_DEADLINE_MS;
3083
+ const deadlineRefused = lookupDeadlineProblem("runtime.lookupDeadlineMs", lookupDeadlineMs);
3084
+ if (deadlineRefused !== void 0) throw new Error(deadlineRefused);
3085
+ const logger = options.logger ?? false;
3086
+ const app = Fastify({
3087
+ logger: logger === false ? false : {
3088
+ serializers: { req: requestForLog },
3089
+ ...logger === true ? {} : { stream: logger.stream }
3090
+ },
3091
+ logController: new RouteOnlyLog(),
3092
+ bodyLimit: BODY_LIMIT,
3093
+ routerOptions: { maxParamLength: 128 }
3094
+ });
3095
+ app.addHook("onSend", async (_request, reply, payload) => {
3096
+ reply.header("cache-control", "no-store");
3097
+ return payload;
3098
+ });
3099
+ const limit = options.rateLimit ?? DEFAULT_RATE_LIMIT;
3100
+ await app.register(rateLimit, {
3101
+ global: true,
3102
+ max: limit.max,
3103
+ timeWindow: limit.timeWindowMs
3104
+ });
3105
+ const counted = app.rateLimit();
3106
+ app.addHook("onRequest", async (request, reply) => {
3107
+ if (plainPath(request.url)) return void 0;
3108
+ await counted.call(app, request, reply);
3109
+ return reply.code(400).send(PATH_NOT_PLAIN);
3110
+ });
3111
+ crossOrigin(app, { origins });
3112
+ const authenticate = async (request) => {
3113
+ const token = bearer(request);
3114
+ if (token === void 0) return void 0;
3115
+ const outcome = await options.verifyIdentity(token);
3116
+ if (!outcome.ok) {
3117
+ request.log.info({ reason: outcome.reason }, "host identity refused");
3118
+ return;
3119
+ }
3120
+ return outcome.identity;
3121
+ };
3122
+ app.get("/health", { config: { rateLimit: false } }, async () => ({ status: "ok" }));
3123
+ app.get("/v1/whoami", async (request, reply) => {
3124
+ const identity = await authenticate(request);
3125
+ if (identity === void 0) return reply.code(401).header("www-authenticate", "Bearer").send({
3126
+ code: "unauthenticated",
3127
+ message: "A valid host token is required."
3128
+ });
3129
+ return identity;
3130
+ });
3131
+ if (options.admin !== void 0) await app.register(adminRoutes, {
3132
+ ...options.admin,
3133
+ authenticate
3134
+ });
3135
+ if (options.runtime !== void 0) {
3136
+ const { origins: _offered, lookupDeadlineMs: _checked, ...runtime } = options.runtime;
3137
+ await app.register(runtimeRoutes, {
3138
+ ...runtime,
3139
+ authenticate,
3140
+ lookupDeadlineMs
3141
+ });
3142
+ }
3143
+ return app;
3144
+ }
3145
+ //#endregion
3146
+ //#region src/connections.ts
3147
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
3148
+ /**
3149
+ * The allowlist, checked: every problem at once, so an operator fixes the file
3150
+ * in one pass rather than one restart per mistake.
3151
+ */
3152
+ function parseConnections(document) {
3153
+ if (!Array.isArray(document)) return {
3154
+ ok: false,
3155
+ problems: ["the connections file is a list"]
3156
+ };
3157
+ const problems = [];
3158
+ const seen = /* @__PURE__ */ new Set();
3159
+ const connections = [];
3160
+ document.forEach((entry, index) => {
3161
+ const where = `connections[${String(index)}]`;
3162
+ if (typeof entry !== "object" || entry === null) {
3163
+ problems.push(`${where} is not an object`);
3164
+ return;
3165
+ }
3166
+ const value = entry;
3167
+ const id = value["id"];
3168
+ const before = problems.length;
3169
+ if (typeof id !== "string" || !ID.test(id)) problems.push(`${where}.id must be lower-case letters, digits, dot, hyphen or underscore`);
3170
+ else if (seen.has(id)) problems.push(`${where}.id ${id} is used twice`);
3171
+ if (!isDatabaseKind(value["kind"])) problems.push(`${where}.kind must be postgres or sqlserver`);
3172
+ for (const field of [
3173
+ "host",
3174
+ "database",
3175
+ "user"
3176
+ ]) if (typeof value[field] !== "string" || value[field] === "") problems.push(`${where}.${field} is required`);
3177
+ const port = value["port"];
3178
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) problems.push(`${where}.port must be a port number`);
3179
+ const password = value["password"];
3180
+ if (typeof password !== "string" || !(password.startsWith("env:") || password.startsWith("file:"))) problems.push(`${where}.password must be a secret reference, env:NAME or file:/path`);
3181
+ const schemas = value["schemas"];
3182
+ if (!Array.isArray(schemas) || schemas.length === 0 || !schemas.every((schema) => typeof schema === "string" && schema !== "")) problems.push(`${where}.schemas must list at least one approved schema`);
3183
+ if (problems.length === before && typeof id === "string") {
3184
+ seen.add(id);
3185
+ connections.push(entry);
3186
+ }
3187
+ });
3188
+ return problems.length === 0 ? {
3189
+ ok: true,
3190
+ connections
3191
+ } : {
3192
+ ok: false,
3193
+ problems
3194
+ };
3195
+ }
3196
+ /**
3197
+ * The allowlisted connections, opened lazily through the composition root's
3198
+ * factories. A form names a connection; this is the only place that name
3199
+ * becomes a database, so a form cannot reach a database the operator did not
3200
+ * list.
3201
+ */
3202
+ function createConnectionRegistry(connections, factories, secrets) {
3203
+ const byId = new Map(connections.map((connection) => [connection.id, connection]));
3204
+ const opening = /* @__PURE__ */ new Map();
3205
+ return {
3206
+ ids: () => [...byId.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0),
3207
+ scope(id) {
3208
+ const config = byId.get(id);
3209
+ return config === void 0 ? void 0 : { schemas: [...config.schemas] };
3210
+ },
3211
+ async open(id) {
3212
+ const config = byId.get(id);
3213
+ if (config === void 0) return void 0;
3214
+ let pending = opening.get(id);
3215
+ if (pending === void 0) {
3216
+ const factory = factories[config.kind];
3217
+ if (factory === void 0) throw new Error(`no driver is configured for ${config.kind} connections`);
3218
+ pending = resolveSecret(config.password, secrets).then((password) => factory(config, password));
3219
+ opening.set(id, pending);
3220
+ pending.catch(() => opening.delete(id));
3221
+ }
3222
+ return pending;
3223
+ },
3224
+ async close() {
3225
+ const open = [...opening.values()];
3226
+ opening.clear();
3227
+ await Promise.allSettled(open.map(async (pending) => (await pending).adapter.close()));
3228
+ }
3229
+ };
3230
+ }
3231
+ //#endregion
3232
+ //#region src/drivers.ts
3233
+ /** TLS unless the operator turned it off, and certificates checked unless the operator trusts the server's. */
3234
+ function tls(config) {
3235
+ return {
3236
+ enabled: config.tls?.enabled ?? true,
3237
+ trust: config.tls?.trustServerCertificate ?? false
3238
+ };
3239
+ }
3240
+ /** The application name the lookups' client gives PostgreSQL, so a DBA tells it from the records' in pg_stat_activity (0061). */
3241
+ const POSTGRES_LOOKUPS_APPLICATION = "formancy-data lookups";
3242
+ /**
3243
+ * PostgreSQL. Pinged before it is handed out, so an allowlisted connection that
3244
+ * cannot be reached fails here, where the registry retries it next time,
3245
+ * rather than on the first record a person saves.
3246
+ *
3247
+ * Two clients (0061). Lookups run on one of their own, because PostgreSQL
3248
+ * delivers a cancel on a connection of its own, and a cancel that arrives
3249
+ * after its statement finished stops whatever that backend runs next: in the
3250
+ * 2026-10-14 probe a lookup's late cancel stopped the statement pipelined
3251
+ * behind it on a shared pool of one. On their own client a late cancel can
3252
+ * stop only another lookup -- which the adapter runs once more -- and never a
3253
+ * record read or write, which 0015 never retries. The cost is up to twice the
3254
+ * connections per allowlisted database, each pool at the driver's default.
3255
+ * The ping runs on the records' client; a failure ends both. Closing the
3256
+ * adapter closes both, each as the adapter closes its own.
3257
+ */
3258
+ const openPostgres = async (config, password) => {
3259
+ const { enabled, trust } = tls(config);
3260
+ const options = {
3261
+ host: config.host,
3262
+ port: config.port,
3263
+ database: config.database,
3264
+ user: config.user,
3265
+ password,
3266
+ tls: {
3267
+ enabled,
3268
+ rejectUnauthorized: !trust
3269
+ }
3270
+ };
3271
+ const sql = connectPostgres(options);
3272
+ const lookupSql = connectPostgres({
3273
+ ...options,
3274
+ applicationName: POSTGRES_LOOKUPS_APPLICATION
3275
+ });
3276
+ const records = createPostgresAdapter(sql);
3277
+ const lookups = createPostgresAdapter(lookupSql);
3278
+ const adapter = {
3279
+ ...records,
3280
+ close: async () => {
3281
+ await Promise.all([records.close(), lookups.close()]);
3282
+ }
3283
+ };
3284
+ try {
3285
+ await adapter.ping();
3286
+ } catch (error) {
3287
+ await Promise.all([sql.end({ timeout: 1 }), lookupSql.end({ timeout: 1 })]);
3288
+ throw error;
3289
+ }
3290
+ return {
3291
+ adapter,
3292
+ lookups: createPostgresLookups(lookupSql),
3293
+ records: createPostgresRecords(sql)
3294
+ };
3295
+ };
3296
+ /** SQL Server: no native module, no ODBC. */
3297
+ const openSqlServer = async (config, password) => {
3298
+ const { enabled, trust } = tls(config);
3299
+ const pool = await connectSqlServer({
3300
+ host: config.host,
3301
+ port: config.port,
3302
+ database: config.database,
3303
+ user: config.user,
3304
+ password,
3305
+ encrypt: enabled,
3306
+ trustServerCertificate: trust
3307
+ });
3308
+ return {
3309
+ adapter: createSqlServerAdapter(pool),
3310
+ lookups: createSqlServerLookups(pool),
3311
+ records: createSqlServerRecords(pool)
3312
+ };
3313
+ };
3314
+ /**
3315
+ * The drivers, one factory per engine. Each adapter package opens its own
3316
+ * connections (`connectPostgres`, `connectSqlServer`), so a connection and the
3317
+ * code that binds values on it always come from one copy of the driver — the
3318
+ * server imports no driver at all, and the end-to-end suite is what found why
3319
+ * that matters.
3320
+ */
3321
+ const DRIVER_FACTORIES = {
3322
+ postgres: openPostgres,
3323
+ sqlserver: openSqlServer
3324
+ };
3325
+ //#endregion
3326
+ export { rateLimitSetting as a, validateBundle as c, recordReference as d, UnparsableVersionError as f, createDataServer as i, lookupDeadlineSetting as l, createConnectionRegistry as n, corsOriginsSetting as o, createFileConfigurationStore as p, parseConnections as r, ROUTE_OPERATIONS as s, DRIVER_FACTORIES as t, logAuditSink as u };
3327
+
3328
+ //# sourceMappingURL=drivers-DtJsZ_HW.mjs.map