@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/README.md +13 -2
- package/dist/index.d.mts +398 -57
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +499 -144
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1198
|
-
files.
|
|
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
|
-
|
|
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
|