@formancy/server-core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3,9 +3,9 @@ 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
5
  import { diffSchemas, modelDataPaths, schemaHash } from "@formancy/spec";
6
- import { validateSchema } from "@formancy/spec/validate";
7
- import { createFormEngine, expressionProblems } from "@formancy/core";
6
+ import { createFormEngine, expressionProblems, unknownReferences } from "@formancy/core";
8
7
  import { check } from "recheck";
8
+ import { validateSchema } from "@formancy/spec/validate";
9
9
  //#region src/options-membership.ts
10
10
  /**
11
11
  * Every sourced answer in a submission, as `path → { source, value }`.
@@ -29,12 +29,15 @@ function sourcedAnswers(schema, value) {
29
29
  continue;
30
30
  }
31
31
  if (field.optionsSource === void 0) continue;
32
- if (typeof held !== "string" || held === "") continue;
33
- found.push({
34
- path: at,
35
- source: field.optionsSource,
36
- value: held
37
- });
32
+ const answers = Array.isArray(held) ? held : [held];
33
+ for (const answer of answers) {
34
+ if (typeof answer !== "string" || answer === "") continue;
35
+ found.push({
36
+ path: at,
37
+ source: field.optionsSource,
38
+ value: answer
39
+ });
40
+ }
38
41
  }
39
42
  };
40
43
  walk(schema.model.fields, value, "");
@@ -492,6 +495,38 @@ async function offerUpload(deps, input) {
492
495
  };
493
496
  }
494
497
  /**
498
+ * Whether an upload's bytes may be kept.
499
+ *
500
+ * **Fail closed.** A scanner that cannot be asked refuses the file, the same as one that
501
+ * found something: a deployment that configured a scanner did so to keep unscanned files
502
+ * out, and its being down is not a reason to let one in. The person is told to try again,
503
+ * and the field offers exactly that. Without a scanner every upload is kept, as it was
504
+ * before there was one — a supported state, said in the documentation rather than here
505
+ * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
506
+ */
507
+ async function screenUpload(scanner, file, bytes) {
508
+ if (scanner === void 0) return { ok: true };
509
+ let verdict;
510
+ try {
511
+ verdict = await scanner.scan({
512
+ name: file.name,
513
+ contentType: file.contentType,
514
+ size: file.size
515
+ }, bytes);
516
+ } catch (error) {
517
+ return {
518
+ ok: false,
519
+ reason: "scanner_unavailable",
520
+ cause: error instanceof Error ? error.message : String(error)
521
+ };
522
+ }
523
+ return verdict.clean ? { ok: true } : {
524
+ ok: false,
525
+ reason: "refused",
526
+ finding: verdict.finding
527
+ };
528
+ }
529
+ /**
495
530
  * Which files a submission may claim, checked but not yet claimed.
496
531
  *
497
532
  * The claiming itself is `insertSubmission`'s job, in the same call that
@@ -623,187 +658,7 @@ function fileFieldPaths(schema) {
623
658
  return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
624
659
  }
625
660
  //#endregion
626
- //#region src/redos.ts
627
- /** Every `pattern` in the model, with the data path of the field it sits on. */
628
- function patternsIn(fields, prefix) {
629
- const found = [];
630
- for (const field of fields) {
631
- const path = field.type === "page" ? prefix : field.type === "repeater" ? `${prefix}${field.key}[].` : field.type === "group" ? `${prefix}${field.key}.` : `${prefix}${field.key}`;
632
- if (typeof field.pattern === "string" && field.pattern !== "") found.push({
633
- path,
634
- pattern: field.pattern
635
- });
636
- if (field.fields !== void 0) found.push(...patternsIn(field.fields, path));
637
- }
638
- return found;
639
- }
640
- /**
641
- * The patterns in this schema that an attacker could exploit, or an empty array
642
- * when there are none.
643
- *
644
- * A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
645
- * would make publishing depend on an analysis timeout, which is a worse failure
646
- * than the one being prevented — an author whose legitimate form stops
647
- * publishing intermittently has no way to act on that.
648
- */
649
- async function unsafePatterns(schema) {
650
- const unsafe = [];
651
- for (const { path, pattern } of patternsIn(schema.model.fields, "")) {
652
- const diagnostics = await check(`^(?:${pattern})$`, "");
653
- if (diagnostics.status !== "vulnerable") continue;
654
- const complexity = diagnostics.complexity;
655
- unsafe.push({
656
- path,
657
- pattern,
658
- complexity: complexity === void 0 ? "unknown" : complexity.type === "polynomial" ? `polynomial degree ${String(complexity.degree)}` : complexity.type
659
- });
660
- }
661
- return unsafe;
662
- }
663
- //#endregion
664
661
  //#region src/use-cases.ts
