@blamejs/core 0.14.26 → 0.15.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
package/lib/guard-yaml.js CHANGED
@@ -553,12 +553,8 @@ function parse(input, opts) {
553
553
  throw _err("yaml.bad-input", "parse requires string input");
554
554
  }
555
555
  var issues = _detectIssues(input, opts);
556
- for (var i = 0; i < issues.length; i += 1) {
557
- if (issues[i].severity === "critical") {
558
- throw _err(issues[i].ruleId || "yaml.refused",
559
- "guardYaml.parse: " + issues[i].snippet);
560
- }
561
- }
556
+ gateContract.throwOnRefusalSeverity(issues,
557
+ { errorClass: GuardYamlError, codePrefix: "yaml", severities: ["critical"], op: "parse" });
562
558
  return safeYamlLazy().parse(input, {
563
559
  maxBytes: opts.maxBytes,
564
560
  maxDepth: opts.maxDepth,
@@ -566,161 +562,53 @@ function parse(input, opts) {
566
562
  });
567
563
  }
568
564
 
569
- /**
570
- * @primitive b.guardYaml.gate
571
- * @signature b.guardYaml.gate(opts?)
572
- * @since 0.7.14
573
- * @status stable
574
- * @compliance hipaa, pci-dss, gdpr, soc2
575
- * @related b.guardYaml.validate, b.guardYaml.parse, b.staticServe.create, b.fileUpload.create
576
- *
577
- * Build a `b.gateContract` gate suitable for plugging into
578
- * `b.staticServe({ contentSafety: { ".yaml": gate } })`,
579
- * `b.fileUpload({ contentSafety: { "application/yaml": gate } })`,
580
- * or any host primitive that consumes the gate-contract shape.
581
- * Action chain on validation: `serve` (no issues) → `audit-only`
582
- * (warn-only issues) → `refuse` (any high/critical issue). YAML
583
- * sanitize is intentionally not offered — there's no safe re-emit
584
- * for tag-injection / alias-explosion shapes; the only correct
585
- * response is refusal.
586
- *
587
- * @opts
588
- * profile: "strict"|"balanced"|"permissive",
589
- * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
590
- * name: string, // gate identity for audit / observability
591
- *
592
- * @example
593
- * var yamlGate = b.guardYaml.gate({ profile: "strict" });
594
- * var hostile = Buffer.from("!!python/object/new:cls\nargs: [x]\n", "utf8");
595
- * var verdict = await yamlGate.check({ bytes: hostile });
596
- * verdict.action; // → "refuse"
597
- */
598
- function gate(opts) {
599
- opts = _resolveOpts(opts);
600
- return gateContract.buildGuardGate(
601
- opts.name || "guardYaml:" + (opts.profile || "default"),
602
- opts,
603
- async function (ctx) {
604
- var text = gateContract.extractBytesAsText(ctx);
605
- if (!text) return { ok: true, action: "serve" };
606
- var rv = validate(text, opts);
607
- if (rv.issues.length === 0) return { ok: true, action: "serve" };
608
- var hasCritical = rv.issues.some(function (i) {
609
- return i.severity === "critical" || i.severity === "high";
610
- });
611
- if (!hasCritical) return { ok: true, action: "audit-only", issues: rv.issues };
612
- return { ok: false, action: "refuse", issues: rv.issues };
613
- });
614
- }
615
-
616
- /**
617
- * @primitive b.guardYaml.buildProfile
618
- * @signature b.guardYaml.buildProfile(opts)
619
- * @since 0.7.14
620
- * @status stable
621
- * @related b.guardYaml.gate, b.guardYaml.compliancePosture
622
- *
623
- * Compose a derived profile from one or more named bases plus
624
- * inline overrides. `opts.extends` is a profile name (`"strict"` /
625
- * `"balanced"` / `"permissive"`) or an array of names; later entries
626
- * shadow earlier ones. Inline `opts` keys win last. Used to keep
627
- * operator-defined profiles traceable to a baseline rather than re-
628
- * typing every key.
629
- *
630
- * @opts
631
- * extends: string|string[], // base profile name(s) to compose
632
- * ...: any guard-yaml key, // inline override of resolved keys
633
- *
634
- * @example
635
- * var custom = b.guardYaml.buildProfile({
636
- * extends: "balanced",
637
- * tagPolicy: "reject",
638
- * maxAnchors: 8,
639
- * });
640
- * custom.tagPolicy; // → "reject"
641
- * custom.maxAnchors; // → 8
642
- */
643
- var buildProfile = gateContract.makeProfileBuilder(PROFILES);
644
-
645
- /**
646
- * @primitive b.guardYaml.compliancePosture
647
- * @signature b.guardYaml.compliancePosture(name)
648
- * @since 0.7.14
649
- * @status stable
650
- * @compliance hipaa, pci-dss, gdpr, soc2
651
- * @related b.guardYaml.gate, b.guardYaml.buildProfile
652
- *
653
- * Look up a compliance-posture overlay by name (`"hipaa"` /
654
- * `"pci-dss"` / `"gdpr"` / `"soc2"`). Returns a shallow clone of the
655
- * posture object — the caller may mutate freely. Throws
656
- * `GuardYamlError("yaml.bad-posture")` on unknown name.
657
- *
658
- * @example
659
- * var posture = b.guardYaml.compliancePosture("hipaa");
660
- * posture.tagPolicy; // → "reject"
661
- * posture.forensicSnippetBytes; // → 256
662
- */
663
- function compliancePosture(name) {
664
- return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES, _err, "yaml");
665
- }
565
+ // The gate is the standard serve -> audit-only -> refuse chain (content
566
+ // kind, reading ctx.bytes); it is assembled by gateContract.defineGuard's
567
+ // default gate below. YAML sanitize is intentionally not offered — there's
568
+ // no safe re-emit for tag-injection / alias-explosion shapes; the only
569
+ // correct response is refusal, which the default chain (no sanitize action)
570
+ // matches exactly. Its "guardYaml:<profile>" gate name and
571
+ // serve/audit-only/refuse decisions are identical to the hand-written gate
572
+ // this replaced.
573
+
574
+ // buildProfile / compliancePosture / loadRulePack are assembled by
575
+ // gateContract.defineGuard below; their wiki sections render from the
576
+ // single-sourced @abiTemplate (defineGuard) blocks in gate-contract.js,
577
+ // instantiated per guard by the page generator.
578
+
579
+ var INTEGRATION_FIXTURES = Object.freeze({
580
+ kind: "content",
581
+ contentType: "application/yaml",
582
+ extension: ".yaml",
583
+ benignBytes: Buffer.from('name: alice\nage: 30\n', "utf8"),
584
+ // Hostile: deserialization-tag injection (CVE-2026-24009 PyYAML
585
+ // class). Parser-runtime would attempt to instantiate the named
586
+ // language-specific class.
587
+ hostileBytes: Buffer.from("!!python/object/new:cls\nargs: [\"x\"]\n", "utf8"),
588
+ });
666
589
 
