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