@formancy/server-core 0.3.0 → 0.5.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
@@ -2,10 +2,11 @@ import { CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, decodeSolution, encodeSoluti
2
2
  import { hmac } from "@noble/hashes/hmac.js";
3
3
  import { sha256 } from "@noble/hashes/sha2.js";
4
4
  import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
5
+ import { createFormEngine, expressionProblems, runScenarios, unknownReferences } from "@formancy/core";
5
6
  import { diffSchemas, modelDataPaths, schemaHash } from "@formancy/spec";
6
- import { createFormEngine, expressionProblems, unknownReferences } from "@formancy/core";
7
7
  import { check } from "recheck";
8
8
  import { validateSchema } from "@formancy/spec/validate";
9
+ import { comparedToLastRun, isModelRequestKind, modelBriefing, readScenario } from "@formancy/builder-core";
9
10
  //#region src/options-membership.ts
10
11
  /**
11
12
  * Every sourced answer in a submission, as `path → { source, value }`.
@@ -137,6 +138,7 @@ const AUDIT_ACTIONS = [
137
138
  "auth.login.failed",
138
139
  "form.published",
139
140
  "form.access.changed",
141
+ "form.examples.changed",
140
142
  "submission.created",
141
143
  "submission.read",
142
144
  "submission.exported",
@@ -144,7 +146,8 @@ const AUDIT_ACTIONS = [
144
146
  "user.created",
145
147
  "apiKey.created",
146
148
  "apiKey.revoked",
147
- "delivery.replayed"
149
+ "delivery.replayed",
150
+ "model.asked"
148
151
  ];
149
152
  /**
150
153
  * An entry from an actor, with the fields spread the way the record stores
@@ -486,7 +489,8 @@ async function offerUpload(deps, input) {
486
489
  storageKey: `${input.form.id}/${id}`,
487
490
  state: "offered",
488
491
  createdAt: deps.now().toISOString(),
489
- submissionId: null
492
+ submissionId: null,
493
+ receivingUntil: null
490
494
  };
491
495
  await deps.storage.insertFile(file);
492
496
  return {
@@ -495,6 +499,38 @@ async function offerUpload(deps, input) {
495
499
  };
496
500
  }
497
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
+ async function screenUpload(scanner, file, bytes) {
512
+ if (scanner === void 0) return { ok: true };
513
+ let verdict;
514
+ try {
515
+ verdict = await scanner.scan({
516
+ name: file.name,
517
+ contentType: file.contentType,
518
+ size: file.size
519
+ }, bytes);
520
+ } catch (error) {
521
+ return {
522
+ ok: false,
523
+ reason: "scanner_unavailable",
524
+ cause: error instanceof Error ? error.message : String(error)
525
+ };
526
+ }
527
+ return verdict.clean ? { ok: true } : {
528
+ ok: false,
529
+ reason: "refused",
530
+ finding: verdict.finding
531
+ };
532
+ }
533
+ /**
498
534
  * Which files a submission may claim, checked but not yet claimed.
499
535
  *
500
536
  * The claiming itself is `insertSubmission`'s job, in the same call that
@@ -626,6 +662,70 @@ function fileFieldPaths(schema) {
626
662
  return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
627
663
  }
628
664
  //#endregion
665
+ //#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
+ function signed(secret, message) {
678
+ return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(message)));
679
+ }
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
+ function draftKey(secret, formId, draftId) {
689
+ return signed(secret, `${formId}:${draftId}`);
690
+ }
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
+ function submissionToken(secret, formId, id) {
701
+ return `${id}.${signed(secret, `submission:${formId}:${id}`)}`;
702
+ }
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
+ function submissionTokenId(secret, formId, token) {
711
+ const dot = token.lastIndexOf(".");
712
+ if (dot <= 0) return void 0;
713
+ const id = token.slice(0, dot);
714
+ return sameToken(token, submissionToken(secret, formId, id)) ? id : void 0;
715
+ }
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
+ function sameToken(a, b) {
723
+ if (a.length !== b.length) return false;
724
+ let differences = 0;
725
+ for (let at = 0; at < a.length; at += 1) differences |= a.charCodeAt(at) ^ b.charCodeAt(at);
726
+ return differences === 0;
727
+ }
728
+ //#endregion
629
729
  //#region src/use-cases.ts
630
730
  async function resolveForm(deps, path) {
631
731
  const form = await deps.storage.getFormByPath(path);
@@ -641,15 +741,6 @@ async function resolveForm(deps, path) {
641
741
  };
642
742
  }
643
743
  /**
644
- * Accept one submission: resolve the version the client says it rendered,
645
- * replay the engine over the submitted data, and store the CANONICAL result.
646
- *
647
- * The replay is the whole point. Computed values are recomputed and overwrite
648
- * whatever arrived; visibility is evaluated server-side and hidden branches
649
- * are stripped, so a client cannot smuggle data by lying about what was shown.
650
- * Client validation is UX; this is truth.
651
- */
652
- /**
653
744
  * Change who may submit a form, and from where.
654
745
  *
655
746
  * Separate from publishing on purpose: access is a property of the deployment,
@@ -665,102 +756,6 @@ async function setFormAccess(deps, input) {
665
756
  });
666
757
  return { ok: true };
667
758
  }
668
- async function createSubmission(deps, input) {
669
- const form = await deps.storage.getFormByPath(input.path);
670
- if (form === void 0) return {
671
- ok: false,
672
- kind: "unknown_form"
673
- };
674
- if (!maySubmit(form, input.actor ?? "anonymous", input.origin)) return {
675
- ok: false,
676
- kind: "forbidden"
677
- };
678
- const current = await resolveForm(deps, input.path);
679
- if (current === void 0) return {
680
- ok: false,
681
- kind: "unknown_form"
682
- };
683
- if (input.declaredSchemaHash !== current.schemaHash) return {
684
- ok: false,
685
- kind: "version_changed",
686
- current
687
- };
688
- const engine = createFormEngine({
689
- schema: current.schema,
690
- initialValue: input.data,
691
- capabilities: deps.capabilities,
692
- mode: "server",
693
- ...deps.checks === void 0 ? {} : { checks: deps.checks }
694
- });
695
- await engine.settle();
696
- const outcome = engine.submit();
697
- if (!outcome.ok) return {
698
- ok: false,
699
- kind: "invalid",
700
- errors: outcome.errors
701
- };
702
- const membership = await checkMembership(current.schema, engine.value(), deps.optionsSources);
703
- if (!membership.ok) return membership.kind === "invalid" ? {
704
- ok: false,
705
- kind: "invalid",
706
- errors: membership.errors
707
- } : {
708
- ok: false,
709
- kind: "source_unavailable",
710
- source: membership.source
711
- };
712
- const id = deps.newId();
713
- const hooks = await deps.storage.webhooksForForm(current.formId);
714
- const body = JSON.stringify({
715
- id,
716
- form: input.path,
717
- data: engine.value()
718
- });
719
- const queued = hooks.map((hook) => ({
720
- id: deps.newId(),
721
- webhookId: hook.id,
722
- submissionId: id,
723
- eventId: deps.newId(),
724
- body,
725
- attempt: 0,
726
- nextAttemptAt: deps.nowIso(),
727
- state: "pending",
728
- lastError: null
729
- }));
730
- const claimable = await filesToClaim(deps.storage, {
731
- schema: current.schema,
732
- formId: current.formId,
733
- data: engine.value()
734
- });
735
- if (!claimable.ok) return {
736
- ok: false,
737
- kind: "invalid",
738
- errors: { _files: [`unknown_file:${claimable.unknown.join(",")}`] }
739
- };
740
- const at = deps.nowIso();
741
- await deps.storage.insertSubmission({
742
- id,
743
- formId: current.formId,
744
- formVersionId: current.versionId,
745
- data: engine.value(),
746
- submittedAt: at
747
- }, queued, claimable.ids, {
748
- id: deps.newId(),
749
- at,
750
- action: "submission.created",
751
- subject: input.path,
752
- detail: {
753
- submissionId: id,
754
- version: current.version,
755
- authenticated: input.actor === "authenticated"
756
- }
757
- });
758
- return {
759
- ok: true,
760
- id,
761
- canonicalData: engine.value()
762
- };
763
- }
764
759
  /** The submissions of one form, newest first, each tagged with the schema
765
760
  * version that produced it. Undefined for an unknown form: the caller must
766
761
  * 404, and an empty list would lie about that. */
@@ -841,39 +836,17 @@ function cellValue(data, column) {
841
836
  function csvCell(value) {
842
837
  return /[",\n\r]/.test(value) ? `"${value.replaceAll("\"", "\"\"")}"` : value;
843
838
  }
844
- /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
845
- * for an unknown form. */
846
- /**
847
- * The token that proves the bearer started this draft.
848
- *
849
- * HMAC over the form and the draft id, the same stateless shape the
850
- * proof-of-work challenge uses
851
- * ([0059](../../../docs/decisions/0059-proof-of-work-not-a-captcha.md)): no
852
- * second table, and no lookup before the check. Bound to the FORM as well as the
853
- * id so a token cannot be carried to a draft of another form that happens to
854
- * share an id.
855
- */
856
- function draftToken(secret, formId, draftId) {
857
- return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(`${formId}:${draftId}`)));
858
- }
859
- /**
860
- * Compared in constant time.
861
- *
862
- * A byte-at-a-time comparison that returns early leaks how much of a guess was
863
- * right, and a token is guessed one byte at a time by exactly that signal.
864
- */
865
- function sameToken(a, b) {
866
- if (a.length !== b.length) return false;
867
- let differences = 0;
868
- for (let at = 0; at < a.length; at += 1) differences |= a.charCodeAt(at) ^ b.charCodeAt(at);
869
- return differences === 0;
870
- }
871
839
  /**
872
840
  * Start a draft: the server picks the id, and signs it.
873
841
  *
874
842
  * The id is the server's because an id a caller supplies is an id a caller can
875
843
  * enumerate — and enumerating them was enough to read other people's
876
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.
877
850
  */
878
851
  async function startDraft(deps, input) {
879
852
  const current = await resolveForm(deps, input.path);
@@ -881,13 +854,16 @@ async function startDraft(deps, input) {
881
854
  const draftId = deps.newId();
882
855
  return {
883
856
  draftId,
884
- token: draftToken(deps.draftSecret, current.formId, draftId)
857
+ token: draftKey(deps.draftSecret, current.formId, draftId),
858
+ submissionToken: submissionToken(deps.draftSecret, current.formId, draftId)
885
859
  };
886
860
  }
861
+ /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
862
+ * for an unknown form. */
887
863
  async function saveDraft(deps, input) {
888
864
  const current = await resolveForm(deps, input.path);
889
865
  if (current === void 0) return void 0;
890
- if (!sameToken(input.token, draftToken(deps.draftSecret, current.formId, input.draftId))) return {
866
+ if (!sameToken(input.token, draftKey(deps.draftSecret, current.formId, input.draftId))) return {
891
867
  version: current.version,
892
868
  saved: false
893
869
  };
@@ -918,15 +894,17 @@ async function saveDraft(deps, input) {
918
894
  async function resumeDraft(deps, input) {
919
895
  const current = await resolveForm(deps, input.path);
920
896
  if (current === void 0) return void 0;
921
- if (!sameToken(input.token, draftToken(deps.draftSecret, current.formId, input.draftId))) return;
897
+ if (!sameToken(input.token, draftKey(deps.draftSecret, current.formId, input.draftId))) return;
922
898
  const draft = await deps.storage.getDraft(current.formId, input.draftId);
923
899
  if (draft === void 0) return void 0;
900
+ const sendWith = submissionToken(deps.draftSecret, current.formId, draft.id);
924
901
  if (draft.formVersionId === current.versionId) return {
925
902
  outcome: "resumed",
926
903
  version: current.version,
927
904
  schema: current.schema,
928
905
  schemaHash: current.schemaHash,
929
- data: draft.data
906
+ data: draft.data,
907
+ submissionToken: sendWith
930
908
  };
931
909
  const draftVersion = await deps.storage.getVersionById(draft.formVersionId);
932
910
  if (draftVersion === void 0) return void 0;
@@ -955,7 +933,8 @@ async function resumeDraft(deps, input) {
955
933
  ...severity === "lossy" ? { migration: {
956
934
  severity,
957
935
  changes
958
- } } : {}
936
+ } } : {},
937
+ submissionToken: sendWith
959
938
  };
960
939
  }
961
940
  const SEVERITY_RANK = {
@@ -1050,6 +1029,160 @@ async function listVersions(deps, path) {
1050
1029
  }));
1051
1030
  }
1052
1031
  //#endregion
1032
+ //#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
+ async function formToFill(deps, path) {
1043
+ const resolved = await resolveForm(deps, path);
1044
+ if (resolved === void 0) return void 0;
1045
+ return {
1046
+ ...resolved,
1047
+ submissionToken: submissionToken(deps.draftSecret, resolved.formId, deps.newId())
1048
+ };
1049
+ }
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
+ async function createSubmission(deps, input) {
1065
+ const form = await deps.storage.getFormByPath(input.path);
1066
+ if (form === void 0) return {
1067
+ ok: false,
1068
+ kind: "unknown_form"
1069
+ };
1070
+ const actor = input.actor ?? "anonymous";
1071
+ if (!maySubmit(form, actor, input.origin)) return {
1072
+ ok: false,
1073
+ kind: "forbidden"
1074
+ };
1075
+ let id;
1076
+ if (input.token === void 0) {
1077
+ if (actor === "anonymous") return {
1078
+ ok: false,
1079
+ kind: "token_required"
1080
+ };
1081
+ id = deps.newId();
1082
+ } else {
1083
+ const named = submissionTokenId(deps.draftSecret, form.id, input.token);
1084
+ if (named === void 0) return {
1085
+ ok: false,
1086
+ kind: "token_invalid"
1087
+ };
1088
+ if (await deps.storage.hasSubmission(named)) return {
1089
+ ok: false,
1090
+ kind: "token_spent",
1091
+ id: named
1092
+ };
1093
+ id = named;
1094
+ }
1095
+ const current = await resolveForm(deps, input.path);
1096
+ if (current === void 0) return {
1097
+ ok: false,
1098
+ kind: "unknown_form"
1099
+ };
1100
+ if (input.declaredSchemaHash !== current.schemaHash) return {
1101
+ ok: false,
1102
+ kind: "version_changed",
1103
+ current
1104
+ };
1105
+ const engine = createFormEngine({
1106
+ schema: current.schema,
1107
+ initialValue: input.data,
1108
+ capabilities: deps.capabilities,
1109
+ mode: "server",
1110
+ ...deps.checks === void 0 ? {} : { checks: deps.checks }
1111
+ });
1112
+ await engine.settle();
1113
+ const outcome = engine.submit();
1114
+ if (!outcome.ok) return {
1115
+ ok: false,
1116
+ kind: "invalid",
1117
+ errors: outcome.errors
1118
+ };
1119
+ const membership = await checkMembership(current.schema, engine.value(), deps.optionsSources);
1120
+ if (!membership.ok) return membership.kind === "invalid" ? {
1121
+ ok: false,
1122
+ kind: "invalid",
1123
+ errors: membership.errors
1124
+ } : {
1125
+ ok: false,
1126
+ kind: "source_unavailable",
1127
+ source: membership.source
1128
+ };
1129
+ const hooks = await deps.storage.webhooksForForm(current.formId);
1130
+ const body = JSON.stringify({
1131
+ id,
1132
+ form: input.path,
1133
+ data: engine.value()
1134
+ });
1135
+ const queued = hooks.map((hook) => ({
1136
+ id: deps.newId(),
1137
+ webhookId: hook.id,
1138
+ submissionId: id,
1139
+ eventId: deps.newId(),
1140
+ body,
1141
+ attempt: 0,
1142
+ nextAttemptAt: deps.nowIso(),
1143
+ state: "pending",
1144
+ lastError: null
1145
+ }));
1146
+ const claimable = await filesToClaim(deps.storage, {
1147
+ schema: current.schema,
1148
+ formId: current.formId,
1149
+ data: engine.value()
1150
+ });
1151
+ if (!claimable.ok) return {
1152
+ ok: false,
1153
+ kind: "invalid",
1154
+ errors: { _files: [`unknown_file:${claimable.unknown.join(",")}`] }
1155
+ };
1156
+ const at = deps.nowIso();
1157
+ if (!await deps.storage.insertSubmission({
1158
+ id,
1159
+ formId: current.formId,
1160
+ formVersionId: current.versionId,
1161
+ data: engine.value(),
1162
+ submittedAt: at
1163
+ }, queued, claimable.ids, {
1164
+ id: deps.newId(),
1165
+ at,
1166
+ action: "submission.created",
1167
+ subject: input.path,
1168
+ ...input.requestId === void 0 ? {} : { requestId: input.requestId },
1169
+ detail: {
1170
+ submissionId: id,
1171
+ version: current.version,
1172
+ authenticated: input.actor === "authenticated"
1173
+ }
1174
+ })) return {
1175
+ ok: false,
1176
+ kind: "token_spent",
1177
+ id
1178
+ };
1179
+ return {
1180
+ ok: true,
1181
+ id,
1182
+ canonicalData: engine.value()
1183
+ };
1184
+ }
1185
+ //#endregion
1053
1186
  //#region src/redos.ts
1054
1187
  /** Every `pattern` in the model, with the data path of the field it sits on. */
1055
1188
  function patternsIn(fields, prefix) {
@@ -1106,6 +1239,7 @@ function createMemoryStorage() {
1106
1239
  const users = /* @__PURE__ */ new Map();
1107
1240
  const apiKeys = /* @__PURE__ */ new Map();
1108
1241
  const files = /* @__PURE__ */ new Map();
1242
+ const examples = /* @__PURE__ */ new Map();
1109
1243
  return {
1110
1244
  getFormByPath: async (path) => [...forms.values()].find((form) => form.path === path),
1111
1245
  listForms: async () => [...forms.values()].map((form) => ({ ...form })),
@@ -1136,6 +1270,15 @@ function createMemoryStorage() {
1136
1270
  if (audit !== void 0) audits.push({ ...audit });
1137
1271
  },
1138
1272
  getVersionById: async (id) => versions.get(id),
1273
+ getExamples: async (formId) => {
1274
+ const found = examples.get(formId);
1275
+ return found === void 0 ? void 0 : { ...found };
1276
+ },
1277
+ keepExamples: async (record, audit) => {
1278
+ if (!forms.has(record.formId)) throw new Error(`No form "${record.formId}"`);
1279
+ examples.set(record.formId, { ...record });
1280
+ if (audit !== void 0) audits.push({ ...audit });
1281
+ },
1139
1282
  findVersionByHash: async (formId, hash) => [...versions.values()].find((version) => version.formId === formId && version.schemaHash === hash),
1140
1283
  latestVersionNumber: async (formId) => Math.max(0, ...[...versions.values()].filter((version) => version.formId === formId).map((version) => version.version)),
1141
1284
  webhooksForForm: async (formId) => [...webhooks.values()].filter((hook) => hook.formId === formId).map((hook) => ({ ...hook })),
@@ -1162,14 +1305,41 @@ function createMemoryStorage() {
1162
1305
  const found = files.get(id);
1163
1306
  return found === void 0 ? void 0 : { ...found };
1164
1307
  },
1165
- updateFile: async (record) => {
1166
- files.set(record.id, { ...record });
1308
+ leaseFile: async (id, nowIso, untilIso) => {
1309
+ const file = files.get(id);
1310
+ if (file === void 0 || file.state !== "offered") return false;
1311
+ if (file.receivingUntil !== null && file.receivingUntil > nowIso) return false;
1312
+ files.set(id, {
1313
+ ...file,
1314
+ receivingUntil: untilIso
1315
+ });
1316
+ return true;
1317
+ },
1318
+ settleFile: async (id, untilIso) => {
1319
+ const file = files.get(id);
1320
+ if (file === void 0 || file.state !== "offered" || file.receivingUntil !== untilIso) return false;
1321
+ files.set(id, {
1322
+ ...file,
1323
+ state: "stored",
1324
+ receivingUntil: null
1325
+ });
1326
+ return true;
1327
+ },
1328
+ releaseFile: async (id, untilIso) => {
1329
+ const file = files.get(id);
1330
+ if (file === void 0 || file.receivingUntil !== untilIso) return false;
1331
+ files.set(id, {
1332
+ ...file,
1333
+ receivingUntil: null
1334
+ });
1335
+ return true;
1167
1336
  },
1168
1337
  abandonedFiles: async (beforeIso) => [...files.values()].filter((file) => file.state !== "claimed" && file.createdAt < beforeIso).map((file) => ({ ...file })),
1169
1338
  deleteFiles: async (ids) => {
1170
1339
  for (const id of ids) files.delete(id);
1171
1340
  },
1172
1341
  insertSubmission: async (record, queued, claimFileIds, audit) => {
1342
+ if (submissions.some((stored) => stored.id === record.id)) return false;
1173
1343
  submissions.push({ ...record });
1174
1344
  for (const delivery of queued ?? []) deliveries.set(delivery.id, { ...delivery });
1175
1345
  for (const id of claimFileIds ?? []) {
@@ -1181,7 +1351,9 @@ function createMemoryStorage() {
1181
1351
  });
1182
1352
  }
1183
1353
  if (audit !== void 0) audits.push({ ...audit });
1354
+ return true;
1184
1355
  },
1356
+ hasSubmission: async (id) => submissions.some((stored) => stored.id === id),
1185
1357
  spendChallenge: async (challenge, expiresAtIso) => {
1186
1358
  if (spentChallenges.has(challenge)) return false;
1187
1359
  spentChallenges.set(challenge, expiresAtIso);
@@ -1407,6 +1579,184 @@ function isIpLiteral(host) {
1407
1579
  return bare.includes(":");
1408
1580
  }
1409
1581
  //#endregion
1582
+ //#region src/examples.ts
1583
+ /** The examples kept for the form at `path`; none, for a form nobody wrote any for. */
1584
+ async function readExamples(deps, input) {
1585
+ if (!can(input.actor, "form.publish")) return {
1586
+ ok: false,
1587
+ kind: "forbidden"
1588
+ };
1589
+ const form = await deps.storage.getFormByPath(input.path);
1590
+ if (form === void 0) return {
1591
+ ok: false,
1592
+ kind: "unknown_form"
1593
+ };
1594
+ const kept = readKept(await deps.storage.getExamples(form.id));
1595
+ return {
1596
+ ok: true,
1597
+ examples: {
1598
+ ...asExamples(kept.scenarios, kept.sample),
1599
+ ...kept.problems.length === 0 ? {} : { unreadable: kept.problems }
1600
+ }
1601
+ };
1602
+ }
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
+ async function keepExamples(deps, input) {
1612
+ if (!can(input.actor, "form.publish")) return {
1613
+ ok: false,
1614
+ kind: "forbidden"
1615
+ };
1616
+ const form = await deps.storage.getFormByPath(input.path);
1617
+ if (form === void 0) return {
1618
+ ok: false,
1619
+ kind: "unknown_form"
1620
+ };
1621
+ const read = readExamplesBody(input.examples);
1622
+ if ("problems" in read) return {
1623
+ ok: false,
1624
+ kind: "invalid_examples",
1625
+ problems: read.problems
1626
+ };
1627
+ await deps.storage.keepExamples({
1628
+ formId: form.id,
1629
+ scenarios: read.scenarios,
1630
+ sample: read.sample,
1631
+ updatedAt: deps.nowIso()
1632
+ }, {
1633
+ id: deps.newId(),
1634
+ at: deps.nowIso(),
1635
+ action: "form.examples.changed",
1636
+ subject: input.path,
1637
+ actorKind: input.actor.kind,
1638
+ actorId: input.actor.id,
1639
+ ...input.requestId === void 0 ? {} : { requestId: input.requestId },
1640
+ detail: {
1641
+ examples: read.scenarios.length,
1642
+ sample: read.sample !== null
1643
+ }
1644
+ });
1645
+ return {
1646
+ ok: true,
1647
+ examples: asExamples(read.scenarios, read.sample)
1648
+ };
1649
+ }
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
+ async function examplesThatStopHolding(storage, formId, before, after) {
1668
+ if (before === void 0) return [];
1669
+ const kept = readKept(await storage.getExamples(formId));
1670
+ const leftOut = kept.problems.length === 0 ? [] : [`Not everything kept with this form's examples could be read, and what could not was not run: ${kept.problems.join(" ")}`];
1671
+ if (kept.scenarios.length === 0) return leftOut;
1672
+ const options = {
1673
+ mode: "server",
1674
+ ...kept.sample === null ? {} : { initialValue: kept.sample }
1675
+ };
1676
+ const now = runScenarios(after.schema, kept.scenarios, options);
1677
+ const { regressions } = comparedToLastRun(runScenarios(before.schema, kept.scenarios, options), now);
1678
+ return [...regressions.map((name) => {
1679
+ const failures = now.find((result) => result.name === name)?.failures ?? [];
1680
+ return `The example "${name}" held against version ${String(before.version)} and does not hold against version ${String(after.version)}: ${failures.map((failure) => failure.detail).join(" ")}`;
1681
+ }), ...leftOut];
1682
+ }
1683
+ function asExamples(scenarios, sample) {
1684
+ return {
1685
+ scenarios,
1686
+ ...sample === null ? {} : { sample }
1687
+ };
1688
+ }
1689
+ /** `{ scenarios, sample? }` from a request body, or a sentence for everything that is not. */
1690
+ function readExamplesBody(body) {
1691
+ if (!isRecord(body) || !Array.isArray(body["scenarios"])) return { problems: ["Send { scenarios, sample? }, where scenarios is a list of examples."] };
1692
+ const read = readList(body["scenarios"], body["sample"]);
1693
+ return read.problems.length > 0 ? { problems: read.problems } : {
1694
+ scenarios: read.scenarios,
1695
+ sample: read.sample
1696
+ };
1697
+ }
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
+ function readKept(kept) {
1704
+ if (kept === void 0) return {
1705
+ scenarios: [],
1706
+ sample: null,
1707
+ problems: []
1708
+ };
1709
+ const items = kept.scenarios;
1710
+ if (Array.isArray(items)) return readList(items, kept.sample);
1711
+ const read = readList([], kept.sample);
1712
+ return {
1713
+ ...read,
1714
+ problems: ["What is kept as the examples is not a list of them.", ...read.problems]
1715
+ };
1716
+ }
1717
+ /** Each item through `readScenario`, names once each, and the sample as answers by field. */
1718
+ function readList(items, sample) {
1719
+ const problems = [];
1720
+ 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.");
1721
+ const scenarios = [];
1722
+ const first = /* @__PURE__ */ new Map();
1723
+ items.forEach((item, index) => {
1724
+ const position = index + 1;
1725
+ const read = readScenario(item);
1726
+ if (!read.ok) {
1727
+ problems.push(`${which(position, read.problem.name)} ${SHAPE_PROBLEMS[read.problem.reason](read.problem)}`);
1728
+ return;
1729
+ }
1730
+ const earlier = first.get(read.scenario.name);
1731
+ if (earlier !== void 0) {
1732
+ problems.push(`${which(position, read.scenario.name)} has the name of example ${String(earlier)}; a name is how an example's result is found.`);
1733
+ return;
1734
+ }
1735
+ first.set(read.scenario.name, position);
1736
+ scenarios.push(read.scenario);
1737
+ });
1738
+ return {
1739
+ scenarios,
1740
+ sample: isRecord(sample) ? sample : null,
1741
+ problems
1742
+ };
1743
+ }
1744
+ function which(position, name) {
1745
+ return `Example ${String(position)}${name === void 0 ? "" : ` ("${name}")`}`;
1746
+ }
1747
+ /** What each reason `readScenario` gives means, said for whoever sent the list. */
1748
+ const SHAPE_PROBLEMS = {
1749
+ "not-an-object": () => "is not an object.",
1750
+ "unknown-key": ({ part }) => `has "${part ?? ""}", which an example does not have.`,
1751
+ "no-name": () => "has no name.",
1752
+ "no-changes": () => "has no \"changes\": the answers it sets, by field.",
1753
+ "no-verdict": () => "does not say whether the form is \"valid\" afterwards, as true or false.",
1754
+ malformed: ({ part }) => `has a "${part ?? ""}" of the wrong shape.`
1755
+ };
1756
+ function isRecord(value) {
1757
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1758
+ }
1759
+ //#endregion
1410
1760
  //#region src/publishing.ts
1411
1761
  /**
1412
1762
  * Publish a schema as a form's next version.
@@ -1488,6 +1838,10 @@ async function publishForm(deps, input) {
1488
1838
  version: already.version
1489
1839
  };
1490
1840
  const version = await deps.storage.latestVersionNumber(existing.id) + 1;
1841
+ const stopped = await examplesThatStopHolding(deps.storage, existing.id, current, {
1842
+ schema,
1843
+ version
1844
+ });
1491
1845
  const versionId = deps.newId();
1492
1846
  await deps.storage.publishVersion({
1493
1847
  version: {
@@ -1510,7 +1864,7 @@ async function publishForm(deps, input) {
1510
1864
  versionId,
1511
1865
  version,
1512
1866
  schemaHash: hash,
1513
- warnings
1867
+ warnings: [...warnings, ...stopped]
1514
1868
  };
1515
1869
  }
1516
1870
  if (input.basedOnSchemaHash !== void 0) return {
@@ -1568,6 +1922,7 @@ function publishAudit(deps, input, about) {
1568
1922
  actorKind: input.actor.kind,
1569
1923
  actorId: input.actor.id
1570
1924
  },
1925
+ ...input.requestId === void 0 ? {} : { requestId: input.requestId },
1571
1926
  detail: {
1572
1927
  version: about.version,
1573
1928
  schemaHash: about.hash,
@@ -1576,6 +1931,38 @@ function publishAudit(deps, input, about) {
1576
1931
  };
1577
1932
  }
1578
1933
  //#endregion
1579
- 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, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, hashOf, healthOf, isIpLiteral, isPrivateAddress, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
1934
+ //#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
+ async function completeBuilderRequest(completer, request, cancellation) {
1943
+ const { kind, user } = request;
1944
+ if (!isModelRequestKind(kind)) return {
1945
+ ok: false,
1946
+ failure: "unknown_kind"
1947
+ };
1948
+ if (typeof user !== "string" || user.trim() === "") return {
1949
+ ok: false,
1950
+ failure: "no_user"
1951
+ };
1952
+ try {
1953
+ return await completer.complete({
1954
+ system: modelBriefing(kind),
1955
+ user
1956
+ }, cancellation);
1957
+ } catch (error) {
1958
+ return {
1959
+ ok: false,
1960
+ failure: "unavailable",
1961
+ cause: error instanceof Error ? error.message : String(error)
1962
+ };
1963
+ }
1964
+ }
1965
+ //#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 };
1580
1967
 
1581
1968
  //# sourceMappingURL=index.mjs.map