@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/README.md +13 -2
- package/dist/index.d.mts +562 -157
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +531 -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 {
|
|
@@ -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:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1166
|
-
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;
|
|
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
|
-
|
|
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
|