@formancy/server-core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CHALLENGE_TTL_SECONDS, Challenge, DEFAULT_MAX_NUMBER, Solution, VerifyOutcome, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution } from "@formancy/challenge";
2
+ import { CapabilitySource, Check, Scenario } from "@formancy/core";
2
3
  import { Change, FormSchema } from "@formancy/spec";
3
- import { CapabilitySource, Check } from "@formancy/core";
4
4
  import { SchemaError } from "@formancy/spec/validate";
5
5
  //#region src/options-membership.d.ts
6
6
  /**
@@ -220,8 +220,37 @@ interface FileRecord {
220
220
  createdAt: string;
221
221
  /** The submission that claimed it, or null. One file, one submission. */
222
222
  submissionId: string | null;
223
+ /**
224
+ * Until when one request holds the right to receive this file's bytes, or null when
225
+ * none does. Not a fourth state: the file is still `offered` while it is received, and
226
+ * a lease that has run out is no lease. See `leaseFile`.
227
+ */
228
+ receivingUntil: string | null;
229
+ }
230
+ /**
231
+ * A form's examples, and the fictional sample every one of them starts from — the pair the
232
+ * starter templates carry as `*.scenarios.json` and `*.sample.json`
233
+ * ([0166](../../../docs/decisions/0166-a-deployment-keeps-a-forms-examples-and-runs-them-at-publish.md)).
234
+ *
235
+ * Beside the form and not inside a version. A published version is immutable
236
+ * ([0025](../../../docs/decisions/0025-immutability-in-the-database.md)), and examples change
237
+ * while the form does not: written into a version, every example added would mint one. One
238
+ * record per form, replaced whole, because the scenario panes hand back the whole list.
239
+ */
240
+ interface ExamplesRecord {
241
+ formId: string;
242
+ scenarios: readonly Scenario[];
243
+ /** Where every example starts, or null when none was kept. */
244
+ sample: Readonly<Record<string, unknown>> | null;
245
+ updatedAt: string;
223
246
  }
224
247
  interface Storage {
248
+ /**
249
+ * The form at this path, or `undefined` for a path no form has — one holding a character this
250
+ * storage cannot keep included, such as a NUL in PostgreSQL's `text`. It arrives from a URL, as
251
+ * anybody wrote it, so it is an unknown form and never an error. `getDraft`, `getUserByEmail`
252
+ * and `findApiKeysByPrefix` find none the same way: what they look up arrives from a request too.
253
+ */
225
254
  getFormByPath(path: string): Promise<FormRecord | undefined>;
226
255
  listForms(): Promise<FormRecord[]>;
227
256
  createForm(record: FormRecord): Promise<void>;
@@ -252,6 +281,17 @@ interface Storage {
252
281
  audit?: AuditEntry;
253
282
  }): Promise<void>;
254
283
  getVersionById(id: string): Promise<FormVersionRecord | undefined>;
284
+ /** A form's examples, or undefined when none were ever kept for it. */
285
+ getExamples(formId: string): Promise<ExamplesRecord | undefined>;
286
+ /**
287
+ * Replace a form's examples and sample, with the audit row, in ONE commit — as a publish
288
+ * carries its own: a change that rolled back must leave nothing saying it happened.
289
+ *
290
+ * The form must exist; the PostgreSQL storage has the database refuse one that does not.
291
+ * Nothing here reads the examples: whether they are examples is the use-case's question,
292
+ * asked before this is called, and again of what `getExamples` hands back.
293
+ */
294
+ keepExamples(record: ExamplesRecord, audit?: AuditEntry): Promise<void>;
255
295
  findVersionByHash(formId: string, schemaHash: string): Promise<FormVersionRecord | undefined>;
256
296
  latestVersionNumber(formId: string): Promise<number>;
257
297
  /**
@@ -269,8 +309,23 @@ interface Storage {
269
309
  * prevents — a webhook fired for a rolled-back submission, a stored
270
310
  * submission nothing was sent about, an accepted submission whose files the
271
311
  * collector deletes — are the ones a self-hoster cannot debug.
312
+ *
313
+ * Returns false, and writes NOTHING — no delivery, no claim, no audit row — when a
314
+ * submission with `record.id` is already stored. The id is the one a response's token
315
+ * names, so that is the same response sent twice
316
+ * ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)). ATOMIC, as
317
+ * `spendChallenge` is and for its reason: two sends of one response both pass every check
318
+ * made before this, and only the storage can decide which is stored.
272
319
  */
273
- insertSubmission(record: SubmissionRecord, deliveries?: readonly DeliveryRecord[], claimFileIds?: readonly string[], audit?: AuditEntry): Promise<void>;
320
+ insertSubmission(record: SubmissionRecord, deliveries?: readonly DeliveryRecord[], claimFileIds?: readonly string[], audit?: AuditEntry): Promise<boolean>;
321
+ /**
322
+ * Whether a submission with this id is stored.
323
+ *
324
+ * Asked before the replay, so a response sent again is told it was sent — rather than that
325
+ * the files it names are unknown, which is what the claim would say of files the first send
326
+ * already took. `insertSubmission` still decides: two sends at once both find nothing here.
327
+ */
328
+ hasSubmission(id: string): Promise<boolean>;
274
329
  /**
275
330
  * Append one audit row.
276
331
  *
@@ -305,8 +360,42 @@ interface Storage {
305
360
  /** Drop spent challenges that can no longer be replayed anyway. */
306
361
  forgetExpiredChallenges(beforeIso: string): Promise<number>;
307
362
  insertFile(record: FileRecord): Promise<void>;
