@formancy/server-core 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,29 +1,20 @@
1
+ import { createFormEngine, expressionProblems, getAt, runScenarios, unknownReferences } from "@formancy/core";
1
2
  import { CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution } from "@formancy/challenge";
2
3
  import { hmac } from "@noble/hashes/hmac.js";
3
4
  import { sha256 } from "@noble/hashes/sha2.js";
4
5
  import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
5
- import { createFormEngine, expressionProblems, runScenarios, unknownReferences } from "@formancy/core";
6
6
  import { diffSchemas, modelDataPaths, schemaHash } from "@formancy/spec";
7
7
  import { check } from "recheck";
8
8
  import { validateSchema } from "@formancy/spec/validate";
9
9
  import { comparedToLastRun, isModelRequestKind, modelBriefing, readScenario } from "@formancy/builder-core";
10
10
  //#region src/options-membership.ts
11
- /**
12
- * Every sourced answer in a submission, as `path → { source, value }`.
13
- *
14
- * Walked from the SCHEMA over the value, never from the value's own shape — the
15
- * same way `referencedFileIds` does it. A walk driven by what arrived would let a
16
- * client dodge the check by omitting a property, and hidden branches have already
17
- * been stripped by the engine before this runs.
18
- */
19
11
  function sourcedAnswers(schema, value) {
20
12
  const found = [];
21
13
  const walk = (fields, data, prefix) => {
22
14
  if (typeof data !== "object" || data === null) return;
23
- const record = data;
24
15
  for (const field of fields) {
25
16
  const at = prefix === "" ? field.key : `${prefix}.${field.key}`;
26
- const held = record[field.key];
17
+ const held = getAt(data, [field.key]);
27
18
  if (field.fields !== void 0) {
28
19
  if (Array.isArray(held)) for (const [index, row] of held.entries()) walk(field.fields, row, `${at}[${String(index)}]`);
29
20
  else walk(field.fields, held, at);
@@ -44,12 +35,6 @@ function sourcedAnswers(schema, value) {
44
35
  walk(schema.model.fields, value, "");
45
36
  return found;
46
37
  }
47
- /**
48
- * Ask each source about the values submitted for it.
49
- *
50
- * One call per source rather than per answer, so a deployment sees `IN (…)` and not
51
- * a query per row.
52
- */
53
38
  async function checkMembership(schema, value, sources) {
54
39
  const answers = sourcedAnswers(schema, value);
55
40
  if (answers.length === 0 || sources === void 0) return { ok: true };
@@ -64,7 +49,7 @@ async function checkMembership(schema, value, sources) {
64
49
  }
65
50
  const errors = {};
66
51
  for (const [name, group] of bySource) {
67
- const source = sources[name];
52
+ const source = Object.hasOwn(sources, name) ? sources[name] : void 0;
68
53
  if (source === void 0) return {
69
54
  ok: false,
70
55
  kind: "source_unavailable",
@@ -93,11 +78,6 @@ async function checkMembership(schema, value, sources) {
93
78
  errors
94
79
  };
95
80
  }
96
- /**
97
- * The source names a document uses, for the publish-time vocabulary check.
98
- *
99
- * Walked over the schema rather than over any data, because publishing has none.
100
- */
101
81
  function sourceNamesIn(schema) {
102
82
  const names = /* @__PURE__ */ new Set();
103
83
  const walk = (fields) => {
@@ -111,28 +91,6 @@ function sourceNamesIn(schema) {
111
91
  }
112
92
  //#endregion
113
93
  //#region src/audit.ts
114
- /**
115
- * Who did what, to which thing, and when.
116
- *
117
- * **Reads and exports are recorded as well as mutations** — `submission.read` and
118
- * `submission.exported` — which is what lets a self-hoster answer who downloaded four
119
- * thousand people's answers ([0057](../../../docs/decisions/0057-the-audit-log-records-reads.md)).
120
- *
121
- * **A row joins the mutation's transaction where there is one.** `insertSubmission` and
122
- * `publishVersion` both commit their audit row with the change it describes, so a
123
- * rollback leaves no trace saying it happened. A read has nothing to be atomic with, so
124
- * its row is appended after the rows are handed over and a failure to write it is logged
125
- * loudly rather than swallowed.
126
- *
127
- * **`detail` carries identifiers and counts, never submission content.** An audit log is
128
- * read by more people, kept longer and exported more freely than the data it describes.
129
- */
130
- /**
131
- * Every auditable action, as a closed list.
132
- *
133
- * Closed so a new route cannot invent its own spelling: an audit log where one event is
134
- * called two things is one nobody can query.
135
- */
136
94
  const AUDIT_ACTIONS = [
137
95
  "auth.login",
138
96
  "auth.login.failed",
@@ -149,13 +107,6 @@ const AUDIT_ACTIONS = [
149
107
  "delivery.replayed",
150
108
  "model.asked"
151
109
  ];
152
- /**
153
- * An entry from an actor, with the fields spread the way the record stores
154
- * them.
155
- *
156
- * A helper rather than a convention, because `actorKind` and `actorId` being
157
- * set together is exactly the sort of thing eleven call sites get wrong once.
158
- */
159
110
  function auditedBy(actor, draft) {
160
111
  return actor === void 0 ? draft : {
161
112
  ...draft,
@@ -165,38 +116,14 @@ function auditedBy(actor, draft) {
165
116
  }
166
117
  //#endregion
167
118
  //#region src/webhook.ts
168
- /**
169
- * What a webhook delivery looks like on the wire.
170
- *
171
- * The signature scheme is Stripe's, deliberately: receivers already have code
172
- * for it, and a scheme somebody has to implement from scratch is a scheme most
173
- * receivers will skip. Adopting a familiar one is the difference between a
174
- * verified webhook and a verified-in-principle one.
175
- */
176
119
  const EVENT_ID_HEADER = "x-formancy-event-id";
177
120
  const SIGNATURE_HEADER = "x-formancy-signature";
178
- /**
179
- * The signed payload is `timestamp.body`, not `body`.
180
- *
181
- * Without the timestamp inside the signature, a signature captured once stays
182
- * valid forever and can be replayed. With it, a receiver can reject anything
183
- * older than its tolerance, and the signature covers the timestamp it is
184
- * checking — so the timestamp cannot be edited either.
185
- */
186
121
  function signedPayload(timestampSeconds, body) {
187
122
  return `${String(timestampSeconds)}.${body}`;
188
123
  }
189
- /** Hex HMAC-SHA256 over the signed payload. */
190
124
  function signBody(secret, timestampSeconds, body) {
191
125
  return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(signedPayload(timestampSeconds, body))));
192
126
  }
193
- /**
194
- * The headers for one delivery attempt.
195
- *
196
- * The event id is stable across retries, which is what makes the receiver able
197
- * to be idempotent. Generating a fresh one per attempt would turn our retry
198
- * into their duplicate.
199
- */
200
127
  function deliveryHeaders(input) {
201
128
  return {
202
129
  "content-type": "application/json",
@@ -205,12 +132,6 @@ function deliveryHeaders(input) {
205
132
  "x-formancy-attempt": String(input.attempt)
206
133
  };
207
134
  }
208
- /**
209
- * Verify a delivery the way a receiver would.
210
- *
211
- * Exported because a scheme nobody can check is a scheme nobody does check —
212
- * this is the reference implementation, and the test for our own signing.
213
- */
214
135
  function verifySignature(input) {
215
136
  const parts = new Map(input.header.split(",").map((part) => {
216
137
  const at = part.indexOf("=");
@@ -223,85 +144,29 @@ function verifySignature(input) {
223
144
  if (Math.abs(input.nowSeconds - timestamp) > tolerance) return false;
224
145
  return timingSafeEqual(signBody(input.secret, timestamp, input.body), provided);
225
146
  }
226
- /**
227
- * Compare without leaking where the first difference is.
228
- *
229
- * `a === b` on a hex string returns as soon as two characters differ, and the
230
- * time it takes says how many leading characters were right — enough, over
231
- * many attempts, to recover a signature a byte at a time.
232
- */
233
147
  function timingSafeEqual(a, b) {
234
148
  if (a.length !== b.length) return false;
235
149
  let difference = 0;
236
150
  for (let index = 0; index < a.length; index += 1) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
237
151
  return difference === 0;
238
152
  }
239
- /**
240
- * How long to wait before attempt n, in milliseconds.
241
- *
242
- * Exponential, capped, with jitter. The jitter is the part that matters at
243
- * scale: without it every delivery queued by the same outage retries at the
244
- * same instant, and the receiver coming back up is knocked over by the
245
- * thundering herd of everyone's retries.
246
- */
247
153
  function retryDelayMs(attempt, random) {
248
154
  const base = Math.min(3e4 * 2 ** (attempt - 1), 216e5);
249
155
  return Math.floor(base * random());
250
156
  }
251
- /** Eight attempts reach roughly 24 hours with this schedule. */
252
157
  const MAX_ATTEMPTS = 8;
253
158
  //#endregion
254
159
  //#region src/breaker.ts
255
- /**
256
- * Stopping, when a destination has plainly stopped listening.
257
- *
258
- * Failure is counted per WEBHOOK as well as per delivery. Enough consecutive failures
259
- * and the breaker opens: nothing is attempted for that webhook until a cool-down passes,
260
- * then exactly one delivery goes through as a probe. It succeeds and the breaker closes;
261
- * it fails and the cool-down starts again.
262
- *
263
- * `consecutiveFailures` and `openedAt` live on the RECORD rather than in the worker's
264
- * memory, so the state survives a restart and can be shown on a screen — a self-hoster
265
- * has no operations team watching a dashboard
266
- * ([0058](../../../docs/decisions/0058-a-breaker-per-destination.md)).
267
- *
268
- * Everything here is a pure function of a record and a clock: decisions tangled into
269
- * network code can only be tested by making requests.
270
- */
271
- /**
272
- * How many consecutive failures open the breaker.
273
- *
274
- * Three, not one. A single failure is a bad minute — a deploy, a timeout, a
275
- * receiver restarting — and opening on it would make every routine blip a
276
- * visible outage. Three in a row is a destination that is not coming back on
277
- * its own.
278
- */
279
160
  const BREAKER_THRESHOLD = 3;
280
- /**
281
- * How long the breaker stays shut before one probe is allowed through.
282
- *
283
- * Five minutes: long enough that a restarting receiver is finished, short
284
- * enough that nobody notices the delay once it recovers. The retry schedule
285
- * handles patience within a delivery; this handles patience about a
286
- * destination.
287
- */
288
161
  const BREAKER_COOLDOWN_MS = 3e5;
289
162
  function breakerState(webhook, now) {
290
163
  if (webhook.consecutiveFailures < 3) return "closed";
291
164
  if (webhook.openedAt === null) return "closed";
292
165
  return now.getTime() - new Date(webhook.openedAt).getTime() >= 3e5 ? "half-open" : "open";
293
166
  }
294
- /** Whether a delivery for this webhook may be attempted now. */
295
167
  function mayAttempt(webhook, now) {
296
168
  return breakerState(webhook, now) !== "open";
297
169
  }
298
- /**
299
- * The webhook's health after one attempt against it.
300
- *
301
- * A success always closes the breaker completely. Decaying the count instead
302
- * would leave a destination that fails twice for every success permanently
303
- * one blip from opening, and that destination is working.
304
- */
305
170
  function afterWebhookAttempt(webhook, ok, now) {
306
171
  if (ok) return {
307
172
  ...webhook,
@@ -350,19 +215,6 @@ function afterAttempt(delivery, outcome, now, random) {
350
215
  lastError: reason
351
216
  };
352
217
  }
353
- /**
354
- * Drain one batch of due deliveries.
355
- *
356
- * Returns how many were attempted, so a caller can decide whether to come back
357
- * immediately or wait. Deliberately one batch rather than a loop that runs
358
- * forever: the thing that decides *when* to run belongs to the host, and a
359
- * function that never returns cannot be tested.
360
- */
361
- /**
362
- * Returns how many were ATTEMPTED — deliveries skipped because their
363
- * destination's breaker is open do not count, so a caller polling until the
364
- * queue is quiet does not spin on a dead endpoint.
365
- */
366
218
  async function drainOutbox(deps, limit = 20) {
367
219
  const now = deps.now();
368
220
  const due = await deps.storage.claimDueDeliveries(now.toISOString(), limit);
@@ -431,14 +283,6 @@ async function replayDelivery(deps, id) {
431
283
  }
432
284
  //#endregion
433
285
  //#region src/access.ts
434
- /**
435
- * Whether this caller may submit this form at all, before any of the work.
436
- *
437
- * Every branch that is not an explicit permission returns false. An allowlist
438
- * that exists but does not name the origin refuses; an origin header that is
439
- * absent refuses, because absent is not the same as allowed; an empty
440
- * allowlist refuses everything, because a list of no origins is a list.
441
- */
442
286
  function maySubmit(form, actor, origin) {
443
287
  if (actor === "authenticated") return true;
444
288
  if (form.accessSubmit !== "public") return false;
@@ -449,14 +293,6 @@ function maySubmit(form, actor, origin) {
449
293
  }
450
294
  //#endregion
451
295
  //#region src/uploads.ts
452
- /**
453
- * Agree where a file will go, and refuse it before a byte is sent.
454
- *
455
- * Checking here rather than after the upload is the difference between
456
- * refusing a 2 GB file and receiving one first. The browser's own `accept` and
457
- * size limits are a convenience for the person filling the form in; these are
458
- * the rule, because somebody posting to the endpoint directly has neither.
459
- */
460
296
  async function offerUpload(deps, input) {
461
297
  if (!maySubmit(input.form, input.actor, input.origin)) return {
462
298
  ok: false,
@@ -498,16 +334,6 @@ async function offerUpload(deps, input) {
498
334
  file
499
335
  };
500
336
  }
501
- /**
502
- * Whether an upload's bytes may be kept.
503
- *
504
- * **Fail closed.** A scanner that cannot be asked refuses the file, the same as one that
505
- * found something: a deployment that configured a scanner did so to keep unscanned files
506
- * out, and its being down is not a reason to let one in. The person is told to try again,
507
- * and the field offers exactly that. Without a scanner every upload is kept, as it was
508
- * before there was one — a supported state, said in the documentation rather than here
509
- * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
510
- */
511
337
  async function screenUpload(scanner, file, bytes) {
512
338
  if (scanner === void 0) return { ok: true };
513
339
  let verdict;
@@ -530,24 +356,6 @@ async function screenUpload(scanner, file, bytes) {
530
356
  finding: verdict.finding
531
357
  };
532
358
  }
533
- /**
534
- * Which files a submission may claim, checked but not yet claimed.
535
- *
536
- * The claiming itself is `insertSubmission`'s job, in the same call that
537
- * stores the submission, so one COMMIT decides both — the same reasoning
538
- * that put the webhook queue there. A submission exists if and only if the
539
- * files it names belong to it. Claiming separately gives the two failures
540
- * nobody can debug: a rolled-back submission leaving files claimed against
541
- * nothing, or an accepted submission referencing bytes the collector is about
542
- * to delete.
543
- *
544
- * Refuses rather than skips. A submission naming a file that does not exist,
545
- * belongs to another form, or is already claimed is not a submission to fix up
546
- * quietly: the first is a bug or somebody guessing ids; the second is how an
547
- * id from a form you may submit to would let you attach a file from one you
548
- * may not; the third would make deleting a submission either leak bytes or
549
- * break a different one.
550
- */
551
359
  async function filesToClaim(storage, input) {
552
360
  const ids = referencedFileIds(input.schema, input.data);
553
361
  if (ids.length === 0) return {
@@ -573,19 +381,10 @@ async function filesToClaim(storage, input) {
573
381
  ids: claimable.map((file) => file.id)
574
382
  };
575
383
  }
576
- /**
577
- * Files nobody claimed, old enough that nobody is going to.
578
- *
579
- * Returns the rows rather than deleting anything: the host removes the bytes
580
- * and then the rows, in that order, because a row without bytes is a broken
581
- * reference and bytes without a row are invisible rubbish. Of the two, the
582
- * second is the one you never find out about.
583
- */
584
384
  async function collectAbandonedFiles(storage, now, afterHours) {
585
385
  const before = (/* @__PURE__ */ new Date(now.getTime() - afterHours * 36e5)).toISOString();
586
386
  return storage.abandonedFiles(before);
587
387
  }
588
- /** The field at a data path, with `[]` treated as "inside this repeater". */
589
388
  function fileFieldAt(schema, path) {
590
389
  const segments = path.replace(/\[\]/g, "").split(".").filter((segment) => segment !== "");
591
390
  let fields = flattenPages(schema.model.fields);
@@ -597,14 +396,12 @@ function fileFieldAt(schema, path) {
597
396
  }
598
397
  return found;
599
398
  }
600
- /** A page contributes no segment to a data path. */
601
399
  function flattenPages(fields) {
602
400
  const out = [];
603
401
  for (const field of fields) if (field.type === "page") out.push(...flattenPages(field.fields ?? []));
604
402
  else out.push(field);
605
403
  return out;
606
404
  }
607
- /** The HTML `accept` grammar, as the engine reads it. */
608
405
  function accepted(accept, name, contentType) {
609
406
  if (accept === void 0 || accept.length === 0) return true;
610
407
  const lowerName = name.toLowerCase();
@@ -617,27 +414,12 @@ function accepted(accept, name, contentType) {
617
414
  return lowerType === rule;
618
415
  });
619
416
  }
620
- /**
621
- * Every file id a submission references.
622
- *
623
- * Driven by the SCHEMA, not by the shape of the values. Recognising an
624
- * attachment by "an object with an id" would let a client omit a property and
625
- * have its reference quietly not claimed — the submission would be accepted
626
- * carrying a dangling file id, and the collector would delete the bytes a day
627
- * later. Reading only the paths the model says are file fields cannot be
628
- * fooled that way, and a text answer that happens to look like a file is not
629
- * one.
630
- *
631
- * A repeater contributes rows the schema cannot know about, so the walk
632
- * expands over whatever rows the data actually has.
633
- */
634
417
  function referencedFileIds(schema, data) {
635
418
  const ids = [];
636
419
  const walk = (fields, value) => {
637
420
  if (value === null || typeof value !== "object" || Array.isArray(value)) return;
638
- const record = value;
639
421
  for (const field of flattenPages(fields)) {
640
- const held = record[field.key];
422
+ const held = getAt(value, [field.key]);
641
423
  if (held === void 0) continue;
642
424
  if (field.type === "file") {
643
425
  if (!Array.isArray(held)) continue;
@@ -657,68 +439,26 @@ function referencedFileIds(schema, data) {
657
439
  walk(schema.model.fields, data);
658
440
  return ids;
659
441
  }
660
- /** Re-exported so a host can see which paths a schema's file fields live at. */
661
442
  function fileFieldPaths(schema) {
662
443
  return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
663
444
  }
664
445
  //#endregion
665
446
  //#region src/signing.ts
666
- /**
667
- * The keys the public plane hands out: a draft's and a response's, each an HMAC under the
668
- * deployment's signing key over what it opens.
669
- *
670
- * Stateless, the shape the proof-of-work challenge uses
671
- * ([0059](../../../docs/decisions/0059-proof-of-work-not-a-captcha.md)): handing one out is
672
- * public and unauthenticated, so it must not be a write — a key kept in a table is a table an
673
- * attacker fills by asking for keys they never use. Both in one file, so what each is signed
674
- * over can be read side by side, and nobody has to check two files to see that one cannot
675
- * stand in for the other.
676
- */
677
447
  function signed(secret, message) {
678
448
  return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(message)));
679
449
  }
680
- /**
681
- * The key to a draft: HMAC over the form and the draft id
682
- * ([0062](../../../docs/decisions/0062-a-draft-carries-its-own-key.md)).
683
- *
684
- * Bound to the FORM as well as the id so a key cannot be carried to a draft of another form
685
- * that happens to share an id. The message is unchanged since 0.2.0, which is what keeps every
686
- * key handed out since then opening its draft.
687
- */
688
450
  function draftKey(secret, formId, draftId) {
689
451
  return signed(secret, `${formId}:${draftId}`);
690
452
  }
691
- /**
692
- * The token a response is sent with: the id it will be stored under, and an HMAC over the form
693
- * and that id ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
694
- *
695
- * The id travels in the clear because the server has to read it back, and the signature is
696
- * what makes it the server's choice rather than the sender's. The message begins with a word a
697
- * form id never is, so a draft's key — signed over `form:draft` — is never also a valid
698
- * signature for a response with the same id, and the reverse.
699
- */
700
453
  function submissionToken(secret, formId, id) {
701
454
  return `${id}.${signed(secret, `submission:${formId}:${id}`)}`;
702
455
  }
703
- /**
704
- * The id a token names, when this server signed it for this form; otherwise undefined.
705
- *
706
- * Read from the LAST dot, since the signature is hex and the id is whatever `newId` minted. A
707
- * forged token, another form's, one signed under a key this server no longer has, and one that
708
- * is not a token at all are the same answer: no id.
709
- */
710
456
  function submissionTokenId(secret, formId, token) {
711
457
  const dot = token.lastIndexOf(".");
712
458
  if (dot <= 0) return void 0;
713
459
  const id = token.slice(0, dot);
714
460
  return sameToken(token, submissionToken(secret, formId, id)) ? id : void 0;
715
461
  }
716
- /**
717
- * Compared in constant time.
718
- *
719
- * A byte-at-a-time comparison that returns early leaks how much of a guess was
720
- * right, and a token is guessed one byte at a time by exactly that signal.
721
- */
722
462
  function sameToken(a, b) {
723
463
  if (a.length !== b.length) return false;
724
464
  let differences = 0;
@@ -740,13 +480,6 @@ async function resolveForm(deps, path) {
740
480
  schemaHash: current.schemaHash
741
481
  };
742
482
  }
743
- /**
744
- * Change who may submit a form, and from where.
745
- *
746
- * Separate from publishing on purpose: access is a property of the deployment,
747
- * and changing it must not mint a new immutable form version or invalidate the
748
- * schema hash every client is holding.
749
- */
750
483
  async function setFormAccess(deps, input) {
751
484
  const form = await deps.storage.getFormByPath(input.path);
752
485
  if (form === void 0) return { ok: false };
@@ -756,9 +489,6 @@ async function setFormAccess(deps, input) {
756
489
  });
757
490
  return { ok: true };
758
491
  }
759
- /** The submissions of one form, newest first, each tagged with the schema
760
- * version that produced it. Undefined for an unknown form: the caller must
761
- * 404, and an empty list would lie about that. */
762
492
  async function listSubmissions(deps, path) {
763
493
  const form = await deps.storage.getFormByPath(path);
764
494
  if (form === void 0) return void 0;
@@ -771,16 +501,6 @@ async function listSubmissions(deps, path) {
771
501
  data: record.data
772
502
  }));
773
503
  }
774
- /**
775
- * Every submission of a form as CSV, columns UNIONED across schema versions:
776
- * the current version's columns lead in its own order, and columns that only
777
- * exist in older versions follow — data outlives the field that collected it,
778
- * and an export that silently dropped extinct columns would lose it.
779
- *
780
- * A repeater is one column carrying its rows as JSON: rows-per-cell beats
781
- * exploding one submission across several CSV lines, which breaks every
782
- * spreadsheet join a business user will try.
783
- */
784
504
  async function exportCsv(deps, path) {
785
505
  const form = await deps.storage.getFormByPath(path);
786
506
  if (form === void 0) return void 0;
@@ -810,8 +530,6 @@ async function exportCsv(deps, path) {
810
530
  }
811
531
  return lines.join("\n") + "\n";
812
532
  }
813
- /** One column per collected answer: leaves as dotted paths through groups,
814
- * a repeater as a single column, pages transparent as always. */
815
533
  function dataColumns(schema) {
816
534
  const columns = [];
817
535
  const walk = (fields, prefix) => {
@@ -823,11 +541,7 @@ function dataColumns(schema) {
823
541
  return columns;
824
542
  }
825
543
  function cellValue(data, column) {
826
- let current = data;
827
- for (const segment of column.split(".")) {
828
- if (current === null || typeof current !== "object") return "";
829
- current = current[segment];
830
- }
544
+ const current = getAt(data, column.split("."));
831
545
  if (current === void 0 || current === null) return "";
832
546
  if (typeof current === "object") return JSON.stringify(current);
833
547
  if (typeof current === "string" && FORMULA_STARTERS.has(current.charAt(0))) return "'" + current;
@@ -836,18 +550,6 @@ function cellValue(data, column) {
836
550
  function csvCell(value) {
837
551
  return /[",\n\r]/.test(value) ? `"${value.replaceAll("\"", "\"\"")}"` : value;
838
552
  }
839
- /**
840
- * Start a draft: the server picks the id, and signs it.
841
- *
842
- * The id is the server's because an id a caller supplies is an id a caller can
843
- * enumerate — and enumerating them was enough to read other people's
844
- * part-filled answers.
845
- *
846
- * A draft is one response, so it comes with the token that response is sent with, naming the
847
- * draft's own id ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)). Resuming
848
- * hands back the same one, so a response whose answer was lost and is sent again after a reload
849
- * is refused as already sent rather than stored twice.
850
- */
851
553
  async function startDraft(deps, input) {
852
554
  const current = await resolveForm(deps, input.path);
853
555
  if (current === void 0) return void 0;
@@ -858,8 +560,6 @@ async function startDraft(deps, input) {
858
560
  submissionToken: submissionToken(deps.draftSecret, current.formId, draftId)
859
561
  };
860
562
  }
861
- /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
862
- * for an unknown form. */
863
563
  async function saveDraft(deps, input) {
864
564
  const current = await resolveForm(deps, input.path);
865
565
  if (current === void 0) return void 0;
@@ -879,18 +579,6 @@ async function saveDraft(deps, input) {
879
579
  saved: true
880
580
  };
881
581
  }
882
- /**
883
- * Resume a draft — and migrate it LAZILY if the form was republished since,
884
- * which is versioning rule five: never migrate eagerly on publish, because
885
- * most drafts are abandoned and eager migration multiplies every publish by
886
- * every draft.
887
- *
888
- * diffSchemas drives the decision: `compatible` rebinds silently, `lossy`
889
- * rebinds with a report — declared renames carry their value to the new key,
890
- * removed fields move their value into `__orphaned` rather than deleting it —
891
- * and `breaking` refuses to rebind at all. A successful rebind is persisted,
892
- * so the migration runs once, not on every resume.
893
- */
894
582
  async function resumeDraft(deps, input) {
895
583
  const current = await resolveForm(deps, input.path);
896
584
  if (current === void 0) return void 0;
@@ -974,33 +662,26 @@ function applyRenames(fields, data) {
974
662
  applyRenames(field.fields ?? [], data);
975
663
  continue;
976
664
  }
977
- if (field.renamedFrom !== void 0 && !(field.key in data) && field.renamedFrom in data) {
665
+ if (field.renamedFrom !== void 0 && !Object.hasOwn(data, field.key) && Object.hasOwn(data, field.renamedFrom)) {
978
666
  data[field.key] = data[field.renamedFrom];
979
667
  delete data[field.renamedFrom];
980
668
  }
981
- if (field.type === "group" && typeof data[field.key] === "object" && data[field.key] !== null) applyRenames(field.fields ?? [], data[field.key]);
669
+ const held = getAt(data, [field.key]);
670
+ if (field.type === "group" && typeof held === "object" && held !== null) applyRenames(field.fields ?? [], held);
982
671
  }
983
672
  }
984
- /** Remove and return the value at a dotted path, pruning nothing else. */
985
673
  function takeAtDottedPath(data, dottedPath) {
986
674
  const segments = dottedPath.split(".");
987
- let parent = data;
988
- for (const segment of segments.slice(0, -1)) {
989
- const next = parent[segment];
990
- if (next === null || typeof next !== "object" || Array.isArray(next)) return void 0;
991
- parent = next;
992
- }
675
+ const parent = getAt(data, segments.slice(0, -1));
993
676
  const last = segments[segments.length - 1];
677
+ if (parent === null || typeof parent !== "object" || Array.isArray(parent) || !Object.hasOwn(parent, last)) return;
994
678
  const value = parent[last];
995
679
  delete parent[last];
996
680
  return value;
997
681
  }
998
- /** JSON-safe deep clone: draft data is JSON by construction, and this package
999
- * runs without structuredClone in its lib on purpose. */
1000
682
  function structuredCloneJson(value) {
1001
683
  return JSON.parse(JSON.stringify(value));
1002
684
  }
1003
- /** Every form with its current version — the admin's landing view. */
1004
685
  async function listForms(deps) {
1005
686
  const forms = await deps.storage.listForms();
1006
687
  const listed = [];
@@ -1017,8 +698,6 @@ async function listForms(deps) {
1017
698
  }
1018
699
  return listed;
1019
700
  }
1020
- /** A form's version history, newest first — metadata only, because a history
1021
- * view rendering fifty full schemas would be its own denial of service. */
1022
701
  async function listVersions(deps, path) {
1023
702
  const form = await deps.storage.getFormByPath(path);
1024
703
  if (form === void 0) return void 0;
@@ -1028,17 +707,30 @@ async function listVersions(deps, path) {
1028
707
  title: version.schema.title
1029
708
  }));
1030
709
  }
710
+ async function formVersion(deps, path, version) {
711
+ const form = await deps.storage.getFormByPath(path);
712
+ if (form === void 0) return {
713
+ ok: false,
714
+ kind: "unknown_form"
715
+ };
716
+ if (!/^[1-9]\d{0,8}$/.test(version)) return {
717
+ ok: false,
718
+ kind: "unknown_version"
719
+ };
720
+ const found = (await deps.storage.listVersionsByForm(form.id)).find((candidate) => candidate.version === Number(version));
721
+ if (found === void 0) return {
722
+ ok: false,
723
+ kind: "unknown_version"
724
+ };
725
+ return {
726
+ ok: true,
727
+ version: found.version,
728
+ schemaHash: found.schemaHash,
729
+ schema: found.schema
730
+ };
731
+ }
1031
732
  //#endregion
1032
733
  //#region src/submitting.ts
1033
- /**
1034
- * The form a respondent fills in, with the token their response is sent with.
1035
- *
1036
- * A fresh id on every call, because every reading is a response that might be sent: two
1037
- * people handed one token would find the second refused as already sent. Minting writes
1038
- * nothing — an HMAC over the form and the id — so handing one to everybody who reads the form
1039
- * costs this server a hash and fills no table
1040
- * ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
1041
- */
1042
734
  async function formToFill(deps, path) {
1043
735
  const resolved = await resolveForm(deps, path);
1044
736
  if (resolved === void 0) return void 0;
@@ -1047,20 +739,6 @@ async function formToFill(deps, path) {
1047
739
  submissionToken: submissionToken(deps.draftSecret, resolved.formId, deps.newId())
1048
740
  };
1049
741
  }
1050
- /**
1051
- * Accept one submission: resolve the version the client says it rendered,
1052
- * replay the engine over the submitted data, and store the CANONICAL result.
1053
- *
1054
- * The replay is the whole point. Computed values are recomputed and overwrite
1055
- * whatever arrived; visibility is evaluated server-side and hidden branches
1056
- * are stripped, so a client cannot smuggle data by lying about what was shown.
1057
- * Client validation is UX; this is truth.
1058
- *
1059
- * And it is stored once. A response is stored under the id its token names, and the storage
1060
- * refuses that id twice, so the same response sent again — a retry after an answer that never
1061
- * arrived, a replay — is told it was sent rather than stored as somebody else
1062
- * ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
1063
- */
1064
742
  async function createSubmission(deps, input) {
1065
743
  const form = await deps.storage.getFormByPath(input.path);
1066
744
  if (form === void 0) return {
@@ -1104,7 +782,7 @@ async function createSubmission(deps, input) {
1104
782
  };
1105
783
  const engine = createFormEngine({
1106
784
  schema: current.schema,
1107
- initialValue: input.data,
785
+ initialValue: input.data ?? {},
1108
786
  capabilities: deps.capabilities,
1109
787
  mode: "server",
1110
788
  ...deps.checks === void 0 ? {} : { checks: deps.checks }
@@ -1184,7 +862,6 @@ async function createSubmission(deps, input) {
1184
862
  }
1185
863
  //#endregion
1186
864
  //#region src/redos.ts
1187
- /** Every `pattern` in the model, with the data path of the field it sits on. */
1188
865
  function patternsIn(fields, prefix) {
1189
866
  const found = [];
1190
867
  for (const field of fields) {
@@ -1197,15 +874,6 @@ function patternsIn(fields, prefix) {
1197
874
  }
1198
875
  return found;
1199
876
  }
1200
- /**
1201
- * The patterns in this schema that an attacker could exploit, or an empty array
1202
- * when there are none.
1203
- *
1204
- * A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
1205
- * would make publishing depend on an analysis timeout, which is a worse failure
1206
- * than the one being prevented — an author whose legitimate form stops
1207
- * publishing intermittently has no way to act on that.
1208
- */
1209
877
  async function unsafePatterns(schema) {
1210
878
  const unsafe = [];
1211
879
  for (const { path, pattern } of patternsIn(schema.model.fields, "")) {
@@ -1222,11 +890,6 @@ async function unsafePatterns(schema) {
1222
890
  }
1223
891
  //#endregion
1224
892
  //#region src/testing/memory-storage.ts
1225
- /**
1226
- * The in-memory Storage — the second implementation that keeps the port
1227
- * honest, and the one unit tests run against. Deliberately dumb: Maps and
1228
- * scans, no cleverness to accidentally diverge from SQL semantics.
1229
- */
1230
893
  function createMemoryStorage() {
1231
894
  const forms = /* @__PURE__ */ new Map();
1232
895
  const webhooks = /* @__PURE__ */ new Map();
@@ -1418,11 +1081,6 @@ async function createLocalUser(deps, input) {
1418
1081
  id
1419
1082
  };
1420
1083
  }
1421
- /**
1422
- * A wrong password and an unknown email answer identically, and BOTH run a
1423
- * hash verification — the difference in shape or timing would be a
1424
- * user-enumeration oracle.
1425
- */
1426
1084
  async function authenticateLocal(deps, input) {
1427
1085
  const user = await deps.storage.getUserByEmail(input.email);
1428
1086
  const hash = user?.passwordHash ?? await deps.hashSecret(`decoy:${deps.newId()}`);
@@ -1437,8 +1095,6 @@ async function authenticateLocal(deps, input) {
1437
1095
  }
1438
1096
  };
1439
1097
  }
1440
- /** The visible prefix of an API key: enough to index and to name in a UI
1441
- * ("fmc_tok_a2c…"), never enough to authenticate. */
1442
1098
  const KEY_PREFIX_LENGTH = 12;
1443
1099
  async function createApiKey(deps, input) {
1444
1100
  if (input.name.trim() === "") return {
@@ -1499,26 +1155,6 @@ function can(actor, action) {
1499
1155
  }
1500
1156
  //#endregion
1501
1157
  //#region src/address.ts
1502
- /**
1503
- * Deciding whether an address is somewhere this server may fetch from.
1504
- *
1505
- * A self-hosted formancy sits inside somebody's private network and webhook
1506
- * URLs are written by whoever can edit a form, which makes the server a
1507
- * confused deputy: it can reach the database, the metadata service and every
1508
- * internal admin panel, and it will fetch whatever it is told to.
1509
- *
1510
- * This half is pure — given an address, is it private — so it lives here and is
1511
- * tested exhaustively. The rest lives in @formancy/server, because it cannot be
1512
- * pure: parsing a URL needs `URL`, and resolving a hostname then connecting to
1513
- * the address that was checked, rather than re-resolving, needs Node's DNS and
1514
- * a custom agent. This package has no @types/node by design, and hand-rolling
1515
- * a URL parser for a security check is how such a check gets quietly wrong.
1516
- * All three parts are necessary. Validating a URL and then handing it to fetch is defeated by DNS
1517
- * rebinding — the name resolves to a public address for the check and a private
1518
- * one for the request — and that is the difference between a mitigation and
1519
- * theatre.
1520
- */
1521
- /** Everything before the first dot, as four octets, or undefined. */
1522
1158
  function ipv4Octets(address) {
1523
1159
  const parts = address.split(".");
1524
1160
  if (parts.length !== 4) return void 0;
@@ -1529,12 +1165,6 @@ function ipv4Octets(address) {
1529
1165
  if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return void 0;
1530
1166
  return octets;
1531
1167
  }
1532
- /**
1533
- * Whether this address is one the server must not be pointed at.
1534
- *
1535
- * Unparseable input counts as private. An address this cannot classify is one
1536
- * it cannot vouch for, and the safe direction is refusing to fetch.
1537
- */
1538
1168
  function isPrivateAddress(address) {
1539
1169
  const trimmed = address.trim().toLowerCase();
1540
1170
  if (trimmed === "") return true;
@@ -1565,14 +1195,6 @@ function isPrivateAddress(address) {
1565
1195
  }
1566
1196
  return true;
1567
1197
  }
1568
- /**
1569
- * Whether this is a literal IP address rather than a hostname.
1570
- *
1571
- * The two need different treatment and conflating them breaks the check in
1572
- * both directions: `isPrivateAddress` fails closed on anything it cannot
1573
- * parse, which is correct for an address that came back from DNS and wrong for
1574
- * `example.ch`, where it would refuse every hostname in the world.
1575
- */
1576
1198
  function isIpLiteral(host) {
1577
1199
  const bare = host.trim().replace(/^\[|\]$/g, "");
1578
1200
  if (/^\d{1,3}(\.\d{1,3}){3}$/.test(bare)) return true;
@@ -1580,7 +1202,6 @@ function isIpLiteral(host) {
1580
1202
  }
1581
1203
  //#endregion
1582
1204
  //#region src/examples.ts
1583
- /** The examples kept for the form at `path`; none, for a form nobody wrote any for. */
1584
1205
  async function readExamples(deps, input) {
1585
1206
  if (!can(input.actor, "form.publish")) return {
1586
1207
  ok: false,
@@ -1600,14 +1221,6 @@ async function readExamples(deps, input) {
1600
1221
  }
1601
1222
  };
1602
1223
  }
1603
- /**
1604
- * Replace the examples of the form at `path`, and the sample they start from.
1605
- *
1606
- * Each is read as an example the way a builder reads a model's draft — `readScenario`, one
1607
- * decision for both — and a list with any item that is not one keeps nothing. Paths are not
1608
- * checked against the published form: an example is written for the form being edited, which
1609
- * may name a field no version has yet, and the runner says so when it runs.
1610
- */
1611
1224
  async function keepExamples(deps, input) {
1612
1225
  if (!can(input.actor, "form.publish")) return {
1613
1226
  ok: false,
@@ -1647,23 +1260,6 @@ async function keepExamples(deps, input) {
1647
1260
  examples: asExamples(read.scenarios, read.sample)
1648
1261
  };
1649
1262
  }
1650
- /**
1651
- * What publishing says about the form's examples: one sentence for each that held against the
1652
- * version the form has and does not hold against the one being published.
1653
- *
1654
- * **Run as the server replays a submission**, in `server` mode, from the kept sample, and
1655
- * compared by `comparedToLastRun` — the scenario panel's own verdict, so the publish cannot
1656
- * call something a regression the panel would not
1657
- * ([0091](../../../docs/decisions/0091-a-second-builder-is-a-binding.md)). An example that did
1658
- * not hold before either is not this publish's doing, and one that holds again is good news;
1659
- * neither is a warning.
1660
- *
1661
- * **It never refuses.** A publish may warn and never refuses over its examples
1662
- * ([0097](../../../docs/decisions/0097-a-publish-may-warn.md)): a rule changed on purpose stops
1663
- * its old example holding, and the person decides. A stored row that is not an example — one
1664
- * edited around `keepExamples` — is read out before the run, as the admin reads it out, and
1665
- * named; run as it was, the runner throws on it and every publish of the form is a 500.
1666
- */
1667
1263
  async function examplesThatStopHolding(storage, formId, before, after) {
1668
1264
  if (before === void 0) return [];
1669
1265
  const kept = readKept(await storage.getExamples(formId));
@@ -1686,7 +1282,6 @@ function asExamples(scenarios, sample) {
1686
1282
  ...sample === null ? {} : { sample }
1687
1283
  };
1688
1284
  }
1689
- /** `{ scenarios, sample? }` from a request body, or a sentence for everything that is not. */
1690
1285
  function readExamplesBody(body) {
1691
1286
  if (!isRecord(body) || !Array.isArray(body["scenarios"])) return { problems: ["Send { scenarios, sample? }, where scenarios is a list of examples."] };
1692
1287
  const read = readList(body["scenarios"], body["sample"]);
@@ -1695,11 +1290,6 @@ function readExamplesBody(body) {
1695
1290
  sample: read.sample
1696
1291
  };
1697
1292
  }
1698
- /**
1699
- * What a storage keeps, read as a list sent to be kept is read. The storage checks nothing
1700
- * (`Storage.keepExamples`), so a row written around the use-case comes back here as it was
1701
- * written, and only what reads as an example is handed on.
1702
- */
1703
1293
  function readKept(kept) {
1704
1294
  if (kept === void 0) return {
1705
1295
  scenarios: [],
@@ -1714,7 +1304,6 @@ function readKept(kept) {
1714
1304
  problems: ["What is kept as the examples is not a list of them.", ...read.problems]
1715
1305
  };
1716
1306
  }
1717
- /** Each item through `readScenario`, names once each, and the sample as answers by field. */
1718
1307
  function readList(items, sample) {
1719
1308
  const problems = [];
1720
1309
  if (sample !== void 0 && sample !== null && !isRecord(sample)) problems.push("The sample is the answers every example starts from, by field, and this is not that.");
@@ -1744,7 +1333,6 @@ function readList(items, sample) {
1744
1333
  function which(position, name) {
1745
1334
  return `Example ${String(position)}${name === void 0 ? "" : ` ("${name}")`}`;
1746
1335
  }
1747
- /** What each reason `readScenario` gives means, said for whoever sent the list. */
1748
1336
  const SHAPE_PROBLEMS = {
1749
1337
  "not-an-object": () => "is not an object.",
1750
1338
  "unknown-key": ({ part }) => `has "${part ?? ""}", which an example does not have.`,
@@ -1758,15 +1346,6 @@ function isRecord(value) {
1758
1346
  }
1759
1347
  //#endregion
1760
1348
  //#region src/publishing.ts
1761
- /**
1762
- * Publish a schema as a form's next version.
1763
- *
1764
- * This is the save gate: structural validation, then the ENGINE's own compile
1765
- * — type-checked expressions, kind policies and the cycle graph — so a form
1766
- * that can loop or read a ghost field is refused here and never persisted.
1767
- * Republishing the identical document is idempotent, because "deploy again"
1768
- * must never manufacture a version.
1769
- */
1770
1349
  async function publishForm(deps, input) {
1771
1350
  const validated = validateSchema(input.schema);
1772
1351
  if (!validated.valid) return {
@@ -1800,8 +1379,9 @@ async function publishForm(deps, input) {
1800
1379
  kind: "unsafe_pattern",
1801
1380
  patterns: unsafe
1802
1381
  };
1803
- if (deps.optionsSources !== void 0) {
1804
- const unknown = sourceNamesIn(schema).filter((name) => deps.optionsSources?.[name] === void 0);
1382
+ const sources = deps.optionsSources;
1383
+ if (sources !== void 0) {
1384
+ const unknown = sourceNamesIn(schema).filter((name) => !Object.hasOwn(sources, name) || sources[name] === void 0);
1805
1385
  if (unknown.length > 0) return {
1806
1386
  ok: false,
1807
1387
  kind: "unknown_options_source",
@@ -1904,14 +1484,6 @@ async function publishForm(deps, input) {
1904
1484
  warnings
1905
1485
  };
1906
1486
  }
1907
- /**
1908
- * The audit row for a publish, built here so it can travel INSIDE the
1909
- * transaction rather than being appended after it.
1910
- *
1911
- * Before this it was written by the route once all three storage calls had
1912
- * returned, which meant a publish could half-apply and still be recorded as
1913
- * having happened. Now it rolls back with everything else.
1914
- */
1915
1487
  function publishAudit(deps, input, about) {
1916
1488
  return {
1917
1489
  id: deps.newId(),
@@ -1932,13 +1504,6 @@ function publishAudit(deps, input, about) {
1932
1504
  }
1933
1505
  //#endregion
1934
1506
  //#region src/model.ts
1935
- /**
1936
- * Ask the deployment's model a builder's request, under the briefing for its kind.
1937
- *
1938
- * Whatever else the request carries is not read — a `system` above all. Resolves on every
1939
- * ending: an adapter that throws broke its contract, and is still a model that could not
1940
- * be asked.
1941
- */
1942
1507
  async function completeBuilderRequest(completer, request, cancellation) {
1943
1508
  const { kind, user } = request;
1944
1509
  if (!isModelRequestKind(kind)) return {
@@ -1963,6 +1528,6 @@ async function completeBuilderRequest(completer, request, cancellation) {
1963
1528
  }
1964
1529
  }
1965
1530
  //#endregion
1966
- export { AUDIT_ACTIONS, BREAKER_COOLDOWN_MS, BREAKER_THRESHOLD, CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, EVENT_ID_HEADER, MAX_ATTEMPTS, SIGNATURE_HEADER, afterAttempt, afterWebhookAttempt, auditedBy, authenticateApiKey, authenticateLocal, breakerState, can, checkMembership, collectAbandonedFiles, completeBuilderRequest, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, formToFill, hashOf, healthOf, isIpLiteral, isPrivateAddress, keepExamples, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, readExamples, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, screenUpload, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
1531
+ export { AUDIT_ACTIONS, BREAKER_COOLDOWN_MS, BREAKER_THRESHOLD, CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, EVENT_ID_HEADER, MAX_ATTEMPTS, SIGNATURE_HEADER, afterAttempt, afterWebhookAttempt, auditedBy, authenticateApiKey, authenticateLocal, breakerState, can, checkMembership, collectAbandonedFiles, completeBuilderRequest, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, formToFill, formVersion, hashOf, healthOf, isIpLiteral, isPrivateAddress, keepExamples, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, readExamples, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, screenUpload, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
1967
1532
 
1968
1533
  //# sourceMappingURL=index.mjs.map