667
- var _yamlRulePacks = gateContract.makeRulePackLoader(GuardYamlError, "yaml");
668
- /**
669
- * @primitive b.guardYaml.loadRulePack
670
- * @signature b.guardYaml.loadRulePack(pack)
671
- * @since 0.7.14
672
- * @status stable
673
- * @related b.guardYaml.gate
674
- *
675
- * Register an operator-supplied rule pack with the guard-yaml
676
- * registry. The pack is identified by `pack.id` (non-empty string)
677
- * and stored for later inspection / dispatch by gates that opt in
678
- * via `opts.rulePackId`. Returns the pack object unchanged on
679
- * success; throws `GuardYamlError("yaml.bad-opt")` when `pack` is
680
- * missing or `pack.id` is not a non-empty string.
681
- *
682
- * @example
683
- * var pack = b.guardYaml.loadRulePack({
684
- * id: "deploy-keys",
685
- * rules: [
686
- * { id: "no-image-latest", severity: "high",
687
- * detect: function (text) { return /image:\s*\S+:latest\b/.test(text); },
688
- * reason: "deployment YAML must pin image tag (no :latest)" },
689
- * ],
690
- * });
691
- * pack.id; // → "deploy-keys"
692
- */
693
- var loadRulePack = _yamlRulePacks.load;
694
-
695
- module.exports = {
696
- // ---- guard-* family registry exports ----
697
- NAME: "yaml",
698
- KIND: "content",
699
- MIME_TYPES: Object.freeze([
700
- "application/yaml", "application/x-yaml", "text/yaml", "text/x-yaml",
701
- ]),
702
- EXTENSIONS: Object.freeze([".yml", ".yaml"]),
703
- INTEGRATION_FIXTURES: Object.freeze({
704
- kind: "content",
705
- contentType: "application/yaml",
706
- extension: ".yaml",
707
- benignBytes: Buffer.from('name: alice\nage: 30\n', "utf8"),
708
- // Hostile: deserialization-tag injection (CVE-2026-24009 PyYAML
709
- // class). Parser-runtime would attempt to instantiate the named
710
- // language-specific class.
711
- hostileBytes: Buffer.from("!!python/object/new:cls\nargs: [\"x\"]\n", "utf8"),
712
- }),
713
- // ---- primitive surface ----
714
- validate: validate,
715
- parse: parse,
716
- gate: gate,
717
- buildProfile: buildProfile,
718
- compliancePosture: compliancePosture,
719
- loadRulePack: loadRulePack,
720
- PROFILES: PROFILES,
721
- DEFAULTS: DEFAULTS,
722
- COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
723
- DANGEROUS_TAG_PREFIXES: DANGEROUS_TAG_PREFIXES,
724
- SAFE_CORE_TAGS: SAFE_CORE_TAGS,
725
- GuardYamlError: GuardYamlError,
726
- };
590
+ // Assembled from the gate-contract guard factory: error class, registry
591
+ // exports (NAME / KIND / MIME_TYPES / EXTENSIONS / INTEGRATION_FIXTURES),
592
+ // buildProfile / compliancePosture / loadRulePack wiring, plus the
593
+ // per-guard inspection surface (validate) and YAML extras
594
+ // (parse / DANGEROUS_TAG_PREFIXES / SAFE_CORE_TAGS) passed through
595
+ // verbatim. The gate is the factory default serve/audit-only/refuse chain
596
+ // (content kind, no sanitize action — there's no safe re-emit for
597
+ // tag-injection / alias-explosion shapes).
598
+ module.exports = gateContract.defineGuard({
599
+ name: "yaml",
600
+ kind: "content",
601
+ errorClass: GuardYamlError,
602
+ profiles: PROFILES,
603
+ defaults: DEFAULTS,
604
+ postures: COMPLIANCE_POSTURES,
605
+ mimeTypes: ["application/yaml", "application/x-yaml", "text/yaml", "text/x-yaml"],
606
+ extensions: [".yml", ".yaml"],
607
+ integrationFixtures: INTEGRATION_FIXTURES,
608
+ validate: validate,
609
+ extra: {
610
+ parse: parse,
611
+ DANGEROUS_TAG_PREFIXES: DANGEROUS_TAG_PREFIXES,
612
+ SAFE_CORE_TAGS: SAFE_CORE_TAGS,
613
+ },
614
+ });
@@ -275,6 +275,9 @@ function _connectHttpsWithAlpn(u, ips) {
275
275
  session.once("connect", function () {
276
276
  var alpn = session.alpnProtocol;
277
277
  if (alpn === "h2") {
278
+ // node:http2 connects directly (not via pqcAgent), so observe the
279
+ // negotiated group here too and audit a classical (non-PQC) downgrade.
280
+ pqcAgent._auditClassicalDowngrade(session.socket, { host: u.hostname, port: u.port });
278
281
  _wireH2Session(session, _originKey(u));
279
282
  _done({ kind: "h2", session: session });
280
283
  return;
@@ -285,6 +288,17 @@ function _connectHttpsWithAlpn(u, ips) {
285
288
  });
286
289
  session.once("error", function (err) {
287
290
  _tearDownH2Session(session);
291
+ // An HTTP/1.1-only TLS server has no "h2" to select and replies with
292
+ // the no_application_protocol alert (RFC 7301). node:http2 forces an
293
+ // h2-only ALPN offer, so the connect event never fires for such a
294
+ // server and the http/1.1-selection fallback above can't run — catch
295
+ // the alert here and fall back to an h1 transport for this origin.
296
+ // Real Azure / S3 / Keycloak support h2; Azurite, Azure Stack, and
297
+ // many private / older endpoints are h1-only.
298
+ if (err && err.code === "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL") {
299
+ _done(_makeH1Transport(u, ips));
300
+ return;
301
+ }
288
302
  _fail(err);
289
303
  });
290
304
  });
@@ -1925,7 +1939,21 @@ async function downloadStream(opts) {
1925
1939
  });