363
+ /**
364
+ * The file with this id, or `undefined` for an id no file has — one in no shape this storage's
365
+ * ids take included. It arrives from a URL and from a response, as anybody wrote it, so it is an
366
+ * unknown file and never an error.
367
+ */
308
368
  getFile(id: string): Promise<FileRecord | undefined>;
309
- updateFile(record: FileRecord): Promise<void>;
369
+ /**
370
+ * Take the right to receive an `offered` file's bytes, until `untilIso`.
371
+ *
372
+ * Returns false when the file is not `offered`, or another request holds a lease that
373
+ * has not run out at `nowIso`. ATOMIC, as `spendChallenge` is and for its reason: two
374
+ * requests carrying bytes for one file both pass every check made before this, and only
375
+ * the storage can decide which of them receives. A lease that reads and then writes hands
376
+ * the file to both, and the second writes its bytes over the first's.
377
+ *
378
+ * `untilIso` is also the lease's name, handed back to `settleFile` and `releaseFile`. No
379
+ * two requests can hold one at once: a lease is taken only once the last has run out,
380
+ * so the next always runs out later.
381
+ */
382
+ leaseFile(id: string, nowIso: string, untilIso: string): Promise<boolean>;
383
+ /**
384
+ * The bytes are kept: `stored`, and the lease cleared — only while the file is `offered`
385
+ * and the lease named `untilIso` still holds it. Returns false and changes nothing
386
+ * otherwise.
387
+ *
388
+ * It sets the state and nothing else. Its predecessor wrote back the whole record the
389
+ * request had read before its scan, so a request that outlasted a faster one put a
390
+ * claimed file back to `stored` with no submission, for the collector to delete.
391
+ */
392
+ settleFile(id: string, untilIso: string): Promise<boolean>;
393
+ /**
394
+ * Give the lease back without keeping anything, so the retry a refusal invites is not
395
+ * refused as busy. Only the lease named `untilIso`: a request that outlasted its own
396
+ * must not free the one that took over.
397
+ */
398
+ releaseFile(id: string, untilIso: string): Promise<boolean>;
310
399
  /** Every file in a state other than `claimed`, created before `beforeIso`. */
311
400
  abandonedFiles(beforeIso: string): Promise<FileRecord[]>;
312
401
  deleteFiles(ids: readonly string[]): Promise<void>;