665
- /**
666
- * Publish a schema as a form's next version.
667
- *
668
- * This is the save gate: structural validation, then the ENGINE's own compile
669
- * — type-checked expressions, kind policies and the cycle graph — so a form
670
- * that can loop or read a ghost field is refused here and never persisted.
671
- * Republishing the identical document is idempotent, because "deploy again"
672
- * must never manufacture a version.
673
- */
674
- async function publishForm(deps, input) {
675
- const validated = validateSchema(input.schema);
676
- if (!validated.valid) return {
677
- ok: false,
678
- kind: "invalid_schema",
679
- errors: validated.errors
680
- };
681
- const schema = validated.schema;
682
- try {
683
- createFormEngine({
684
- schema,
685
- capabilities: deps.capabilities
686
- });
687
- } catch (error) {
688
- return {
689
- ok: false,
690
- kind: "invalid_logic",
691
- message: error instanceof Error ? error.message : String(error)
692
- };
693
- }
694
- const problems = expressionProblems(schema);
695
- if (problems.length > 0) return {
696
- ok: false,
697
- kind: "invalid_logic",
698
- message: problems.map((problem) => problem.message).join("\n")
699
- };
700
- const unsafe = await unsafePatterns(schema);
701
- if (unsafe.length > 0) return {
702
- ok: false,
703
- kind: "unsafe_pattern",
704
- patterns: unsafe
705
- };
706
- if (deps.optionsSources !== void 0) {
707
- const unknown = sourceNamesIn(schema).filter((name) => deps.optionsSources?.[name] === void 0);
708
- if (unknown.length > 0) return {
709
- ok: false,
710
- kind: "unknown_options_source",
711
- sources: unknown
712
- };
713
- }
714
- const hash = schemaHash(schema);
715
- const existing = await deps.storage.getFormByPath(input.path);
716
- if (existing !== void 0) {
717
- const current = existing.currentVersionId === null ? void 0 : await deps.storage.getVersionById(existing.currentVersionId);
718
- if (current !== void 0 && current.schemaHash === hash) return {
719
- ok: true,
720
- formId: existing.id,
721
- versionId: current.id,
722
- version: current.version,
723
- schemaHash: hash
724
- };
725
- const version = await deps.storage.latestVersionNumber(existing.id) + 1;
726
- const versionId = deps.newId();
727
- await deps.storage.publishVersion({
728
- version: {
729
- id: versionId,
730
- formId: existing.id,
731
- version,
732
- schema,
733
- schemaHash: hash
734
- },
735
- audit: publishAudit(deps, input, {
736
- path: input.path,
737
- version,
738
- hash,
739
- sources: sourceNamesIn(schema)
740
- })
741
- });
742
- return {
743
- ok: true,
744
- formId: existing.id,
745
- versionId,
746
- version,
747
- schemaHash: hash
748
- };
749
- }
750
- const formId = deps.newId();
751
- const versionId = deps.newId();
752
- await deps.storage.publishVersion({
753
- form: {
754
- id: formId,
755
- path: input.path,
756
- currentVersionId: null,
757
- accessSubmit: "authenticated",
758
- allowedOrigins: null
759
- },
760
- version: {
761
- id: versionId,
762
- formId,
763
- version: 1,
764
- schema,
765
- schemaHash: hash
766
- },
767
- audit: publishAudit(deps, input, {
768
- path: input.path,
769
- version: 1,
770
- hash,
771
- sources: sourceNamesIn(schema)
772
- })
773
- });
774
- return {
775
- ok: true,
776
- formId,
777
- versionId,
778
- version: 1,
779
- schemaHash: hash
780
- };
781
- }
782
- /**
783
- * The audit row for a publish, built here so it can travel INSIDE the
784
- * transaction rather than being appended after it.
785
- *
786
- * Before this it was written by the route once all three storage calls had
787
- * returned, which meant a publish could half-apply and still be recorded as
788
- * having happened. Now it rolls back with everything else.
789
- */
790
- function publishAudit(deps, input, about) {
791
- return {
792
- id: deps.newId(),
793
- at: deps.nowIso(),
794
- action: "form.published",
795
- subject: about.path,
796
- ...input.actor === void 0 ? {} : {
797
- actorKind: input.actor.kind,
798
- actorId: input.actor.id
799
- },
800
- detail: {
801
- version: about.version,
802
- schemaHash: about.hash,
803
- ...about.sources.length === 0 ? {} : { optionsSources: [...about.sources].sort().join(", ") }
804
- }
805
- };
806
- }
807
662
  async function resolveForm(deps, path) {
808
663
  const form = await deps.storage.getFormByPath(path);
809
664
  if (form === void 0 || form.currentVersionId === null) return void 0;
@@ -865,8 +720,11 @@ async function createSubmission(deps, input) {
865
720
  const engine = createFormEngine({
866
721
  schema: current.schema,
867
722
  initialValue: input.data,
868
- capabilities: deps.capabilities
723
+ capabilities: deps.capabilities,
724
+ mode: "server",
725
+ ...deps.checks === void 0 ? {} : { checks: deps.checks }
869
726
  });
727
+ await engine.settle();
870
728
  const outcome = engine.submit();
871
729
  if (!outcome.ok) return {
872
730
  ok: false,
@@ -1224,6 +1082,44 @@ async function listVersions(deps, path) {
1224
1082
  }));
1225
1083
  }