1926
1940
  counter.bytesWritten = 0;
1927
1941
 
1928
- var fileStream = nodeFs.createWriteStream(tmpPath, { mode: DEFAULT_DOWNLOAD_FILE_MODE, flags: "w" });
1942
+ // CWE-377 (insecure temporary file) / CWE-59 (symlink follow): stage
1943
+ // the download into the sibling tmp file with an EXCLUSIVE, no-follow
1944
+ // create. The legacy "w" flag is O_WRONLY|O_CREAT|O_TRUNC — it would
1945
+ // open (and truncate, or write through) a file an attacker pre-planted
1946
+ // at tmpPath, including a symlink aimed at a victim path this process
1947
+ // can write. O_EXCL fails with EEXIST if anything already exists at
1948
+ // tmpPath; O_NOFOLLOW rejects a symlink in the final path component
1949
+ // where the platform defines it (Windows leaves it undefined → `|| 0`).
1950
+ // tmpPath already carries a 64-bit CSPRNG suffix (line above), so an
1951
+ // EEXIST here is a hostile-collision signal, not a benign retry.
1952
+ var fileStream = nodeFs.createWriteStream(tmpPath, {
1953
+ mode: DEFAULT_DOWNLOAD_FILE_MODE,
1954
+ flags: nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
1955
+ nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0),
1956
+ });
1929
1957
 