@@ -323,6 +412,7 @@ interface Storage {
323
412
  * a table nobody looks at, which is the same as not having it.
324
413
  */
325
414
  deadDeliveries(limit: number): Promise<DeliveryRecord[]>;
415
+ /** As `getFile`: `undefined` for an id no delivery has, whatever its shape, since it arrives from a URL. */
326
416
  getDelivery(id: string): Promise<DeliveryRecord | undefined>;
327
417
  /** Oldest first, only those due. */
328
418
  claimDueDeliveries(nowIso: string, limit: number): Promise<DeliveryRecord[]>;
@@ -438,7 +528,7 @@ export declare function can(actor: Actor, action: Action): boolean;
438
528
  * Closed so a new route cannot invent its own spelling: an audit log where one event is
439
529
  * called two things is one nobody can query.
440
530
  */
441
- export declare const AUDIT_ACTIONS: readonly ["auth.login", "auth.login.failed", "form.published", "form.access.changed", "submission.created", "submission.read", "submission.exported", "file.downloaded", "user.created", "apiKey.created", "apiKey.revoked", "delivery.replayed"];
531
+ export declare const AUDIT_ACTIONS: readonly ["auth.login", "auth.login.failed", "form.published", "form.access.changed", "form.examples.changed", "submission.created", "submission.read", "submission.exported", "file.downloaded", "user.created", "apiKey.created", "apiKey.revoked", "delivery.replayed", "model.asked"];
442
532
  type AuditAction = (typeof AUDIT_ACTIONS)[number];
443
533
  interface AuditEntry {
444
534
  readonly id: string;
@@ -458,7 +548,9 @@ interface AuditEntry {
458
548
  readonly subject?: string;
459
549
  /**
460
550
  * The request it happened in, so an export and the rows it returned can be
461
- * tied together later.
551
+ * tied together later — and a row to the line a server's request log wrote
552
+ * for that request. A use-case that writes its row inside its transaction
553
+ * takes the id in its input, and names it when it is given one.
462
554
  */
463
555
  readonly requestId?: string;
464
556
  /** Identifiers and counts. Never a submission's answers. */
@@ -606,13 +698,158 @@ interface WebhookHealth {
606
698
  }
607
699
  export declare function healthOf(webhook: WebhookRecord, now: Date): WebhookHealth;
608
700
  //#endregion
609
- //#region src/use-cases.d.ts
701
+ //#region src/uploads.d.ts
610
702
  /**
611
- * The backend's use-cases, framework-free.
703
+ * The file lifecycle: offered, stored, claimed, collected.
612
704
  *
613
- * Identity and time are injected like every other capability: the server pins
614
- * one clock per request, so a submission can be replayed later and produce the
615
- * same computed values byte for byte. Nothing here reads Date.now or crypto.
705
+ * **Bytes are not the submission's problem.** A submission stores what each
706
+ * file is and where it went, never the bytes themselves, so a submission read
707
+ * back years later is small, self-describing, and still says what was attached
708
+ * even if the object store has since been emptied.
709
+ *
710
+ * **The claim happens inside the submission's transaction.** A submission
711
+ * exists if and only if the files it names are claimed against it. Anything
712
+ * looser gives one of the two failures nobody can debug: a rolled-back
713
+ * submission leaving files claimed against nothing, or an accepted submission
714
+ * referencing files the collector is about to delete.
715
+ *
716
+ * **Nothing here touches a disk.** This package has no filesystem and no
717
+ * object store; it decides what is rubbish and the host deletes it. That is
718
+ * the same split every other port here makes, and it is why the whole
719
+ * lifecycle is testable without either.
720
+ */
721
+ interface OfferDeps {
722
+ storage: Storage;
723
+ now: () => Date;
724
+ newId: () => string;
725
+ }
726
+ interface OfferInput {
727
+ schema: FormSchema;
728
+ /** The form, for the access check — the same one a submission passes. */
729
+ form: FormRecord;
730
+ actor: 'anonymous' | 'authenticated';
731
+ origin: string | undefined;
732
+ /** The field the file is for, as a data path: `evidence`, `people[].passport`. */
733
+ field: string;
734
+ name: string;
735
+ size: number;
736
+ contentType: string;
737
+ }
738
+ type OfferOutcome = {
739
+ ok: true;
740
+ file: FileRecord;
741
+ } | {
742
+ ok: false;
743
+ reason: 'not_allowed' | 'unknown_field' | 'not_a_file_field' | 'too_large' | 'not_accepted';
744
+ };
745
+ /**
746
+ * Agree where a file will go, and refuse it before a byte is sent.
747
+ *
748
+ * Checking here rather than after the upload is the difference between
749
+ * refusing a 2 GB file and receiving one first. The browser's own `accept` and
750
+ * size limits are a convenience for the person filling the form in; these are
751
+ * the rule, because somebody posting to the endpoint directly has neither.
752
+ */
753
+ export declare function offerUpload(deps: OfferDeps, input: OfferInput): Promise<OfferOutcome>;
754
+ /** What a deployment's scanner says about an upload's bytes. */
755
+ type ScanVerdict = {
756
+ clean: true;
757
+ } | {
758
+ clean: false;
759
+ finding: string;
760
+ };
761
+ /**
762
+ * A deployment's virus scanner, asked about an upload's bytes before they are kept.
763
+ *
764
+ * A port because the deployment supplies it and there are two: the ClamAV adapter in
765
+ * `@formancy/server`, and the doubles that test this decision. **Throwing means it could
766
+ * not be asked** — a connection refused, a timeout — and never that the file is clean.
767
+ */
768
+ interface Scanner {
769
+ scan(file: {
770
+ name: string;
771
+ contentType: string;
772
+ size: number;
773
+ }, bytes: Uint8Array): Promise<ScanVerdict>;
774
+ }
775
+ type ScreenOutcome = {
776
+ ok: true;
777
+ } | {
778
+ ok: false;
779
+ reason: 'refused';
780
+ finding: string;
781
+ } |
782
+ /** `cause` is for the operator's log, never for the client: it can name an address. */
783
+ {
784
+ ok: false;
785
+ reason: 'scanner_unavailable';
786
+ cause: string;
787
+ };
788
+ /**
789
+ * Whether an upload's bytes may be kept.
790
+ *
791
+ * **Fail closed.** A scanner that cannot be asked refuses the file, the same as one that
792
+ * found something: a deployment that configured a scanner did so to keep unscanned files
793
+ * out, and its being down is not a reason to let one in. The person is told to try again,
794
+ * and the field offers exactly that. Without a scanner every upload is kept, as it was
795
+ * before there was one — a supported state, said in the documentation rather than here
796
+ * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
797
+ */
798
+ export declare function screenUpload(scanner: Scanner | undefined, file: {
799
+ name: string;
800
+ contentType: string;
801
+ size: number;
802
+ }, bytes: Uint8Array): Promise<ScreenOutcome>;
803
+ type ClaimOutcome = {
804
+ ok: true;
805
+ ids: readonly string[];
806
+ } | {
807
+ ok: false;
808
+ unknown: readonly string[];
809
+ };
810
+ /**
811
+ * Which files a submission may claim, checked but not yet claimed.
812
+ *
813
+ * The claiming itself is `insertSubmission`'s job, in the same call that
814
+ * stores the submission, so one COMMIT decides both — the same reasoning
815
+ * that put the webhook queue there. A submission exists if and only if the
816
+ * files it names belong to it. Claiming separately gives the two failures
817
+ * nobody can debug: a rolled-back submission leaving files claimed against
818
+ * nothing, or an accepted submission referencing bytes the collector is about
819
+ * to delete.
820
+ *
821
+ * Refuses rather than skips. A submission naming a file that does not exist,
822
+ * belongs to another form, or is already claimed is not a submission to fix up
823
+ * quietly: the first is a bug or somebody guessing ids; the second is how an
824
+ * id from a form you may submit to would let you attach a file from one you
825
+ * may not; the third would make deleting a submission either leak bytes or
826
+ * break a different one.
827
+ */
828
+ export declare function filesToClaim(storage: Storage, input: {
829
+ schema: FormSchema;
830
+ formId: string;
831
+ data: unknown;
832
+ }): Promise<ClaimOutcome>;
833
+ /**
834
+ * Files nobody claimed, old enough that nobody is going to.
835
+ *
836
+ * Returns the rows rather than deleting anything: the host removes the bytes
837
+ * and then the rows, in that order, because a row without bytes is a broken
838
+ * reference and bytes without a row are invisible rubbish. Of the two, the
839
+ * second is the one you never find out about.
840
+ */
841
+ export declare function collectAbandonedFiles(storage: Storage, now: Date, afterHours: number): Promise<FileRecord[]>;
842
+ /** Re-exported so a host can see which paths a schema's file fields live at. */
843
+ export declare function fileFieldPaths(schema: FormSchema): string[];
844
+ //#endregion
845
+ //#region src/deps.d.ts
846
+ /**
847
+ * What a deployment supplies to the use-cases: storage, identity, time, and the
848
+ * answers a document may only name — checks, lists and a scanner.
849
+ *
850
+ * Its own file because it is the one contract every use-case family shares, and
851
+ * `use-cases.ts` is where the families used to live; it moved when that file's size
852
+ * budget refused the scanner.
616
853
  */
617
854
  interface ServerDeps {
618
855
  storage: Storage;
@@ -630,13 +867,16 @@ interface ServerDeps {
630
867
  /** The clock/randomness the ENGINE sees during replay. */
631
868
  capabilities: CapabilitySource;
632
869
  /**
633
- * Signs the token that proves somebody started a draft.
870
+ * Signs the keys the public plane hands out: the one that proves somebody started a
871
+ * draft, and the token a response is sent with so that it is stored once (0169).
634
872
  *
635
873
  * The public plane is anonymous, so a draft has no account behind it — which
636
874
  * is exactly why it needs a secret of its own. The host's own signing key is
637
875
  * reused rather than a second one being configured: a draft token is a
638
876
  * server-signed bearer token, which is what that key is already for, and an
639
877
  * optional secret would mean drafts are unprotected whenever nobody set it.
878
+ * Named for the first of the two; renaming it would break every host that builds
879
+ * these deps, for a word.
640
880
  */
641
881
  draftSecret: string;
642
882
  /**
@@ -654,7 +894,21 @@ interface ServerDeps {
654
894
  * legitimate configuration, and the reason the guarantee is written down.
655
895
  */
656
896
  optionsSources?: ServerOptionsSources;
897
+ /**
898
+ * The deployment's virus scanner, asked about every upload's bytes before they are
899
+ * kept. Absent means none is asked — a supported state, and the documented one.
900
+ */
901
+ scanner?: Scanner;
657
902
  }
903
+ //#endregion
904
+ //#region src/use-cases.d.ts
905
+ /**
906
+ * The backend's use-cases, framework-free.
907
+ *
908
+ * Identity and time are injected like every other capability: the server pins
909
+ * one clock per request, so a submission can be replayed later and produce the
910
+ * same computed values byte for byte. Nothing here reads Date.now or crypto.
911
+ */
658
912
  interface ResolvedForm {
659
913
  formId: string;
660
914
  versionId: string;
@@ -663,48 +917,6 @@ interface ResolvedForm {
663
917
  schemaHash: string;
664
918
  }
665
919
  export declare function resolveForm(deps: ServerDeps, path: string): Promise<ResolvedForm | undefined>;
666
- type SubmissionOutcome = {
667
- ok: true;
668
- id: string;
669
- canonicalData: unknown;
670
- } | {
671
- ok: false;
672
- kind: 'unknown_form';
673
- } | {
674
- ok: false;
675
- kind: 'version_changed';
676
- current?: ResolvedForm;
677
- } | {
678
- ok: false;
679
- kind: 'invalid';
680
- errors: Record<string, string[]>;
681
- } |
682
- /** The caller may not submit this form: not public, or not from this origin. */
683
- {
684
- ok: false;
685
- kind: 'forbidden';
686
- } |
687
- /**
688
- * A source that should have vouched for an answer could not.
689
- *
690
- * Failed closed and never accepted unchecked: a bogus value that is stored is
691
- * undetectable afterwards, while a refusal is retryable and the draft still holds
692
- * the answers.
693
- */
694
- {
695
- ok: false;
696
- kind: 'source_unavailable';
697
- source: string;
698
- };
699
- /**
700
- * Accept one submission: resolve the version the client says it rendered,
701
- * replay the engine over the submitted data, and store the CANONICAL result.
702
- *
703
- * The replay is the whole point. Computed values are recomputed and overwrite
704
- * whatever arrived; visibility is evaluated server-side and hidden branches
705
- * are stripped, so a client cannot smuggle data by lying about what was shown.
706
- * Client validation is UX; this is truth.
707
- */
708
920
  /**
709
921
  * Change who may submit a form, and from where.
710
922
  *
@@ -719,14 +931,6 @@ export declare function setFormAccess(deps: ServerDeps, input: {
719
931
  }): Promise<{
720
932
  ok: boolean;
721
933
  }>;
722
- export declare function createSubmission(deps: ServerDeps, input: {
723
- path: string;
724
- declaredSchemaHash: string;
725
- data: unknown;
726
- /** Defaults to anonymous: the caller must prove otherwise, not the reverse. */
727
- actor?: 'anonymous' | 'authenticated';
728
- origin?: string;
729
- }): Promise<SubmissionOutcome>;
730
934
  interface ListedSubmission {
731
935
  id: string;
732
936
  version: number;
@@ -754,13 +958,21 @@ export declare function exportCsv(deps: ServerDeps, path: string): Promise<strin
754
958
  * The id is the server's because an id a caller supplies is an id a caller can
755
959
  * enumerate — and enumerating them was enough to read other people's
756
960
  * part-filled answers.
961
+ *
962
+ * A draft is one response, so it comes with the token that response is sent with, naming the
963
+ * draft's own id ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)). Resuming
964
+ * hands back the same one, so a response whose answer was lost and is sent again after a reload
965
+ * is refused as already sent rather than stored twice.
757
966
  */
758
967
  export declare function startDraft(deps: ServerDeps, input: {
759
968
  path: string;
760
969
  }): Promise<{
761
970
  draftId: string;
762
971
  token: string;
972
+ submissionToken: string;
763
973
  } | undefined>;
974
+ /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
975
+ * for an unknown form. */
764
976
  export declare function saveDraft(deps: ServerDeps, input: {
765
977
  path: string;
766
978
  draftId: string;
@@ -782,6 +994,8 @@ type ResumeOutcome = {
782
994
  data: unknown;
783
995
  /** Present when the rebind lost something; absent for a silent rebind. */
784
996
  migration?: DraftMigration;
997
+ /** What the response is sent with: the same on every resume, naming the draft's id. */
998
+ submissionToken: string;
785
999
  } | {
786
1000
  /** The schema changed in a way no automatic rebind survives: the draft
787
1001
  * comes back against ITS OWN version, and the caller offers a restart. */
@@ -824,6 +1038,110 @@ interface ListedVersion {
824
1038
  * view rendering fifty full schemas would be its own denial of service. */
825
1039
  export declare function listVersions(deps: ServerDeps, path: string): Promise<ListedVersion[] | undefined>;
826
1040
  //#endregion
1041
+ //#region src/submitting.d.ts
1042
+ /**
1043
+ * Submitting a form: what a respondent is handed, the replay, and what is stored.
1044
+ *
1045
+ * Its own file because `use-cases.ts` named it as the next family to leave when its size
1046
+ * budget refused growth, and the submission is what grew.
1047
+ */
1048
+ type SubmissionOutcome = {
1049
+ ok: true;
1050
+ id: string;
1051
+ canonicalData: unknown;
1052
+ } | {
1053
+ ok: false;
1054
+ kind: 'unknown_form';
1055
+ } | {
1056
+ ok: false;
1057
+ kind: 'version_changed';
1058
+ current?: ResolvedForm;
1059
+ } | {
1060
+ ok: false;
1061
+ kind: 'invalid';
1062
+ errors: Record<string, string[]>;
1063
+ } |
1064
+ /** The caller may not submit this form: not public, or not from this origin. */
1065
+ {
1066
+ ok: false;
1067
+ kind: 'forbidden';
1068
+ } |
1069
+ /**
1070
+ * A source that should have vouched for an answer could not.
1071
+ *
1072
+ * Failed closed and never accepted unchecked: a bogus value that is stored is
1073
+ * undetectable afterwards, while a refusal is retryable and the draft still holds
1074
+ * the answers.
1075
+ */
1076
+ {
1077
+ ok: false;
1078
+ kind: 'source_unavailable';
1079
+ source: string;
1080
+ } |
1081
+ /** An anonymous response arrived without the token its form was handed out with. */
1082
+ {
1083
+ ok: false;
1084
+ kind: 'token_required';
1085
+ } |
1086
+ /**
1087
+ * A token this server did not sign for this form: forged, another form's, or signed under
1088
+ * a key the deployment no longer has. Loading the form again hands out a good one.
1089
+ */
1090
+ {
1091
+ ok: false;
1092
+ kind: 'token_invalid';
1093
+ } |
1094
+ /** The response this token names is already stored, as `id`; nothing was stored again. */
1095
+ {
1096
+ ok: false;
1097
+ kind: 'token_spent';
1098
+ id: string;
1099
+ };
1100
+ /** A published form as a respondent is handed it: its current version, and what to send it with. */
1101
+ interface FormToFill extends ResolvedForm {
1102
+ /** Names the id the response will be stored under, signed for this form (0169). */
1103
+ submissionToken: string;
1104
+ }
1105
+ /**
1106
+ * The form a respondent fills in, with the token their response is sent with.
1107
+ *
1108
+ * A fresh id on every call, because every reading is a response that might be sent: two
1109
+ * people handed one token would find the second refused as already sent. Minting writes
1110
+ * nothing — an HMAC over the form and the id — so handing one to everybody who reads the form
1111
+ * costs this server a hash and fills no table
1112
+ * ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
1113
+ */
1114
+ export declare function formToFill(deps: ServerDeps, path: string): Promise<FormToFill | undefined>;
1115
+ /**
1116
+ * Accept one submission: resolve the version the client says it rendered,
1117
+ * replay the engine over the submitted data, and store the CANONICAL result.
1118
+ *
1119
+ * The replay is the whole point. Computed values are recomputed and overwrite
1120
+ * whatever arrived; visibility is evaluated server-side and hidden branches
1121
+ * are stripped, so a client cannot smuggle data by lying about what was shown.
1122
+ * Client validation is UX; this is truth.
1123
+ *
1124
+ * And it is stored once. A response is stored under the id its token names, and the storage
1125
+ * refuses that id twice, so the same response sent again — a retry after an answer that never
1126
+ * arrived, a replay — is told it was sent rather than stored as somebody else
1127
+ * ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
1128
+ */
1129
+ export declare function createSubmission(deps: ServerDeps, input: {
1130
+ path: string;
1131
+ declaredSchemaHash: string;
1132
+ data: unknown;
1133
+ /** Defaults to anonymous: the caller must prove otherwise, not the reverse. */
1134
+ actor?: 'anonymous' | 'authenticated';
1135
+ origin?: string;
1136
+ requestId?: string;
1137
+ /**
1138
+ * The token the form was handed out with. Required of an anonymous response. A signed-in
1139
+ * one may leave it out and is stored under a fresh id, as before; one it sends is checked
1140
+ * and spent like any other, because a token that is ignored when it is wrong is decoration.
1141
+ */
1142
+ token?: string;
1143
+ }): Promise<SubmissionOutcome>;
1144
+ //#endregion
827
1145
  //#region src/redos.d.ts
828
1146
  /**
829
1147
  * Refuse a form whose `pattern` values can be made to backtrack.
@@ -968,101 +1286,6 @@ export declare function retryDelayMs(attempt: number, random: () => number): num
968
1286
  /** Eight attempts reach roughly 24 hours with this schedule. */
969
1287
  export declare const MAX_ATTEMPTS = 8;
970
1288
  //#endregion
971
- //#region src/uploads.d.ts
972
- /**
973
- * The file lifecycle: offered, stored, claimed, collected.
974
- *
975
- * **Bytes are not the submission's problem.** A submission stores what each
976
- * file is and where it went, never the bytes themselves, so a submission read
977
- * back years later is small, self-describing, and still says what was attached
978
- * even if the object store has since been emptied.
979
- *
980
- * **The claim happens inside the submission's transaction.** A submission
981
- * exists if and only if the files it names are claimed against it. Anything
982
- * looser gives one of the two failures nobody can debug: a rolled-back
983
- * submission leaving files claimed against nothing, or an accepted submission
984
- * referencing files the collector is about to delete.
985
- *
986
- * **Nothing here touches a disk.** This package has no filesystem and no
987
- * object store; it decides what is rubbish and the host deletes it. That is
988
- * the same split every other port here makes, and it is why the whole
989
- * lifecycle is testable without either.
990
- */
991
- interface OfferDeps {
992
- storage: Storage;
993
- now: () => Date;
994
- newId: () => string;
995
- }
996
- interface OfferInput {
997
- schema: FormSchema;
998
- /** The form, for the access check — the same one a submission passes. */
999
- form: FormRecord;
1000
- actor: 'anonymous' | 'authenticated';
1001
- origin: string | undefined;
1002
- /** The field the file is for, as a data path: `evidence`, `people[].passport`. */
1003
- field: string;
1004
- name: string;
1005
- size: number;
1006
- contentType: string;
1007
- }
1008
- type OfferOutcome = {
1009
- ok: true;
1010
- file: FileRecord;
1011
- } | {
1012
- ok: false;
1013
- reason: 'not_allowed' | 'unknown_field' | 'not_a_file_field' | 'too_large' | 'not_accepted';
1014
- };
1015
- /**
1016
- * Agree where a file will go, and refuse it before a byte is sent.
1017
- *
1018
- * Checking here rather than after the upload is the difference between
1019
- * refusing a 2 GB file and receiving one first. The browser's own `accept` and
1020
- * size limits are a convenience for the person filling the form in; these are
1021
- * the rule, because somebody posting to the endpoint directly has neither.
1022
- */
1023
- export declare function offerUpload(deps: OfferDeps, input: OfferInput): Promise<OfferOutcome>;
1024
- type ClaimOutcome = {
1025
- ok: true;
1026
- ids: readonly string[];
1027
- } | {
1028
- ok: false;
1029
- unknown: readonly string[];
1030
- };
1031
- /**
1032
- * Which files a submission may claim, checked but not yet claimed.
1033
- *
1034
- * The claiming itself is `insertSubmission`'s job, in the same call that
1035
- * stores the submission, so one COMMIT decides both — the same reasoning
1036
- * that put the webhook queue there. A submission exists if and only if the
1037
- * files it names belong to it. Claiming separately gives the two failures
1038
- * nobody can debug: a rolled-back submission leaving files claimed against
1039
- * nothing, or an accepted submission referencing bytes the collector is about
1040
- * to delete.
1041
- *
1042
- * Refuses rather than skips. A submission naming a file that does not exist,
1043
- * belongs to another form, or is already claimed is not a submission to fix up
1044
- * quietly: the first is a bug or somebody guessing ids; the second is how an
1045
- * id from a form you may submit to would let you attach a file from one you
1046
- * may not; the third would make deleting a submission either leak bytes or
1047
- * break a different one.
1048
- */
1049
- export declare function filesToClaim(storage: Storage, input: {
1050
- schema: FormSchema;
1051
- formId: string;
1052
- data: unknown;
1053
- }): Promise<ClaimOutcome>;
1054
- /**
1055
- * Files nobody claimed, old enough that nobody is going to.
1056
- *
1057
- * Returns the rows rather than deleting anything: the host removes the bytes
1058
- * and then the rows, in that order, because a row without bytes is a broken
1059
- * reference and bytes without a row are invisible rubbish. Of the two, the
1060
- * second is the one you never find out about.
1061
- */
1062
- export declare function collectAbandonedFiles(storage: Storage, now: Date, afterHours: number): Promise<FileRecord[]>;
1063
- /** Re-exported so a host can see which paths a schema's file fields live at. */
1064
- export declare function fileFieldPaths(schema: FormSchema): string[];
1065
- //#endregion
1066
1289
  //#region src/publishing.d.ts
1067
1290
  /**
1068
1291
  * Publishing a form: the save gate, and who may overwrite whom.
@@ -1157,7 +1380,189 @@ export declare function publishForm(deps: ServerDeps, input: {
1157
1380
  * asks to be told it has been overtaken.
1158
1381
  */
1159
1382
  basedOnSchemaHash?: string;
1383
+ /** The request this publish is answering, which its audit row names (`AuditEntry.requestId`). */
1384
+ requestId?: string;
1160
1385
  }): Promise<PublishOutcome>;
1161
1386
  //#endregion
1162
- export { type Action, type Actor, type AttemptOutcome, type AuditAction, type AuditDraft, type AuditEntry, type AuthDeps, type AuthOutcome, type BreakerState, CHALLENGE_TTL_SECONDS, type Challenge, type ClaimOutcome, DEFAULT_MAX_NUMBER, type DeliveryHeaders, type DeliveryRecord, type DraftMigration, type DraftRecord, type FileRecord, type FormRecord, type FormVersionRecord, type ListedForm, type ListedSubmission, type ListedVersion, type MembershipOutcome, type OfferDeps, type OfferInput, type OfferOutcome, type OutboxDeps, type PublishOutcome, type ReplayOutcome, type ResolvedForm, type ResumeOutcome, type Role, type ServerDeps, type ServerOptionsSource, type ServerOptionsSources, type Solution, type Storage, type SubmissionOutcome, type SubmissionRecord, type UnsafePattern, type VerifyOutcome, type WebhookHealth, type WebhookRecord, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution };
1387
+ //#region src/examples.d.ts
1388
+ /**
1389
+ * A form's examples, kept by the deployment beside the form, and run when it is published
1390
+ * ([0166](../../../docs/decisions/0166-a-deployment-keeps-a-forms-examples-and-runs-them-at-publish.md)).
1391
+ *
1392
+ * An example with its answer written down is the one check that tells a condition that
1393
+ * compiles from the condition that was asked for
1394
+ * ([0110](../../../docs/decisions/0110-a-form-is-checked-against-examples.md)). The builders
1395
+ * run them, and the scenarios are the host's to keep
1396
+ * ([0111](../../../docs/decisions/0111-a-scenario-panel-names-what-stopped-holding.md)); on a
1397
+ * deployment the host is this server, so it keeps them, with the fictional sample they start
1398
+ * from, and publishing — the last moment before a version is frozen — runs them.
1399
+ *
1400
+ * **The permission is decided here, not in the route.** Reading and changing a form's
1401
+ * examples take what editing the form takes, `form.publish`, and a host composing its own
1402
+ * HTTP over this package gets that rule with the use-case rather than having to remember it.
1403
+ *
1404
+ * **What is kept is read as what is sent is read**, by `readList`, on the way in and on the
1405
+ * way out. A list sent to be kept with anything in it that is not an example keeps nothing; a
1406
+ * row already kept that is not one — edited around `keepExamples` — is left out and named
1407
+ * wherever the examples are read back, so the admin that draws them and the publish that runs
1408
+ * them are only ever handed examples.
1409
+ */
1410
+ /** A form's examples as a builder holds them: the list, and where every one starts. */
1411
+ interface FormExamples {
1412
+ readonly scenarios: readonly Scenario[];
1413
+ /** Absent when none was kept: every example then starts from an empty form. */
1414
+ readonly sample?: Readonly<Record<string, unknown>>;
1415
+ /**
1416
+ * When they are read back: what is kept and is not an example, or not a sample, one sentence
1417
+ * each. Left out of the above, so nothing draws or runs it, and gone at the next save, which
1418
+ * keeps only what it is sent. Absent when everything kept could be read.
1419
+ */
1420
+ readonly unreadable?: readonly string[];
1421
+ }
1422
+ type ExamplesOutcome = {
1423
+ readonly ok: true;
1424
+ readonly examples: FormExamples;
1425
+ } |
1426
+ /** Not with this actor's role: a viewer may read the form, and not what it is checked against. */
1427
+ {
1428
+ readonly ok: false;
1429
+ readonly kind: 'forbidden';
1430
+ } |
1431
+ /** Examples are kept beside a form, so there must be one at this path. */
1432
+ {
1433
+ readonly ok: false;
1434
+ readonly kind: 'unknown_form';
1435
+ };
1436
+ type KeepExamplesOutcome = ExamplesOutcome |
1437
+ /** Something sent is not an example, or not a sample: one sentence each, and nothing kept. */
1438
+ {
1439
+ readonly ok: false;
1440
+ readonly kind: 'invalid_examples';
1441
+ readonly problems: readonly string[];
1442
+ };
1443
+ /** The examples kept for the form at `path`; none, for a form nobody wrote any for. */
1444
+ export declare function readExamples(deps: ServerDeps, input: {
1445
+ path: string;
1446
+ actor: Actor;
1447
+ }): Promise<ExamplesOutcome>;
1448
+ /**
1449
+ * Replace the examples of the form at `path`, and the sample they start from.
1450
+ *
1451
+ * Each is read as an example the way a builder reads a model's draft — `readScenario`, one
1452
+ * decision for both — and a list with any item that is not one keeps nothing. Paths are not
1453
+ * checked against the published form: an example is written for the form being edited, which
1454
+ * may name a field no version has yet, and the runner says so when it runs.
1455
+ */
1456
+ export declare function keepExamples(deps: ServerDeps,
1457
+ /** `requestId` is the request this change answers, which its audit row names. */
1458
+ input: {
1459
+ path: string;
1460
+ actor: Actor;
1461
+ examples: unknown;
1462
+ requestId?: string;
1463
+ }): Promise<KeepExamplesOutcome>;
1464
+ //#endregion
1465
+ //#region src/model.d.ts
1466
+ /**
1467
+ * The deployment's own model, asked on a builder's behalf.
1468
+ *
1469
+ * The builders ask a model through an `AskModel` the host supplies, and a key in a browser
1470
+ * is a key anybody who opens the page can read. So a deployment that has a model keeps it
1471
+ * here, behind its own sessions, and the admin's `AskModel` is a request to this server
1472
+ * ([0165](../../../docs/decisions/0165-a-deployments-model-is-asked-through-its-server.md)).
1473
+ *
1474
+ * **The server writes the briefing.** The browser names the kind of request — one of the
1475
+ * three formancy makes — and sends the user part; the system part is the one
1476
+ * `@formancy/builder-core` briefs that kind with, and one in the request is never read, so
1477
+ * every request the operator's key pays for is asked under one of formancy's briefings.
1478
+ * That narrows the endpoint; it does not close it. The user part is still the person's
1479
+ * text, and nothing here stops it asking the model for something else under formancy's
1480
+ * briefing: the permission, the rate limit, the size cap and the output limit bound what
1481
+ * that costs, and the audit log says who asked.
1482
+ */
1483
+ /** Everything a completion is asked: one system part and one user part, no conversation. */
1484
+ interface CompletionPrompt {
1485
+ readonly system: string;
1486
+ readonly user: string;
1487
+ }
1488
+ /**
1489
+ * Told when whoever asked has gone, so the request is abandoned at the provider rather than
1490
+ * paid for to the end.
1491
+ *
1492
+ * A callback rather than an `AbortSignal`, which this package's types do not have
1493
+ * ([0008](../../../docs/decisions/0008-layered-packages.md)) — the shape builder-core's
1494
+ * `AskTurn` has for the same reason. An adapter wires it in one line:
1495
+ * `const c = new AbortController(); cancellation.onCancel(() => c.abort())`.
1496
+ */
1497
+ interface Cancellation {
1498
+ /** Called at most once: when the asker goes, or at once if it already has. */
1499
+ onCancel(listener: () => void): void;
1500
+ }
1501
+ /**
1502
+ * What one completion came to. Only the first is an answer.
1503
+ *
1504
+ * - `refused`: the provider declined in its response rather than in its text. `reason` is
1505
+ * for the person, and ends their run as a decline
1506
+ * ([0158](../../../docs/decisions/0158-a-model-may-decline.md)).
1507
+ * - `truncated`: the answer reached the output limit. Cut off, it is not an answer, and
1508
+ * is never handed over as half a document.
1509
+ * - `unavailable`: not reached, or it answered with an error. `status` is the provider's
1510
+ * HTTP status when it gave one; `cause` is for the operator's log, never the client.
1511
+ * - `cancelled`: the asker went away and the request was abandoned.
1512
+ */
1513
+ type Completion = {
1514
+ readonly ok: true;
1515
+ readonly text: string;
1516
+ } | {
1517
+ readonly ok: false;
1518
+ readonly failure: 'refused';
1519
+ readonly reason: string;
1520
+ } | {
1521
+ readonly ok: false;
1522
+ readonly failure: 'truncated';
1523
+ } | {
1524
+ readonly ok: false;
1525
+ readonly failure: 'unavailable';
1526
+ readonly status?: number;
1527
+ readonly cause: string;
1528
+ } | {
1529
+ readonly ok: false;
1530
+ readonly failure: 'cancelled';
1531
+ };
1532
+ /**
1533
+ * A model, asked to complete one prompt.
1534
+ *
1535
+ * A port because what is behind it is genuinely swappable: the deployment chooses Anthropic,
1536
+ * OpenAI or xAI, `@formancy/server` has an adapter for each, and the tests drive the route
1537
+ * through doubles. **It does not throw for a provider's failure**; it says which failure.
1538
+ */
1539
+ interface Completer {
1540
+ complete(prompt: CompletionPrompt, cancellation: Cancellation): Promise<Completion>;
1541
+ }
1542
+ /** A builder's request as the browser sends it, unread. */
1543
+ interface BuilderRequest {
1544
+ readonly kind?: unknown;
1545
+ readonly user?: unknown;
1546
+ }
1547
+ type BuilderRequestOutcome = Completion |
1548
+ /** Not one of the requests formancy makes, so there is no briefing to ask it under. */
1549
+ {
1550
+ readonly ok: false;
1551
+ readonly failure: 'unknown_kind';
1552
+ } |
1553
+ /** No text to ask about: the briefing alone, which no run sends. */
1554
+ {
1555
+ readonly ok: false;
1556
+ readonly failure: 'no_user';
1557
+ };
1558
+ /**
1559
+ * Ask the deployment's model a builder's request, under the briefing for its kind.
1560
+ *
1561
+ * Whatever else the request carries is not read — a `system` above all. Resolves on every
1562
+ * ending: an adapter that throws broke its contract, and is still a model that could not
1563
+ * be asked.
1564
+ */
1565
+ export declare function completeBuilderRequest(completer: Completer, request: BuilderRequest, cancellation: Cancellation): Promise<BuilderRequestOutcome>;
1566
+ //#endregion
1567
+ export { type Action, type Actor, type AttemptOutcome, type AuditAction, type AuditDraft, type AuditEntry, type AuthDeps, type AuthOutcome, type BreakerState, type BuilderRequest, type BuilderRequestOutcome, CHALLENGE_TTL_SECONDS, type Cancellation, type Challenge, type ClaimOutcome, type Completer, type Completion, type CompletionPrompt, DEFAULT_MAX_NUMBER, type DeliveryHeaders, type DeliveryRecord, type DraftMigration, type DraftRecord, type ExamplesOutcome, type ExamplesRecord, type FileRecord, type FormExamples, type FormRecord, type FormToFill, type FormVersionRecord, type KeepExamplesOutcome, type ListedForm, type ListedSubmission, type ListedVersion, type MembershipOutcome, type OfferDeps, type OfferInput, type OfferOutcome, type OutboxDeps, type PublishOutcome, type ReplayOutcome, type ResolvedForm, type ResumeOutcome, type Role, type ScanVerdict, type Scanner, type ScreenOutcome, type ServerDeps, type ServerOptionsSource, type ServerOptionsSources, type Solution, type Storage, type SubmissionOutcome, type SubmissionRecord, type UnsafePattern, type VerifyOutcome, type WebhookHealth, type WebhookRecord, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution };
1163
1568
  //# sourceMappingURL=index.d.mts.map