@formancy/server-core 0.2.0 → 0.3.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, "");
@@ -623,187 +626,7 @@ function fileFieldPaths(schema) {
623
626
  return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
624
627
  }
625
628
  //#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
629
  //#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
630
  async function resolveForm(deps, path) {
808
631
  const form = await deps.storage.getFormByPath(path);
809
632
  if (form === void 0 || form.currentVersionId === null) return void 0;
@@ -865,8 +688,11 @@ async function createSubmission(deps, input) {
865
688
  const engine = createFormEngine({
866
689
  schema: current.schema,
867
690
  initialValue: input.data,
868
- capabilities: deps.capabilities
691
+ capabilities: deps.capabilities,
692
+ mode: "server",
693
+ ...deps.checks === void 0 ? {} : { checks: deps.checks }
869
694
  });
695
+ await engine.settle();
870
696
  const outcome = engine.submit();
871
697
  if (!outcome.ok) return {
872
698
  ok: false,
@@ -1224,6 +1050,44 @@ async function listVersions(deps, path) {
1224
1050
  }));
1225
1051
  }
1226
1052
  //#endregion
1053
+ //#region src/redos.ts
1054
+ /** Every `pattern` in the model, with the data path of the field it sits on. */
1055
+ function patternsIn(fields, prefix) {
1056
+ const found = [];
1057
+ for (const field of fields) {
1058
+ const path = field.type === "page" ? prefix : field.type === "repeater" ? `${prefix}${field.key}[].` : field.type === "group" ? `${prefix}${field.key}.` : `${prefix}${field.key}`;
1059
+ if (typeof field.pattern === "string" && field.pattern !== "") found.push({
1060
+ path,
1061
+ pattern: field.pattern
1062
+ });
1063
+ if (field.fields !== void 0) found.push(...patternsIn(field.fields, path));
1064
+ }
1065
+ return found;
1066
+ }
1067
+ /**
1068
+ * The patterns in this schema that an attacker could exploit, or an empty array
1069
+ * when there are none.
1070
+ *
1071
+ * A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
1072
+ * would make publishing depend on an analysis timeout, which is a worse failure
1073
+ * than the one being prevented — an author whose legitimate form stops
1074
+ * publishing intermittently has no way to act on that.
1075
+ */
1076
+ async function unsafePatterns(schema) {
1077
+ const unsafe = [];
1078
+ for (const { path, pattern } of patternsIn(schema.model.fields, "")) {
1079
+ const diagnostics = await check(`^(?:${pattern})$`, "");
1080
+ if (diagnostics.status !== "vulnerable") continue;
1081
+ const complexity = diagnostics.complexity;
1082
+ unsafe.push({
1083
+ path,
1084
+ pattern,
1085
+ complexity: complexity === void 0 ? "unknown" : complexity.type === "polynomial" ? `polynomial degree ${String(complexity.degree)}` : complexity.type
1086
+ });
1087
+ }
1088
+ return unsafe;
1089
+ }
1090
+ //#endregion
1227
1091
  //#region src/testing/memory-storage.ts
