@formancy/server-core 0.4.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 {
@@ -658,6 +662,70 @@ function fileFieldPaths(schema) {
658
662
  return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
659
663
  }
660
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
661
729
  //#region src/use-cases.ts
662
730
  async function resolveForm(deps, path) {
663
731
  const form = await deps.storage.getFormByPath(path);
@@ -673,15 +741,6 @@ async function resolveForm(deps, path) {
673
741
  };
674
742
  }
675
743
  /**
676
- * Accept one submission: resolve the version the client says it rendered,
677
- * replay the engine over the submitted data, and store the CANONICAL result.
678
- *
679
- * The replay is the whole point. Computed values are recomputed and overwrite
680
- * whatever arrived; visibility is evaluated server-side and hidden branches
681
- * are stripped, so a client cannot smuggle data by lying about what was shown.
682
- * Client validation is UX; this is truth.
683
- */
684
- /**
685
744
  * Change who may submit a form, and from where.
686
745
  *
687
746
  * Separate from publishing on purpose: access is a property of the deployment,
@@ -697,102 +756,6 @@ async function setFormAccess(deps, input) {
697
756
  });
698
757
  return { ok: true };
699
758
  }
700
- async function createSubmission(deps, input) {
701
- const form = await deps.storage.getFormByPath(input.path);
702
- if (form === void 0) return {
703
- ok: false,
704
- kind: "unknown_form"
705
- };
706
- if (!maySubmit(form, input.actor ?? "anonymous", input.origin)) return {
707
- ok: false,
708
- kind: "forbidden"
709
- };
710
- const current = await resolveForm(deps, input.path);
711
- if (current === void 0) return {
712
- ok: false,
713
- kind: "unknown_form"
714
- };
715
- if (input.declaredSchemaHash !== current.schemaHash) return {
716
- ok: false,
717
- kind: "version_changed",
718
- current
719
- };
720
- const engine = createFormEngine({
721
- schema: current.schema,
722
- initialValue: input.data,
723
- capabilities: deps.capabilities,
724
- mode: "server",
725
- ...deps.checks === void 0 ? {} : { checks: deps.checks }
726
- });
727
- await engine.settle();
728
- const outcome = engine.submit();
729
- if (!outcome.ok) return {
730
- ok: false,
731
- kind: "invalid",
732
- errors: outcome.errors
733
- };
734
- const membership = await checkMembership(current.schema, engine.value(), deps.optionsSources);
735
- if (!membership.ok) return membership.kind === "invalid" ? {
736
- ok: false,
737
- kind: "invalid",
738
- errors: membership.errors
739
- } : {
740
- ok: false,
741
- kind: "source_unavailable",
742
- source: membership.source
743
- };
744
- const id = deps.newId();
745
- const hooks = await deps.storage.webhooksForForm(current.formId);
746
- const body = JSON.stringify({
747
- id,
748
- form: input.path,
749
- data: engine.value()
750
- });
751
- const queued = hooks.map((hook) => ({
752
- id: deps.newId(),
753
- webhookId: hook.id,
754
- submissionId: id,
755
- eventId: deps.newId(),
756
- body,
757
- attempt: 0,
758
- nextAttemptAt: deps.nowIso(),
759
- state: "pending",
760
- lastError: null
761
- }));
762
- const claimable = await filesToClaim(deps.storage, {
763
- schema: current.schema,
764
- formId: current.formId,
765
- data: engine.value()
766
- });
767
- if (!claimable.ok) return {
768
- ok: false,
769
- kind: "invalid",
770
- errors: { _files: [`unknown_file:${claimable.unknown.join(",")}`] }
771
- };
772
- const at = deps.nowIso();
773
- await deps.storage.insertSubmission({
774
- id,
775
- formId: current.formId,
776
- formVersionId: current.versionId,
777
- data: engine.value(),
778
- submittedAt: at
779
- }, queued, claimable.ids, {
780
- id: deps.newId(),
781
- at,
782
- action: "submission.created",
783
- subject: input.path,
784
- detail: {
785
- submissionId: id,
786
- version: current.version,
787
- authenticated: input.actor === "authenticated"
788
- }
789
- });
790
- return {
791
- ok: true,
792
- id,
793
- canonicalData: engine.value()
794
- };
795
- }
796
759
  /** The submissions of one form, newest first, each tagged with the schema
797
760
  * version that produced it. Undefined for an unknown form: the caller must
798
761
  * 404, and an empty list would lie about that. */
@@ -873,39 +836,17 @@ function cellValue(data, column) {
873
836
  function csvCell(value) {
874
837
  return /[",\n\r]/.test(value) ? `"${value.replaceAll("\"", "\"\"")}"` : value;
875
838
  }
876
- /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
877
- * for an unknown form. */
878
- /**
879
- * The token that proves the bearer started this draft.
880
- *
881
- * HMAC over the form and the draft id, the same stateless shape the
882
- * proof-of-work challenge uses
883
- * ([0059](../../../docs/decisions/0059-proof-of-work-not-a-captcha.md)): no
884
- * second table, and no lookup before the check. Bound to the FORM as well as the
885
- * id so a token cannot be carried to a draft of another form that happens to
886
- * share an id.
887
- */
888
- function draftToken(secret, formId, draftId) {
889
- return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(`${formId}:${draftId}`)));
890
- }
891
- /**
892
- * Compared in constant time.
893
- *
894
- * A byte-at-a-time comparison that returns early leaks how much of a guess was
895
- * right, and a token is guessed one byte at a time by exactly that signal.
896
- */
897
- function sameToken(a, b) {
898
- if (a.length !== b.length) return false;
899
- let differences = 0;
900
- for (let at = 0; at < a.length; at += 1) differences |= a.charCodeAt(at) ^ b.charCodeAt(at);
901
- return differences === 0;
902
- }
903
839
  /**
904
840
  * Start a draft: the server picks the id, and signs it.
905
841
  *
906
842
  * The id is the server's because an id a caller supplies is an id a caller can
907
843
  * enumerate — and enumerating them was enough to read other people's
908
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.
909
850
  */
910
851
  async function startDraft(deps, input) {
911
852
  const current = await resolveForm(deps, input.path);
@@ -913,13 +854,16 @@ async function startDraft(deps, input) {
913
854
  const draftId = deps.newId();
914
855
  return {
915
856
  draftId,
916
- token: draftToken(deps.draftSecret, current.formId, draftId)
857
+ token: draftKey(deps.draftSecret, current.formId, draftId),
858
+ submissionToken: submissionToken(deps.draftSecret, current.formId, draftId)
917
859
  };
918
860
  }
861
+ /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
862
+ * for an unknown form. */
919
863
  async function saveDraft(deps, input) {
920
864
  const current = await resolveForm(deps, input.path);
921
865
  if (current === void 0) return void 0;
922
- 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 {
923
867
  version: current.version,
924
868
  saved: false
925
869
  };
@@ -950,15 +894,17 @@ async function saveDraft(deps, input) {
950
894
  async function resumeDraft(deps, input) {
951
895
  const current = await resolveForm(deps, input.path);
952
896
  if (current === void 0) return void 0;
953
- 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;
954
898
  const draft = await deps.storage.getDraft(current.formId, input.draftId);
955
899
  if (draft === void 0) return void 0;
900
+ const sendWith = submissionToken(deps.draftSecret, current.formId, draft.id);
956
901
  if (draft.formVersionId === current.versionId) return {
957
902
  outcome: "resumed",
958
903
  version: current.version,
959
904
  schema: current.schema,
960
905
  schemaHash: current.schemaHash,
961
- data: draft.data
906
+ data: draft.data,
907
+ submissionToken: sendWith
962
908
  };
963
909
  const draftVersion = await deps.storage.getVersionById(draft.formVersionId);
964
910
  if (draftVersion === void 0) return void 0;
@@ -987,7 +933,8 @@ async function resumeDraft(deps, input) {
987
933
  ...severity === "lossy" ? { migration: {
988
934
  severity,
989
935
  changes
990
- } } : {}
936
+ } } : {},
937
+ submissionToken: sendWith
991
938
  };
992
939
  }
993
940
  const SEVERITY_RANK = {
@@ -1082,6 +1029,160 @@ async function listVersions(deps, path) {
1082
1029
  }));
1083
1030
  }