1226
1084
  //#endregion
1085
+ //#region src/redos.ts
1086
+ /** Every `pattern` in the model, with the data path of the field it sits on. */
1087
+ function patternsIn(fields, prefix) {
1088
+ const found = [];
1089
+ for (const field of fields) {
1090
+ const path = field.type === "page" ? prefix : field.type === "repeater" ? `${prefix}${field.key}[].` : field.type === "group" ? `${prefix}${field.key}.` : `${prefix}${field.key}`;
1091
+ if (typeof field.pattern === "string" && field.pattern !== "") found.push({
1092
+ path,
1093
+ pattern: field.pattern
1094
+ });
1095
+ if (field.fields !== void 0) found.push(...patternsIn(field.fields, path));
1096
+ }
1097
+ return found;
1098
+ }
1099
+ /**
1100
+ * The patterns in this schema that an attacker could exploit, or an empty array
1101
+ * when there are none.
1102
+ *
1103
+ * A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
1104
+ * would make publishing depend on an analysis timeout, which is a worse failure
1105
+ * than the one being prevented — an author whose legitimate form stops
1106
+ * publishing intermittently has no way to act on that.
1107
+ */
1108
+ async function unsafePatterns(schema) {
1109
+ const unsafe = [];
1110
+ for (const { path, pattern } of patternsIn(schema.model.fields, "")) {
1111
+ const diagnostics = await check(`^(?:${pattern})$`, "");
1112
+ if (diagnostics.status !== "vulnerable") continue;
1113
+ const complexity = diagnostics.complexity;
1114
+ unsafe.push({
1115
+ path,
1116
+ pattern,
1117
+ complexity: complexity === void 0 ? "unknown" : complexity.type === "polynomial" ? `polynomial degree ${String(complexity.degree)}` : complexity.type
1118
+ });
1119
+ }
1120
+ return unsafe;
1121
+ }
1122
+ //#endregion
1227
1123
  //#region src/testing/memory-storage.ts