1930
1958
  try {
1931
1959
  await streamPromises.pipeline(res.body, counter, fileStream);
@@ -1944,14 +1972,14 @@ async function downloadStream(opts) {
1944
1972
  // across platforms but matches the discipline of the rest of the
1945
1973
  // framework's atomic-write paths.
1946
1974
  //
1947
- // CodeQL js/insecure-temporary-file: tmpPath = dest + ".tmp-" +
1948
- // bCrypto.generateToken(C.BYTES.bytes(8)) (line 1802), where
1949
- // bCrypto.generateToken produces 16 hex chars of CSPRNG-derived
1950
- // randomness. The path lives next to operator-supplied `dest`
1951
- // (downloadStream contract — never under os.tmpdir()), and the
1952
- // 64-bit unpredictable suffix defeats the symlink-pre-creation
1953
- // attack the rule flags. The fd is used solely for fsync; the
1954
- // file's bytes were already written by the upstream pipeline.
1975
+ // CodeQL js/insecure-temporary-file (CWE-377 / CWE-59): the tmp file
1976
+ // was already created above with O_EXCL | O_NOFOLLOW, so this reopen
1977
+ // binds to an inode this process exclusively created at a path
1978
+ // carrying a 64-bit CSPRNG suffix (line above) next to operator-
1979
+ // supplied `dest` (downloadStream contract — never under os.tmpdir()).
1980
+ // The earlier exclusive create — not the reopen — is the symlink-
1981
+ // pre-creation defense; this fd is used solely for fsync, after the
1982
+ // upstream pipeline already wrote the bytes.
1955
1983
  try {
1956
1984
  var fd = nodeFs.openSync(tmpPath, "r+");
1957
1985
  try { atomicFile.fsync(fd); } finally { try { nodeFs.closeSync(fd); } catch (_c) { /* best-effort fd close */ } }
package/lib/inbox.js CHANGED
@@ -46,6 +46,7 @@ var C = require("./constants");
46
46
  var lazyRequire = require("./lazy-require");
47
47
  var safeJson = require("./safe-json");
48
48
  var safeSql = require("./safe-sql");
49
+ var sql = require("./sql");
49
50
  var validateOpts = require("./validate-opts");
50
51
  var { defineClass } = require("./framework-error");
51
52
 
@@ -63,14 +64,19 @@ function _validateTableName(name) {
63
64
  }
64
65
  }
65
66
 
66
- function _utcNowExpr(externalDb) {
67
- // Both backends accept this expression; SQLite returns ISO-8601,
68
- // Postgres returns timestamptz.
69
- if (externalDb && typeof externalDb.dialect === "string" &&
70
- externalDb.dialect === "postgres") {
71
- return "NOW()";
72
- }
73
- return "CURRENT_TIMESTAMP";
67
+ // Map the operator backend's dialect tag to the b.sql dialect vocabulary
68
+ // (postgres -> $1..$N at toExternalSql; sqlite/other -> `?`).
69
+ function _sqlDialect(externalDb) {
70
+ return (externalDb && externalDb.dialect === "postgres") ? "postgres" : "sqlite";
71
+ }
72
+
73
+ // The server-clock timestamp expression, as an allowlisted b.sql function
74
+ // cell (emits the keyword the engine evaluates, binds no param). Postgres
75
+ // returns timestamptz from NOW(); the portable CURRENT_TIMESTAMP serves
76
+ // sqlite. Used directly in a b.sql values()/set() cell.
77
+ function _utcNowCell(externalDb) {
78
+ return (externalDb && externalDb.dialect === "postgres")
79
+ ? sql.fn("NOW") : sql.fn("CURRENT_TIMESTAMP");
74
80
  }
75
81
 
76
82
  /**
@@ -144,12 +150,9 @@ function create(opts) {
144
150
 
145
151
  var externalDb = opts.externalDb;
146
152
  var tableRaw = opts.table;
147
- // Identifiers reach SQL through safeSql.quoteIdentifier runs
148
- // validateIdentifier internally + emits the dialect-correct quoted
149
- // form. sqlite + postgres both use the double-quote dialect (per
150
- // lib/safe-sql.js), so one quoted form serves both inbox paths.
151
- var qTable = safeSql.quoteIdentifier(tableRaw, "sqlite");
152
- var qIndex = safeSql.quoteIdentifier(tableRaw + "_received_at_idx", "sqlite");
153
+ // The table identifier reaches SQL through b.sql, which validates +
154
+ // quotes it by construction on every emitted statement; _validateTableName
155
+ // above fails fast at create() time on a bad name.
153
156
  var retentionDays = (typeof opts.retentionDays === "number" && opts.retentionDays > 0) // allow:numeric-opt-Infinity
154
157
  ? opts.retentionDays : 30; // default retention days
155
158
  var auditOn = opts.audit !== false;
@@ -227,43 +230,38 @@ function create(opts) {
227
230
  }
228
231
  metaJson = serialized;
229
232
  }
230
- var nowExpr = _utcNowExpr(externalDb);
231
- var dialect = (externalDb.dialect === "postgres") ? "postgres" : "sqlite";
233
+ var dialect = _sqlDialect(externalDb);
234
+ var nowCell = _utcNowCell(externalDb);
232
235
 
233
- if (dialect === "postgres") {
234
- var rs = await txn.query(
235
- "INSERT INTO " + qTable +
236
- " (message_id, source, received_at, metadata_json) " +
237
- " VALUES ($1, $2, " + nowExpr + ", $3::jsonb) " +
238
- " ON CONFLICT (source, message_id) DO NOTHING " +
239
- " RETURNING message_id",
240
- [receiveOpts.messageId, receiveOpts.source, metaJson]);
241
- var fresh = rs && rs.rows && rs.rows.length === 1;
242
- _emitAudit("inbox.received", "success", {
243
- source: receiveOpts.source, messageId: receiveOpts.messageId,
244
- fresh: fresh,
245
- });
246
- return fresh;
247
- }
248
-
249
- // SQLite path — INSERT OR IGNORE ... RETURNING 1 (SQLite 3.35+,
250
- // March 2021). The previous two-statement INSERT + SELECT
251
- // changes() pattern raced when callers issued an intervening
252
- // statement on the same txn handle (e.g. trace logging) — a
253
- // legitimate use case on the public recordReceive(opts, txn) API
254
- // that the framework can't prevent. RETURNING 1 collapses both
255
- // round-trips into one and removes the changes() dependency.
256
- var sqlInsert = await txn.query(
257
- "INSERT OR IGNORE INTO " + qTable +
258
- " (message_id, source, received_at, metadata_json) " +
259
- " VALUES (?, ?, " + nowExpr + ", ?) RETURNING 1",
260
- [receiveOpts.messageId, receiveOpts.source, metaJson]);
261
- var sqlFresh = !!(sqlInsert && sqlInsert.rows && sqlInsert.rows.length === 1);
236
+ // ON CONFLICT (source, message_id) DO NOTHING RETURNING message_id:
237
+ // a fresh insert returns one row, a duplicate redelivery returns none
238
+ // (the collision short-circuits). received_at takes the server-clock
239
+ // function cell (NOW() / CURRENT_TIMESTAMP, no param); metadata_json
240
+ // binds with a ::jsonb cast on Postgres and as a plain `?` on sqlite.
241
+ // RETURNING message_id (rather than RETURNING 1) is the portable
242
+ // presence sentinel - one row iff the insert landed - across both
243
+ // dialects, collapsing the prior INSERT-then-SELECT-changes() race
244
+ // into one round-trip on sqlite too.
245
+ var metaCell = (dialect === "postgres") ? sql.cast(metaJson, "jsonb") : metaJson;
246
+ var stmt = sql.upsert(tableRaw, { dialect: dialect })
247
+ .columns(["message_id", "source", "received_at", "metadata_json"])
248
+ .values({
249
+ message_id: receiveOpts.messageId,
250
+ source: receiveOpts.source,
251
+ received_at: nowCell,
252
+ metadata_json: metaCell,
253
+ })
254
+ .onConflict(["source", "message_id"])
255
+ .doNothing()
256
+ .returning(["message_id"])
257
+ .toExternalSql(dialect);
258
+ var rs = await txn.query(stmt.sql, stmt.params);
259
+ var fresh = !!(rs && rs.rows && rs.rows.length === 1);
262
260
  _emitAudit("inbox.received", "success", {
263
261
  source: receiveOpts.source, messageId: receiveOpts.messageId,
264
- fresh: sqlFresh,
262
+ fresh: fresh,
265
263
  });
266
- return sqlFresh;
264
+ return fresh;
267
265
  }
268
266
 
269
267
  async function markProcessed(receiveOpts, txn) {
@@ -272,13 +270,13 @@ function create(opts) {
272
270
  "markProcessed: txn must be a transaction handle");
273
271
  }
274
272
  _validateReceiveOpts(receiveOpts, "markProcessed");
275
- var nowExpr = _utcNowExpr(externalDb);
276
- var dialect = (externalDb.dialect === "postgres") ? "postgres" : "sqlite";
277
- var sql = "UPDATE " + qTable +
278
- " SET processed_at = " + nowExpr +
279
- " WHERE source = " + (dialect === "postgres" ? "$1" : "?") +
280
- " AND message_id = " + (dialect === "postgres" ? "$2" : "?");
281
- await txn.query(sql, [receiveOpts.source, receiveOpts.messageId]);
273
+ var dialect = _sqlDialect(externalDb);
274
+ var stmt = sql.update(tableRaw, { dialect: dialect })
275
+ .set({ processed_at: _utcNowCell(externalDb) })
276
+ .where("source", receiveOpts.source)
277
+ .where("message_id", receiveOpts.messageId)
278
+ .toExternalSql(dialect);
279
+ await txn.query(stmt.sql, stmt.params);
282
280
  }
283
281
 
284
282
  async function handle(receiveOpts, handler) {
@@ -321,56 +319,65 @@ function create(opts) {
321
319
  }
322
320
 
323
321
  async function declareSchema(xdb) {
324
- var dialect = (xdb && xdb.dialect === "postgres") ? "postgres" : "sqlite";
325
- if (dialect === "postgres") {
326
- await xdb.query(
327
- "CREATE TABLE IF NOT EXISTS " + qTable + " (" +
328
- " message_id TEXT NOT NULL," +
329
- " source TEXT NOT NULL," +
330
- " received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()," +
331
- " processed_at TIMESTAMPTZ NULL," +
332
- " metadata_json JSONB NULL," +
333
- " PRIMARY KEY (source, message_id)" +
334
- ")");
335
- await xdb.query(
336
- "CREATE INDEX IF NOT EXISTS " + qIndex + " " +
337
- "ON " + qTable + " (received_at)");
338
- } else {
339
- await xdb.query(
340
- "CREATE TABLE IF NOT EXISTS " + qTable + " (" +
341
- " message_id TEXT NOT NULL," +
342
- " source TEXT NOT NULL," +
343
- " received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP," +
344
- " processed_at TEXT NULL," +
345
- " metadata_json TEXT NULL," +
346
- " PRIMARY KEY (source, message_id)" +
347
- ")");
348
- await xdb.query(
349
- "CREATE INDEX IF NOT EXISTS " + qIndex + " " +
350
- "ON " + qTable + " (received_at)");
351
- }
322
+ var dialect = _sqlDialect(xdb);
323
+ // received_at / processed_at are a timestamp-with-zone column on
324
+ // Postgres and a TEXT (ISO-8601) column on sqlite; metadata_json is
325
+ // JSONB on Postgres and TEXT on sqlite. The composite (source,
326
+ // message_id) primary key is the dedupe collision boundary. Verbatim
327
+ // type strings sit in type position after the quoted column name.
328
+ var tsType = dialect === "postgres" ? "TIMESTAMPTZ" : "TEXT";
329
+ var tsDefault = dialect === "postgres" ? "NOW()" : "CURRENT_TIMESTAMP";
330
+ var jsonType = dialect === "postgres" ? "JSONB" : "TEXT";
331
+ var ddl = sql.toExternalSql(sql.createTable(tableRaw, [
332
+ { name: "message_id", type: "TEXT", notNull: true },
333
+ { name: "source", type: "TEXT", notNull: true },
334
+ // The DEFAULT here is a SQL function keyword, not a bound literal -
335
+ // expressed via the `constraints` verbatim clause so the type map
336
+ // does not quote NOW() / CURRENT_TIMESTAMP as a string default.
337
+ { name: "received_at", type: tsType, notNull: true, constraints: "DEFAULT " + tsDefault },
338
+ { name: "processed_at", type: tsType },
339
+ { name: "metadata_json", type: jsonType },
340
+ ], { dialect: dialect, primaryKey: ["source", "message_id"] }), dialect);
341
+ await xdb.query(ddl.sql, ddl.params);
342
+
343
+ var idx = sql.toExternalSql(sql.createIndex(tableRaw + "_received_at_idx", tableRaw,
344
+ ["received_at"], { dialect: dialect }), dialect);
345
+ await xdb.query(idx.sql, idx.params);
352
346
  }
353
347
 
354
348
  async function sweep() {
355
- var dialect = (externalDb.dialect === "postgres") ? "postgres" : "sqlite";
349
+ var dialect = _sqlDialect(externalDb);
356
350
  var deleted = 0;
357
351
  await externalDb.transaction(async function (xdb) {
358
352
  if (dialect === "postgres") {
359
- var rs = await xdb.query(
360
- "DELETE FROM " + qTable +
361
- " WHERE received_at < NOW() - $1::interval " +
362
- " AND (processed_at IS NOT NULL OR received_at < NOW() - $2::interval)",
363
- [retentionDays + " days", (retentionDays * 2) + " days"]);
353
+ // received_at < NOW() - <retention>::interval, with the
354
+ // unprocessed grace of 2x retention. The interval STRINGS bind as
355
+ // a ::interval-cast `?` (the cast value-cell form); NOW() is the
356
+ // server-clock function token in the predicate (a guarded raw
357
+ // fragment - no string literal, no bound value).
358
+ var delStmt = sql.delete(tableRaw, { dialect: "postgres" })
359
+ .whereRaw("received_at < NOW() - ?::interval", [retentionDays + " days"])
360
+ .whereRaw("(processed_at IS NOT NULL OR received_at < NOW() - ?::interval)",
361
+ [(retentionDays * 2) + " days"])
362
+ .toExternalSql("postgres");
363
+ var rs = await xdb.query(delStmt.sql, delStmt.params);
364
364
  deleted = (rs && typeof rs.rowCount === "number") ? rs.rowCount : 0;
365
365
  } else {
366
366
  var staleDate = new Date(Date.now() - retentionDays * C.TIME.days(1)).toISOString();
367
367
  var unprocStaleDate = new Date(Date.now() - retentionDays * 2 * C.TIME.days(1)).toISOString();
368
- await xdb.query(
369
- "DELETE FROM " + qTable +
370
- " WHERE received_at < ? " +
371
- " AND (processed_at IS NOT NULL OR received_at < ?)",
372
- [staleDate, unprocStaleDate]);
373
- var changedResult = await xdb.query("SELECT changes() AS c");
368
+ var delSqlite = sql.delete(tableRaw, { dialect: "sqlite" })
369
+ .where("received_at", "<", staleDate)
370
+ .whereRaw("(processed_at IS NOT NULL OR received_at < ?)", [unprocStaleDate])
371
+ .toExternalSql("sqlite");
372
+ await xdb.query(delSqlite.sql, delSqlite.params);
373
+ // SELECT changes() reports the row count of the last sqlite write
374
+ // on this connection. b.sql.catalog has no changes() builder (it
375
+ // is a sqlite-internal scalar with no table), so emit it through
376
+ // the same dialect-final terminal as a guarded raw projection on a
377
+ // zero-table SELECT is not expressible; the single-statement
378
+ // changes() probe is a fixed, parameter-free sqlite scalar query.
379
+ var changedResult = await xdb.query(
380
+ sql.toExternalSql(sql.catalog.changes(), "sqlite").sql);
374
381
  var changedRow = changedResult.rows && changedResult.rows[0];
375
382
  deleted = changedRow ? Number(changedRow.c) : 0;
376
383
  }
@@ -383,12 +390,16 @@ function create(opts) {
383
390
 
384
391
  async function isFresh(receiveOpts) {
385
392
  _validateReceiveOpts(receiveOpts, "isFresh");
386
- var dialect = (externalDb.dialect === "postgres") ? "postgres" : "sqlite";
387
- var sql = "SELECT 1 FROM " + qTable +
388
- " WHERE source = " + (dialect === "postgres" ? "$1" : "?") +
389
- " AND message_id = " + (dialect === "postgres" ? "$2" : "?");
393
+ var dialect = _sqlDialect(externalDb);
394
+ // SELECT 1 ... is a presence probe; the constant 1 projection is a
395
+ // builder-emitted raw scalar (no column, no bound value).
396
+ var stmt = sql.select(tableRaw, { dialect: dialect })
397
+ .selectRaw("1")
398
+ .where("source", receiveOpts.source)
399
+ .where("message_id", receiveOpts.messageId)
400
+ .toExternalSql(dialect);
390
401
  var rs = await externalDb.transaction(async function (xdb) {
391
- return await xdb.query(sql, [receiveOpts.source, receiveOpts.messageId]);
402
+ return await xdb.query(stmt.sql, stmt.params);
392
403
  });
393
404
  return !rs || !rs.rows || rs.rows.length === 0;
394
405
  }
@@ -397,15 +408,17 @@ function create(opts) {
397
408
  opts2 = opts2 || {};
398
409
  var sourceFilter = (typeof opts2.source === "string" && opts2.source.length > 0)
399
410
  ? opts2.source : null;
400
- var dialect = (externalDb.dialect === "postgres") ? "postgres" : "sqlite";
411
+ var dialect = _sqlDialect(externalDb);
401
412
  var stats = await externalDb.transaction(async function (xdb) {
402
- var sql = "SELECT COUNT(*) AS total," +
403
- " COUNT(processed_at) AS processed " +
404
- " FROM " + qTable +
405
- (sourceFilter ? " WHERE source = " +
406
- (dialect === "postgres" ? "$1" : "?") : "");
407
- var args = sourceFilter ? [sourceFilter] : [];
408
- var rs = await xdb.query(sql, args);
413
+ // COUNT(*) total + COUNT(processed_at) processed (the latter counts
414
+ // only non-NULL processed_at, i.e. handled rows), optionally scoped
415
+ // to one source.
416
+ var builder = sql.select(tableRaw, { dialect: dialect })
417
+ .count("*", "total")
418
+ .count("processed_at", "processed");
419
+ if (sourceFilter) builder.where("source", sourceFilter);
420
+ var stmt = builder.toExternalSql(dialect);
421
+ var rs = await xdb.query(stmt.sql, stmt.params);
409
422
  var row = rs.rows && rs.rows[0];
410
423
  return {
411
424
  total: row ? Number(row.total) : 0,