1228
1092
  /**
1229
1093
  * The in-memory Storage — the second implementation that keeps the port
@@ -1543,6 +1407,175 @@ function isIpLiteral(host) {
1543
1407
  return bare.includes(":");
1544
1408
  }
1545
1409
  //#endregion
1410
+ //#region src/publishing.ts
1411
+ /**
1412
+ * Publish a schema as a form's next version.
1413
+ *
1414
+ * This is the save gate: structural validation, then the ENGINE's own compile
1415
+ * — type-checked expressions, kind policies and the cycle graph — so a form
1416
+ * that can loop or read a ghost field is refused here and never persisted.
1417
+ * Republishing the identical document is idempotent, because "deploy again"
1418
+ * must never manufacture a version.
1419
+ */
1420
+ async function publishForm(deps, input) {
1421
+ const validated = validateSchema(input.schema);
1422
+ if (!validated.valid) return {
1423
+ ok: false,
1424
+ kind: "invalid_schema",
1425
+ errors: validated.errors
1426
+ };
1427
+ const schema = validated.schema;
1428
+ try {
1429
+ createFormEngine({
1430
+ schema,
1431
+ capabilities: deps.capabilities
1432
+ });
1433
+ } catch (error) {
1434
+ return {
1435
+ ok: false,
1436
+ kind: "invalid_logic",
1437
+ message: error instanceof Error ? error.message : String(error)
1438
+ };
1439
+ }
1440
+ const problems = expressionProblems(schema);
1441
+ if (problems.length > 0) return {
1442
+ ok: false,
1443
+ kind: "invalid_logic",
1444
+ message: problems.map((problem) => problem.message).join("\n")
1445
+ };
1446
+ const warnings = unknownReferences(schema).map((found) => found.message);
1447
+ const unsafe = await unsafePatterns(schema);
1448
+ if (unsafe.length > 0) return {
1449
+ ok: false,
1450
+ kind: "unsafe_pattern",
1451
+ patterns: unsafe
1452
+ };
1453
+ if (deps.optionsSources !== void 0) {
1454
+ const unknown = sourceNamesIn(schema).filter((name) => deps.optionsSources?.[name] === void 0);
1455
+ if (unknown.length > 0) return {
1456
+ ok: false,
1457
+ kind: "unknown_options_source",
1458
+ sources: unknown
1459
+ };
1460
+ }
1461
+ const hash = schemaHash(schema);
1462
+ const existing = await deps.storage.getFormByPath(input.path);
1463
+ if (existing !== void 0) {
1464
+ const current = existing.currentVersionId === null ? void 0 : await deps.storage.getVersionById(existing.currentVersionId);
1465
+ if (current !== void 0 && current.schemaHash === hash) return {
1466
+ ok: true,
1467
+ formId: existing.id,
1468
+ versionId: current.id,
1469
+ version: current.version,
1470
+ schemaHash: hash,
1471
+ warnings
1472
+ };
1473
+ if (input.basedOnSchemaHash !== void 0 && input.basedOnSchemaHash !== current?.schemaHash) return {
1474
+ ok: false,
1475
+ kind: "version_changed",
1476
+ ...current === void 0 ? {} : { current: {
1477
+ formId: existing.id,
1478
+ versionId: current.id,
1479
+ version: current.version,
1480
+ schema: current.schema,
1481
+ schemaHash: current.schemaHash
1482
+ } }
1483
+ };
1484
+ const already = (await deps.storage.listVersionsByForm(existing.id)).find((candidate) => candidate.schemaHash === hash);
1485
+ if (already !== void 0) return {
1486
+ ok: false,
1487
+ kind: "already_published",
1488
+ version: already.version
1489
+ };
1490
+ const version = await deps.storage.latestVersionNumber(existing.id) + 1;
1491
+ const versionId = deps.newId();
1492
+ await deps.storage.publishVersion({
1493
+ version: {
1494
+ id: versionId,
1495
+ formId: existing.id,
1496
+ version,
1497
+ schema,
1498
+ schemaHash: hash
1499
+ },
1500
+ audit: publishAudit(deps, input, {
1501
+ path: input.path,
1502
+ version,
1503
+ hash,
1504
+ sources: sourceNamesIn(schema)
1505
+ })
1506
+ });
1507
+ return {
1508
+ ok: true,
1509
+ formId: existing.id,
1510
+ versionId,
1511
+ version,
1512
+ schemaHash: hash,
1513
+ warnings
1514
+ };
1515
+ }
1516
+ if (input.basedOnSchemaHash !== void 0) return {
1517
+ ok: false,
1518
+ kind: "version_changed"
1519
+ };
1520
+ const formId = deps.newId();
1521
+ const versionId = deps.newId();
1522
+ await deps.storage.publishVersion({
1523
+ form: {
1524
+ id: formId,
1525
+ path: input.path,
1526
+ currentVersionId: null,
1527
+ accessSubmit: "authenticated",
1528
+ allowedOrigins: null
1529
+ },
1530
+ version: {
1531
+ id: versionId,
1532
+ formId,
1533
+ version: 1,
1534
+ schema,
1535
+ schemaHash: hash
1536
+ },
1537
+ audit: publishAudit(deps, input, {
1538
+ path: input.path,
1539
+ version: 1,
1540
+ hash,
1541
+ sources: sourceNamesIn(schema)
1542
+ })
1543
+ });
1544
+ return {
1545
+ ok: true,
1546
+ formId,
1547
+ versionId,
1548
+ version: 1,
1549
+ schemaHash: hash,
1550
+ warnings
1551
+ };
1552
+ }
1553
+ /**
1554
+ * The audit row for a publish, built here so it can travel INSIDE the
1555
+ * transaction rather than being appended after it.
1556
+ *
1557
+ * Before this it was written by the route once all three storage calls had
1558
+ * returned, which meant a publish could half-apply and still be recorded as
1559
+ * having happened. Now it rolls back with everything else.
1560
+ */
1561
+ function publishAudit(deps, input, about) {
1562
+ return {
1563
+ id: deps.newId(),
1564
+ at: deps.nowIso(),
1565
+ action: "form.published",
1566
+ subject: about.path,
1567
+ ...input.actor === void 0 ? {} : {
1568
+ actorKind: input.actor.kind,
1569
+ actorId: input.actor.id
1570
+ },
1571
+ detail: {
1572
+ version: about.version,
1573
+ schemaHash: about.hash,
1574
+ ...about.sources.length === 0 ? {} : { optionsSources: [...about.sources].sort().join(", ") }
1575
+ }
1576
+ };
1577
+ }
1578
+ //#endregion
1546
1579
  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 };
1547
1580
 
1548
1581
  //# sourceMappingURL=index.mjs.map