1228
1124
  /**
1229
1125
  * The in-memory Storage — the second implementation that keeps the port
@@ -1543,6 +1439,175 @@ function isIpLiteral(host) {
1543
1439
  return bare.includes(":");
1544
1440
  }
1545
1441
  //#endregion
1546
- export { AUDIT_ACTIONS, BREAKER_COOLDOWN_MS, BREAKER_THRESHOLD, CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, EVENT_ID_HEADER, MAX_ATTEMPTS, SIGNATURE_HEADER, afterAttempt, afterWebhookAttempt, auditedBy, authenticateApiKey, authenticateLocal, breakerState, can, checkMembership, collectAbandonedFiles, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, hashOf, healthOf, isIpLiteral, isPrivateAddress, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
1442
+ //#region src/publishing.ts
1443
+ /**
1444
+ * Publish a schema as a form's next version.
1445
+ *
1446
+ * This is the save gate: structural validation, then the ENGINE's own compile
1447
+ * — type-checked expressions, kind policies and the cycle graph — so a form
1448
+ * that can loop or read a ghost field is refused here and never persisted.
1449
+ * Republishing the identical document is idempotent, because "deploy again"
1450
+ * must never manufacture a version.
1451
+ */
1452
+ async function publishForm(deps, input) {
1453
+ const validated = validateSchema(input.schema);
1454
+ if (!validated.valid) return {
1455
+ ok: false,
1456
+ kind: "invalid_schema",
1457
+ errors: validated.errors
1458
+ };
1459
+ const schema = validated.schema;
1460
+ try {
1461
+ createFormEngine({
1462
+ schema,
1463
+ capabilities: deps.capabilities
1464
+ });
1465
+ } catch (error) {
1466
+ return {
1467
+ ok: false,
1468
+ kind: "invalid_logic",
1469
+ message: error instanceof Error ? error.message : String(error)
1470
+ };
1471
+ }
1472
+ const problems = expressionProblems(schema);
1473
+ if (problems.length > 0) return {
1474
+ ok: false,
1475
+ kind: "invalid_logic",
1476
+ message: problems.map((problem) => problem.message).join("\n")
1477
+ };
1478
+ const warnings = unknownReferences(schema).map((found) => found.message);
1479
+ const unsafe = await unsafePatterns(schema);
1480
+ if (unsafe.length > 0) return {
1481
+ ok: false,
1482
+ kind: "unsafe_pattern",
1483
+ patterns: unsafe
1484
+ };
1485
+ if (deps.optionsSources !== void 0) {
1486
+ const unknown = sourceNamesIn(schema).filter((name) => deps.optionsSources?.[name] === void 0);
1487
+ if (unknown.length > 0) return {
1488
+ ok: false,
1489
+ kind: "unknown_options_source",
1490
+ sources: unknown
1491
+ };
1492
+ }
1493
+ const hash = schemaHash(schema);
1494
+ const existing = await deps.storage.getFormByPath(input.path);
1495
+ if (existing !== void 0) {
1496
+ const current = existing.currentVersionId === null ? void 0 : await deps.storage.getVersionById(existing.currentVersionId);
1497
+ if (current !== void 0 && current.schemaHash === hash) return {
1498
+ ok: true,
1499
+ formId: existing.id,
1500
+ versionId: current.id,
1501
+ version: current.version,
1502
+ schemaHash: hash,
1503
+ warnings
1504
+ };
1505
+ if (input.basedOnSchemaHash !== void 0 && input.basedOnSchemaHash !== current?.schemaHash) return {
1506
+ ok: false,
1507
+ kind: "version_changed",
1508
+ ...current === void 0 ? {} : { current: {
1509
+ formId: existing.id,
1510
+ versionId: current.id,
1511
+ version: current.version,
1512
+ schema: current.schema,
1513
+ schemaHash: current.schemaHash
1514
+ } }
1515
+ };
1516
+ const already = (await deps.storage.listVersionsByForm(existing.id)).find((candidate) => candidate.schemaHash === hash);
1517
+ if (already !== void 0) return {
1518
+ ok: false,
1519
+ kind: "already_published",
1520
+ version: already.version
1521
+ };
1522
+ const version = await deps.storage.latestVersionNumber(existing.id) + 1;
1523
+ const versionId = deps.newId();
1524
+ await deps.storage.publishVersion({
1525
+ version: {
1526
+ id: versionId,
1527
+ formId: existing.id,
1528
+ version,
1529
+ schema,
1530
+ schemaHash: hash
1531
+ },
1532
+ audit: publishAudit(deps, input, {
1533
+ path: input.path,
1534
+ version,
1535
+ hash,
1536
+ sources: sourceNamesIn(schema)
1537
+ })
1538
+ });
1539
+ return {
1540
+ ok: true,
1541
+ formId: existing.id,
1542
+ versionId,
1543
+ version,
1544
+ schemaHash: hash,
1545
+ warnings
1546
+ };
1547
+ }
1548
+ if (input.basedOnSchemaHash !== void 0) return {
1549
+ ok: false,
1550
+ kind: "version_changed"
1551
+ };
1552
+ const formId = deps.newId();
1553
+ const versionId = deps.newId();
1554
+ await deps.storage.publishVersion({
1555
+ form: {
1556
+ id: formId,
1557
+ path: input.path,
1558
+ currentVersionId: null,
1559
+ accessSubmit: "authenticated",
1560
+ allowedOrigins: null
1561
+ },
1562
+ version: {
1563
+ id: versionId,
1564
+ formId,
1565
+ version: 1,
1566
+ schema,
1567
+ schemaHash: hash
1568
+ },
1569
+ audit: publishAudit(deps, input, {
1570
+ path: input.path,
1571
+ version: 1,
1572
+ hash,
1573
+ sources: sourceNamesIn(schema)
1574
+ })
1575
+ });
1576
+ return {
1577
+ ok: true,
1578
+ formId,
1579
+ versionId,
1580
+ version: 1,
1581
+ schemaHash: hash,
1582
+ warnings
1583
+ };
1584
+ }
1585
+ /**
1586
+ * The audit row for a publish, built here so it can travel INSIDE the
1587
+ * transaction rather than being appended after it.
1588
+ *
1589
+ * Before this it was written by the route once all three storage calls had
1590
+ * returned, which meant a publish could half-apply and still be recorded as
1591
+ * having happened. Now it rolls back with everything else.
1592
+ */
1593
+ function publishAudit(deps, input, about) {
1594
+ return {
1595
+ id: deps.newId(),
1596
+ at: deps.nowIso(),
1597
+ action: "form.published",
1598
+ subject: about.path,
1599
+ ...input.actor === void 0 ? {} : {
1600
+ actorKind: input.actor.kind,
1601
+ actorId: input.actor.id
1602
+ },
1603
+ detail: {
1604
+ version: about.version,
1605
+ schemaHash: about.hash,
1606
+ ...about.sources.length === 0 ? {} : { optionsSources: [...about.sources].sort().join(", ") }
1607
+ }
1608
+ };
1609
+ }
1610
+ //#endregion
1611
+ export { AUDIT_ACTIONS, BREAKER_COOLDOWN_MS, BREAKER_THRESHOLD, CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, EVENT_ID_HEADER, MAX_ATTEMPTS, SIGNATURE_HEADER, afterAttempt, afterWebhookAttempt, auditedBy, authenticateApiKey, authenticateLocal, breakerState, can, checkMembership, collectAbandonedFiles, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, hashOf, healthOf, isIpLiteral, isPrivateAddress, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, screenUpload, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
1547
1612
 
1548
1613
  //# sourceMappingURL=index.mjs.map