1084
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
1085
1186
  //#region src/redos.ts
1086
1187
  /** Every `pattern` in the model, with the data path of the field it sits on. */
1087
1188
  function patternsIn(fields, prefix) {
@@ -1138,6 +1239,7 @@ function createMemoryStorage() {
1138
1239
  const users = /* @__PURE__ */ new Map();
1139
1240
  const apiKeys = /* @__PURE__ */ new Map();
1140
1241
  const files = /* @__PURE__ */ new Map();
1242
+ const examples = /* @__PURE__ */ new Map();
1141
1243
  return {
1142
1244
  getFormByPath: async (path) => [...forms.values()].find((form) => form.path === path),
1143
1245
  listForms: async () => [...forms.values()].map((form) => ({ ...form })),
@@ -1168,6 +1270,15 @@ function createMemoryStorage() {
1168
1270
  if (audit !== void 0) audits.push({ ...audit });
1169
1271
  },
1170
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
+ },
1171
1282
  findVersionByHash: async (formId, hash) => [...versions.values()].find((version) => version.formId === formId && version.schemaHash === hash),
1172
1283
  latestVersionNumber: async (formId) => Math.max(0, ...[...versions.values()].filter((version) => version.formId === formId).map((version) => version.version)),
1173
1284
  webhooksForForm: async (formId) => [...webhooks.values()].filter((hook) => hook.formId === formId).map((hook) => ({ ...hook })),
@@ -1194,14 +1305,41 @@ function createMemoryStorage() {
1194
1305
  const found = files.get(id);
1195
1306
  return found === void 0 ? void 0 : { ...found };
1196
1307
  },
1197
- updateFile: async (record) => {
1198
- 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;
1199
1336
  },
1200
1337
  abandonedFiles: async (beforeIso) => [...files.values()].filter((file) => file.state !== "claimed" && file.createdAt < beforeIso).map((file) => ({ ...file })),
1201
1338
  deleteFiles: async (ids) => {
1202
1339
  for (const id of ids) files.delete(id);
1203
1340
  },
1204
1341
  insertSubmission: async (record, queued, claimFileIds, audit) => {
1342
+ if (submissions.some((stored) => stored.id === record.id)) return false;
1205
1343
  submissions.push({ ...record });
1206
1344
  for (const delivery of queued ?? []) deliveries.set(delivery.id, { ...delivery });
1207
1345
  for (const id of claimFileIds ?? []) {
@@ -1213,7 +1351,9 @@ function createMemoryStorage() {
1213
1351
  });
1214
1352
  }
1215
1353
  if (audit !== void 0) audits.push({ ...audit });
1354
+ return true;
1216
1355
  },
1356
+ hasSubmission: async (id) => submissions.some((stored) => stored.id === id),
1217
1357
  spendChallenge: async (challenge, expiresAtIso) => {
1218
1358
  if (spentChallenges.has(challenge)) return false;
1219
1359
  spentChallenges.set(challenge, expiresAtIso);
@@ -1439,6 +1579,184 @@ function isIpLiteral(host) {
1439
1579
  return bare.includes(":");
1440
1580
  }
1441
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
1442
1760
  //#region src/publishing.ts
1443
1761
  /**
1444
1762
  * Publish a schema as a form's next version.
@@ -1520,6 +1838,10 @@ async function publishForm(deps, input) {
1520
1838
  version: already.version
1521
1839
  };
1522
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
+ });
1523
1845
  const versionId = deps.newId();
1524
1846
  await deps.storage.publishVersion({
1525
1847
  version: {
@@ -1542,7 +1864,7 @@ async function publishForm(deps, input) {
1542
1864
  versionId,
1543
1865
  version,
1544
1866
  schemaHash: hash,
1545
- warnings
1867
+ warnings: [...warnings, ...stopped]
1546
1868
  };
1547
1869
  }
1548
1870
  if (input.basedOnSchemaHash !== void 0) return {
@@ -1600,6 +1922,7 @@ function publishAudit(deps, input, about) {
1600
1922
  actorKind: input.actor.kind,
1601
1923
  actorId: input.actor.id
1602
1924
  },
1925
+ ...input.requestId === void 0 ? {} : { requestId: input.requestId },
1603
1926
  detail: {
1604
1927
  version: about.version,
1605
1928
  schemaHash: about.hash,
@@ -1608,6 +1931,38 @@ function publishAudit(deps, input, about) {
1608
1931
  };
1609
1932
  }
1610
1933
  //#endregion
1611
- 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, screenUpload, 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 };
1612
1967
 
1613
1968
  //# sourceMappingURL=index.mjs.map