@blamejs/blamejs-shop 0.3.56 → 0.3.58

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 (84) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +11 -0
  4. package/lib/asset-manifest.json +3 -3
  5. package/lib/checkout.js +89 -10
  6. package/lib/security-middleware.js +17 -1
  7. package/lib/storefront.js +156 -51
  8. package/lib/vendor/MANIFEST.json +84 -72
  9. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  10. package/lib/vendor/blamejs/README.md +3 -3
  11. package/lib/vendor/blamejs/SECURITY.md +5 -0
  12. package/lib/vendor/blamejs/api-snapshot.json +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
  14. package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
  15. package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
  16. package/lib/vendor/blamejs/lib/archive-read.js +215 -16
  17. package/lib/vendor/blamejs/lib/archive.js +206 -52
  18. package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
  19. package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
  20. package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
  21. package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
  22. package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
  23. package/lib/vendor/blamejs/lib/db-schema.js +120 -3
  24. package/lib/vendor/blamejs/lib/db.js +10 -3
  25. package/lib/vendor/blamejs/lib/error-page.js +93 -9
  26. package/lib/vendor/blamejs/lib/external-db.js +164 -13
  27. package/lib/vendor/blamejs/lib/guard-email.js +36 -3
  28. package/lib/vendor/blamejs/lib/http-client.js +37 -7
  29. package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
  30. package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
  31. package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
  32. package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
  33. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
  34. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
  35. package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
  36. package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
  37. package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
  38. package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
  39. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
  40. package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
  41. package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
  42. package/lib/vendor/blamejs/lib/openapi.js +225 -100
  43. package/lib/vendor/blamejs/lib/problem-details.js +15 -3
  44. package/lib/vendor/blamejs/lib/queue-local.js +148 -38
  45. package/lib/vendor/blamejs/lib/queue.js +41 -11
  46. package/lib/vendor/blamejs/lib/render.js +21 -3
  47. package/lib/vendor/blamejs/lib/router.js +13 -6
  48. package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
  49. package/lib/vendor/blamejs/lib/sse.js +7 -5
  50. package/lib/vendor/blamejs/lib/static.js +46 -17
  51. package/lib/vendor/blamejs/lib/uri-template.js +3 -1
  52. package/lib/vendor/blamejs/package.json +1 -1
  53. package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
  54. package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
  55. package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
  56. package/lib/vendor/blamejs/test/00-primitives.js +151 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
  59. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
  65. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
  66. package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
  67. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
  68. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
  69. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
  70. package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
  71. package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
  72. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
  73. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
  74. package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
  75. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
  76. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
  77. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
  78. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
  79. package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
  80. package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
  81. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
  82. package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
  83. package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
  84. package/package.json +1 -1
@@ -2571,6 +2571,25 @@ async function testNoDuplicateCodeBlocks() {
2571
2571
  ],
2572
2572
  reason: "v0.14.12 — generic validate/derive/byte-walk control-flow shingle the vault-rotation reseal work tipped over the 3-file threshold. Members are unrelated primitives (agent-idempotency arg-check, agent-tenant per-tenant AEAD seal, archive-wrap explicit-root tenant-key derive, atomic-file recursive dir-copy, ddl-change-control dual-control approve/reject, deprecate alias, guard-filename path-segment safety walk, jose-jwe decrypt, mail-deploy TLS-RPT validate, totp otpauth URI build). No shared behaviour to extract; consolidating would couple ten unrelated subsystems.",
2573
2573
  },
2574
+ {
2575
+ mode: "family-subset",
2576
+ files: [
2577
+ "lib/mail-auth.js:_xmlEscapeText",
2578
+ "lib/mail-deploy.js:_xmlEscape",
2579
+ "lib/object-store/azure-blob-bucket-ops.js:_xmlEscape",
2580
+ ],
2581
+ reason: "v0.14.18 — coincidental shingle of the five-metacharacter XML text-node escaper (String(s).replace chain for & < > \" '). The three live in unrelated domains: mail-auth._xmlEscapeText neutralizes RFC 7489 DMARC aggregate-report text nodes (attacker-influenced envelope-from / source-IP bytes), mail-deploy._xmlEscape escapes Autoconfig / Autodiscover provider-XML fields, object-store/azure-blob-bucket-ops._xmlEscape escapes Azure Blob service-properties / CORS-rule XML. The codebase intentionally keeps XML/HTML escaping inline per context — the four-metacharacter HTML variants (mail._htmlEscape, compliance-ai-act-transparency._escapeHtml) and the RFC 3741 c14n numeric-reference escapers (xml-c14n._escapeText / _escapeAttrValue, which emit &#xD; / &#xA; / &#x9; and differ by attr-vs-text position) are deliberately distinct operations, so there is no single shared escaper these three could compose without coupling unrelated serializers on the trivial replace chain.",
2582
+ },
2583
+ {
2584
+ mode: "family-subset",
2585
+ files: [
2586
+ "lib/auth/oid4vci.js:_verifyProofJwt",
2587
+ "lib/auth/openid-federation.js:verifyEntityStatement",
2588
+ "lib/auth/saml.js:_decryptEncryptedAssertion",
2589
+ "lib/auth/saml.js:verifyResponse",
2590
+ ],
2591
+ reason: "v0.14.18 — coincidental shingle of the import-public-key-then-wrap-failure idiom (try { keyObj = nodeCrypto.createPublicKey({ key, format }); } catch (e) { throw new AuthError(code, msg + ((e && e.message) || String(e))); }). The alg/key-type cross-check (CVE-2026-22817) is ALREADY routed through the shared jwtExternal._assertAlgKtyMatch helper; what repeats here is only the per-primitive createPublicKey + typed-catch shell. oid4vci._verifyProofJwt imports an OID4VCI proof-JWT holder key and throws auth-oid4vci/*; openid-federation.verifyEntityStatement imports an entity-statement JWS key and throws auth-openid-federation/*; saml._decryptEncryptedAssertion / verifyResponse import SAML XML-DSig / EncryptedAssertion keys (a different signature mechanism entirely) and throw auth-saml/*. Each carries a primitive-local error code namespace operators grep for; consolidating would couple three unrelated credential formats on the createPublicKey boilerplate.",
2592
+ },
2574
2593
  {
2575
2594
  files: ["lib/api-key.js:issue", "lib/db-query.js:<top>", "lib/session.js:create"],
2576
2595
  reason: "Generic JS array helper / lambda shape — Object.keys(...).map(fn) + similar functional idioms appearing in any code that walks a column-or-key list.",
@@ -4080,6 +4099,7 @@ async function testNoDuplicateCodeBlocks() {
4080
4099
  "lib/openapi-paths-builder.js:_normaliseRequestBody",
4081
4100
  "lib/openapi-paths-builder.js:_normaliseResponses",
4082
4101
  "lib/openapi.js:_validateServerEntry",
4102
+ "lib/openapi.js:_validateItemOperations",
4083
4103
  "lib/openapi.js:parse",
4084
4104
  "lib/asyncapi.js:_addChannel",
4085
4105
  "lib/asyncapi.js:_normaliseMessage",
@@ -4106,7 +4126,7 @@ async function testNoDuplicateCodeBlocks() {
4106
4126
  "lib/db-file-lifecycle.js:fileLifecycle",
4107
4127
  "lib/middleware/protected-resource-metadata.js:create",
4108
4128
  ],
4109
- reason: "validateOpts.requireNonEmptyString-prelude scaffold — primitives gate operator-supplied opts with the same `validateOpts.requireNonEmptyString(opts.X, ..., ErrorClass, code)` cascade. Each domain's error class differs (DeprecateError / OpenApiError / AsyncApiError / MailError / InboxError / A2aError / BudrError / AuthError / DbFileLifecycleError); consolidating would lose the per-module error code.",
4129
+ reason: "validateOpts.requireNonEmptyString-prelude scaffold — primitives gate operator-supplied opts with the same `validateOpts.requireNonEmptyString(opts.X, ..., ErrorClass, code)` cascade. Each domain's error class differs (DeprecateError / OpenApiError / AsyncApiError / MailError / InboxError / A2aError / BudrError / AuthError / DbFileLifecycleError); consolidating would lose the per-module error code. The same family carries the spec-doc walk shingle (`for (var k in obj) { hasOwnProperty guard; var entry = obj[k]; if (!entry || typeof entry !== \"object\") ... }`) shared by the OpenAPI 3.1/3.2 external-doc validators (openapi.parse / _validateItemOperations, which accumulate spec-distinct error strings) and the AsyncAPI 3.0 parser (asyncapi.parse) alongside the OpenAPI builder normalizers (_normaliseRequestBody / _normaliseResponses, which throw typed errors and construct a normalized media-type map). Validator-accumulate vs builder-throw-and-construct are different output contracts across two specs; extracting one walk helper would couple the OpenAPI and AsyncAPI namespaces and lose the per-spec refusal vocabulary.",
4110
4130
  },
4111
4131
  {
4112
4132
  mode: "family-subset",
@@ -5679,6 +5699,21 @@ async function testNoDuplicateCodeBlocks() {
5679
5699
  ],
5680
5700
  reason: "v0.11.25 — defensive object-shape read pattern: each primitive accepts a raw object (siteverify JSON response / agent-orchestrator opts / WebSocket close-frame payload) and normalises it into a typed internal shape via per-field `typeof === 'string' ? raw.X : null` guards. Bot-challenge bodies normalise Cloudflare/hCaptcha success+hostname+action+challenge_ts+error-codes; agent-orchestrator bodies normalise opts.topology+opts.leader+opts.health; ws-client bodies normalise the close-frame {code, reason}. The shared shape is the per-field typeof-then-null defensive read; the bodies enforce entirely different spec contracts (Cloudflare siteverify JSON vs b.agent.orchestrator topology vs RFC 6455 close-frame).",
5681
5701
  },
5702
+ {
5703
+ mode: "family-subset",
5704
+ files: [
5705
+ "lib/breach-deadline.js:trackReport",
5706
+ "lib/auth/oid4vp.js:_validateDcql",
5707
+ "lib/cms-codec.js:encodeEnvelopedData",
5708
+ "lib/cms-codec.js:encodeSignedData",
5709
+ "lib/mail-crypto-pgp.js:experimentalEncrypt",
5710
+ "lib/auth/dpop.js:thumbprint",
5711
+ "lib/incident-report.js:track",
5712
+ "lib/incident-report.js:open",
5713
+ "lib/guard-snapshot-envelope.js:validate",
5714
+ ],
5715
+ reason: "breach-deadline.createClock composes incident-report.createDeadlineClock; the matched 50-token shingle is the coincidental validate/register/threshold idiom shared with unrelated domains (cms-codec/oid4vp/dpop/guard-snapshot-envelope), not extractable duplication. trackReport validates a breach.report record then registers each per-state deadline onto the inner clock it does NOT own; oid4vp._validateDcql validates a DCQL query; cms-codec.encodeEnvelopedData/encodeSignedData build CMS BER/DER structures; mail-crypto-pgp.experimentalEncrypt assembles an OpenPGP message; dpop.thumbprint derives a JWK thumbprint; incident-report.track/open register synthetic incidents; guard-snapshot-envelope.validate gates a snapshot envelope. Each enforces a distinct spec (US state breach statutes / OID4VP DCQL / RFC 5652 CMS / RFC 9580 OpenPGP / RFC 9449 DPoP / breach-notification regimes / snapshot envelope contract); consolidating would couple unrelated wire grammars. breach-deadline already delegates the tick loop + timer lifecycle to incident-report, so there is no timer to extract.",
5716
+ },
5682
5717
  ];
5683
5718
  // Each KNOWN_CLUSTERS entry's `files` is a list of `path:fn` strings.
5684
5719
  // Build per-entry matchers and reject malformed entries (bare path
@@ -6552,6 +6587,20 @@ var KNOWN_ANTIPATTERNS = [
6552
6587
 
6553
6588
  { id: "connect-entry-point-port-must-compose-optionalPort", primitive: "a connection entry point reading opts.port / opts.kePort / opts.ntpPort with a `|| <default>` fallback must first validate it via validateOpts.optionalPort (or, where a permanent typed error is needed, the equivalent numericBounds.isPositiveFiniteInt(opts.port) + 65535 cap) — an unvalidated opts.port || N silently accepts a string / negative / NaN / out-of-range port", scanScope: "lib", regex: /\bopts\.(?:port|kePort|ntpPort)\s*\|\|/, requires: /validateOpts\.optionalPort\(|isPositiveFiniteInt\(opts\.port\)/, allowlist: [], reason: "v0.14.15 — the connection entry points (mail.smtpTransport, ntpCheck.querySingle, dns.useDnsOverTls, nts.performKeHandshake / querySingle / query, redis.create) read opts.port || <default>, silently coercing a string / negative / NaN / >65535 port; rule §5 says config-time entry points THROW so the operator catches the typo at boot. Each now composes validateOpts.optionalPort (RFC 6335 §6 [1,65535]; allowZero for the app.listen ephemeral bind) — or, where a MailError-permanent typed error is needed, the same numericBounds.isPositiveFiniteInt + 65535 rule inline. The requires-companion clears a file once it validates; a new entry point reading opts.port || N without composing the validator trips the gate." },
6554
6589
 
6590
+ { id: "deny-response-guards-headers-sent", primitive: "the deny-path writer in lib/middleware/deny-response.js must guard the default res.writeHead(ctx.status, ...) on res.headersSent (not res.writableEnded alone) — a wrapping consumer that already sent headers without flipping writableEnded would otherwise re-enter writeHead and throw \"headers already sent\", turning a refusal into a 500", scanScope: "lib", regex: /res\.writeHead\s*\(\s*ctx\.status\b/, requires: /res\.headersSent/, allowlist: [], reason: "denyResponse's pre-writeHead terminal guard once checked only res.writableEnded; a consumer that committed headers via res.setHeader/writeHead without setting writableEnded slipped past it and re-entered the default writeHead(ctx.status), throwing on already-sent headers. The guard now reads `res.writableEnded || res.headersSent || !_isFn(res.writeHead)`. File-scoped via the writeHead(ctx.status anchor unique to deny-response.js; the requires-companion fails if a future edit drops the headersSent term. Empty allowlist — losing the headersSent guard is a real defect." },
6591
+
6592
+ { id: "mw-uses-real-appshutdown-addphase", primitive: "the app-shutdown handle exposes addPhase(phase) — a .registerPhase( call is a silently-dead phase registration against a method that does not exist, so the phase never runs at shutdown", scanScope: "lib", regex: /\.registerPhase\s*\(/, allowlist: [], reason: "b.appShutdown.create returns a handle whose phase-registration method is addPhase, not registerPhase. A caller that wrote handle.registerPhase(...) would get a no-op (or a TypeError at call time) and its teardown phase would never fire — the kind of silent miswiring where a documented shutdown step simply never runs. No lib site calls registerPhase today; this detector keeps the wrong method name from entering lib/. Empty allowlist — a registerPhase call is always the miswire." },
6593
+
6594
+ { id: "body-parser-write-error-connection-close", primitive: "lib/middleware/body-parser.js's _writeError(res, status, message, code) response must set Connection: close — a body-parse rejection abandons the request stream mid-body, so the 4xx must force a socket teardown (RFC 9112 §9.6) to close the request-smuggling reuse window", scanScope: "lib", skipCommentLines: true, regex: /function _writeError\s*\(\s*res\s*,\s*status\s*,\s*message\s*,\s*code\s*\)[\s\S]{0,500}?res\.writeHead\s*\((?:(?!res\.end\b)(?!Connection)[\s\S]){0,500}?res\.end\b/, allowlist: [], reason: "_writeError is the generic body-parse rejection writer (malformed JSON, poisoned key, oversize payload); pairing the 4xx with Connection: close stops an upstream proxy reusing a socket whose request stream the parser abandoned mid-body (request-smuggling desync, RFC 9112 §9.6). Block-scoped to the _writeError(res, status, message, code) signature unique to body-parser.js (tus-upload / static / webhook _writeError carry different signatures): the regex matches the function's writeHead(...)…res.end window ONLY when that window carries no Connection token, so removing Connection: close from THIS writer fires even though the inline smuggling/chunked writers keep theirs. Empty allowlist — dropping Connection: close here re-opens the desync window." },
6595
+
6596
+ { id: "queue-local-no-raw-jobs-table", primitive: "every jobs-table SQL reference must flow through the configured, safeSql-quoted qTable variable (resolved by _resolveTableRef via safeSql.quoteIdentifier / quoteQualified) — a raw `FROM _blamejs_jobs` / `INTO _blamejs_jobs` / `UPDATE _blamejs_jobs` literal hardcodes the default name and bypasses BYO-table + dialect quoting", scanScope: "lib", skipCommentLines: true, regex: /\b(?:FROM|INTO|UPDATE|DELETE FROM|JOIN)\s+_blamejs_jobs\b/, allowlist: [], reason: "queue-local now supports an operator-supplied config.table (validated + dialect-quoted by _resolveTableRef, held in qTable); every enqueue/lease/dlq statement interpolates qTable. A statement that hardcodes the raw `_blamejs_jobs` literal after a SQL keyword would ignore the BYO table and skip identifier quoting. The DEFAULT_TABLE / SEAL_TABLE constant declarations (`var X = \"_blamejs_jobs\"`) carry no preceding SQL keyword so they don't match, and skipCommentLines blanks the doc references — no per-file allowlist needed; any match is a real raw reference." },
6597
+
6598
+ { id: "breach-clock-composes-incident-clock", primitive: "b.breach.deadline.createClock must compose b.incident.report.createDeadlineClock (one underlying timer) — it must NOT re-roll its own setInterval tick loop; the breach clock delegates the timer lifecycle to incident-report so there is a single tick source to drive + stop", scanScope: "lib", skipCommentLines: true, regex: /setInterval[\s\S]{0,8000}?breach\.deadline\.createClock|breach\.deadline\.createClock[\s\S]{0,8000}?setInterval/, allowlist: [], reason: "breach-deadline.createDeadlineClock wraps incidentReport().createDeadlineClock and forwards trackReport / acknowledgeSubmission / cancel / stop onto that inner clock; it owns no timer of its own. A setInterval inside breach-deadline.js means the tick loop was re-rolled instead of delegated — two independent timers drifting, and a stop() that no longer tears the real one down. File-scoped via co-occurrence of setInterval with the breach.deadline.createClock token unique to breach-deadline.js (incident-report.js owns the legitimate setInterval but never names breach.deadline.createClock, so it is not flagged). Empty allowlist — a timer here is the miswire." },
6599
+
6600
+ { id: "bounded-chunk-collector-not-a-stream-consumer", primitive: "b.safeBuffer.boundedChunkCollector(opts) takes a SINGLE options object and returns a { push, result, bytesCollected } collector — it is not a stream consumer. To read a Readable (request body, upstream response) use b.safeBuffer.collectStream(stream, opts), which pumps the stream into a bounded collector and resolves a Buffer. A boundedChunkCollector(req, ...) call passes the stream as opts (maxBytes undefined → buffer/bad-arg throw) and then awaits / .then()s a non-Promise collector.", scanScope: "lib", skipCommentLines: true, regex: /boundedChunkCollector\s*\(\s*\w+\s*,/, allowlist: [], reason: "csp-report (413 on EVERY POST) and scim-server (every streamed body broke) both called safeBuffer.boundedChunkCollector(req, { maxBytes }) / (req, MAX, ErrClass, code): the request stream was passed as the opts argument (maxBytes undefined → synchronous buffer/bad-arg, surfaced as 413/500) and the returned push-collector was treated as a thenable. boundedChunkCollector has no (stream, opts) overload; b.safeBuffer.collectStream is the stream-reading sibling. The regex flags a call whose first argument is a bare identifier immediately followed by a comma (the multi-arg / stream-first misuse); single-object boundedChunkCollector({ ... }) and single-var boundedChunkCollector(opts) / boundedChunkCollector(opts || {}) calls do not match. Empty allowlist — there is no valid multi-arg form. This is the detector that would have caught both endpoints before smoke." },
6601
+
6602
+ { id: "regex-polynomial-whitespace-in-repeated-group", primitive: "a regex literal must not place an optional-whitespace `\\s*` / `\\s+` at the END of a repeated group (the `(?:…\\s*)*` / `…\\s*)+` shape) — the same whitespace can be consumed either inside the group or by surrounding whitespace, so a crafted input backtracks polynomially (CWE-1333 ReDoS). Consume whitespace as a single disjoint alternative `(?:\\s|…)*` instead, and match block comments with the star-not-slash form, never a lazy `[\\s\\S]*?`.", scanScope: "lib", skipCommentLines: true, regex: /\\s[*+]\)[*+]/, allowlist: [], reason: "CodeQL js/polynomial-redos (alert 330) flagged lib/external-db.js's leading-keyword classifier `/^\\s*(?:\\/\\*…\\s*|--…\\s*)*([A-Za-z]+)/` — the `\\s*` both before AND at the tail of the repeated group gives two ways to consume the same whitespace run, so a SQL string of nested `/**/` or `*/--` comment runs backtracks polynomially; reused by the new OTel db.operation path it became a taint sink. Rewritten to `/^(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|--[^\\n]*\\n)*([A-Za-z]+)/` (disjoint single-char alternatives). codebase-patterns is a curated detector set, not a taint/ReDoS analyzer like CodeQL — this closes the specific shape locally so the next agent-authored comment-skip regex trips the gate before CI. Empty allowlist — a `\\s*)*` / `\\s+)+` tail in a lib regex is the ReDoS tell; allowlist a genuinely-anchored case with its structural reason." },
6603
+
6555
6604
  {
6556
6605
  // ROTATION-EPOCH ACCEPT (v0.14.x): a vault-key rotation (b.vault.rotate)
6557
6606
  // re-keys the local dataDir, which changes the SHA3-512 fingerprint of
@@ -389,6 +389,69 @@ function run() {
389
389
  check("middleware: custom headerPrefix on Article", res5._headers["X-AI-Article"] === "Art. 50(1)");
390
390
  check("middleware: custom headerPrefix replaces default", res5._headers["AI-Act-Notice"] == null);
391
391
 
392
+ // html mode — the banner injects whether res.end() is handed a STRING or
393
+ // a Buffer. b.render serves a Buffer (res.end(Buffer.from(html))); a
394
+ // string-only injection path silently dropped the disclosure on that
395
+ // common server-render route.
396
+ function _captureEnd(res) {
397
+ var captured = { chunk: null };
398
+ var realEnd = res.end;
399
+ res.end = function (chunk) { captured.chunk = chunk; return realEnd.call(res, chunk); };
400
+ return captured;
401
+ }
402
+
403
+ var mwHtml = b.middleware.aiActDisclosure({
404
+ kind: "ai-interaction",
405
+ deployerName: "myco",
406
+ mode: "html",
407
+ audit: false,
408
+ });
409
+
410
+ // String body — injects after <body>.
411
+ var resStr = _mockRes();
412
+ var capStr = _captureEnd(resStr);
413
+ mwHtml({ headers: {}, url: "/html-str" }, resStr, function () {});
414
+ resStr.setHeader("Content-Type", "text/html; charset=utf-8");
415
+ resStr.writeHead(200, {});
416
+ resStr.end("<html><body><h1>hi</h1></body></html>");
417
+ check("middleware html: string body injects banner", typeof capStr.chunk === "string" &&
418
+ capStr.chunk.indexOf("data-blamejs-aiAct") !== -1 &&
419
+ capStr.chunk.indexOf("<body>") !== -1);
420
+
421
+ // Buffer body, utf-8 — decodes, injects, re-encodes to a Buffer.
422
+ var resBuf = _mockRes();
423
+ var capBuf = _captureEnd(resBuf);
424
+ mwHtml({ headers: {}, url: "/html-buf" }, resBuf, function () {});
425
+ resBuf.setHeader("Content-Type", "text/html; charset=utf-8");
426
+ resBuf.writeHead(200, {});
427
+ resBuf.end(Buffer.from("<html><body><h1>hi</h1></body></html>", "utf8"));
428
+ check("middleware html: Buffer body stays a Buffer", Buffer.isBuffer(capBuf.chunk));
429
+ check("middleware html: Buffer body injects banner", Buffer.isBuffer(capBuf.chunk) &&
430
+ capBuf.chunk.toString("utf8").indexOf("data-blamejs-aiAct") !== -1);
431
+
432
+ // Buffer body, no explicit charset — defaults to utf-8, still injects.
433
+ var resBufNoCs = _mockRes();
434
+ var capBufNoCs = _captureEnd(resBufNoCs);
435
+ mwHtml({ headers: {}, url: "/html-buf-nocs" }, resBufNoCs, function () {});
436
+ resBufNoCs.setHeader("Content-Type", "text/html");
437
+ resBufNoCs.writeHead(200, {});
438
+ resBufNoCs.end(Buffer.from("<html><body>x</body></html>", "utf8"));
439
+ check("middleware html: Buffer body default-utf8 injects", Buffer.isBuffer(capBufNoCs.chunk) &&
440
+ capBufNoCs.chunk.toString("utf8").indexOf("data-blamejs-aiAct") !== -1);
441
+
442
+ // Buffer body in an unsupported charset — served untouched (no corruption),
443
+ // and the disclosure header still carries the machine-readable notice.
444
+ var resUtf16 = _mockRes();
445
+ var capUtf16 = _captureEnd(resUtf16);
446
+ mwHtml({ headers: {}, url: "/html-utf16" }, resUtf16, function () {});
447
+ resUtf16.setHeader("Content-Type", "text/html; charset=utf-16le");
448
+ resUtf16.writeHead(200, {});
449
+ var utf16Body = Buffer.from("<html><body>x</body></html>", "utf16le");
450
+ resUtf16.end(utf16Body);
451
+ check("middleware html: unsupported charset served untouched", Buffer.isBuffer(capUtf16.chunk) &&
452
+ capUtf16.chunk.equals(utf16Body));
453
+ check("middleware html: unsupported charset still sets header", resUtf16._headers["AI-Act-Notice"] === "ai-interaction");
454
+
392
455
  // ---- expanded prohibited classifier ----
393
456
  var hits7 = aiAct.prohibited.classify({
394
457
  purpose: "predictive-policing", usesProfileOnly: true,
@@ -5,8 +5,19 @@
5
5
  */
6
6
 
7
7
  var helpers = require("../helpers");
8
- var b = helpers.b;
9
- var check = helpers.check;
8
+ var b = helpers.b;
9
+ var check = helpers.check;
10
+ var _bodyReq = helpers._bodyReq;
11
+
12
+ function _capRes() {
13
+ var sent = {};
14
+ return {
15
+ headersSent: false,
16
+ writeHead: function (s, h) { sent.status = s; sent.headers = h; },
17
+ end: function () { sent.ended = true; },
18
+ _sent: sent,
19
+ };
20
+ }
10
21
 
11
22
  async function run() {
12
23
  var middleware = b.middleware.cspReport({});
@@ -22,6 +33,100 @@ async function run() {
22
33
  await middleware(req, res, function () {});
23
34
  check("middleware.cspReport: GET returns 405",
24
35
  sent.status === 405 && sent.ended === true);
36
+
37
+ await testOnRejectFiresOnEveryRejection();
38
+ testOnRejectRejectsNonFunction();
39
+ await testValidPostReachesOnReport();
40
+ await testOversizedPostRefused();
41
+ }
42
+
43
+ // The SUCCESS path: a valid POST with a parseable report body must reach
44
+ // the normalize / audit / onReport pipeline and return 204. A prior
45
+ // collector-call bug turned EVERY POST into a 413 (the endpoint never
46
+ // parsed anything); no test exercised an end-to-end POST-with-body, so
47
+ // smoke stayed green while the endpoint was dead. This is that test.
48
+ async function testValidPostReachesOnReport() {
49
+ var reports = [];
50
+ var res = _capRes();
51
+ var mw = b.middleware.cspReport({ onReport: function (r) { reports.push(r); } });
52
+ var payload = JSON.stringify([{
53
+ type: "csp-violation",
54
+ url: "https://app.example.com/",
55
+ body: { effectiveDirective: "script-src", blockedURL: "https://evil.example/x.js" },
56
+ }]);
57
+ await mw(_bodyReq("POST", { "content-type": "application/reports+json" }, payload), res, function () {});
58
+ check("cspReport: valid POST returns 204",
59
+ res._sent.status === 204 && res._sent.ended === true);
60
+ check("cspReport: valid POST reaches onReport with the normalized report",
61
+ reports.length === 1 && reports[0].body.effectiveDirective === "script-src");
62
+ }
63
+
64
+ // The byte cap still fires through the collector: a body over maxBytes
65
+ // is refused 413 and surfaces onReject "payload-too-large".
66
+ async function testOversizedPostRefused() {
67
+ var rejected = null;
68
+ var res = _capRes();
69
+ var mw = b.middleware.cspReport({
70
+ maxBytes: 8,
71
+ onReject: function (req, res, info) { rejected = info; },
72
+ });
73
+ await mw(_bodyReq("POST", {}, "[{\"type\":\"csp-violation\",\"body\":{}}]"), res, function () {});
74
+ check("cspReport: body over maxBytes returns 413", res._sent.status === 413);
75
+ check("cspReport: oversize fires onReject payload-too-large",
76
+ rejected !== null && rejected.reason === "payload-too-large");
77
+ }
78
+
79
+ // onReject surfaces the otherwise-empty-bodied 405 / 413 / 400 refusals
80
+ // to the operator (W3C Reporting API §3.1 — the browser ignores the
81
+ // rejection body, so onReject is the only signal a metrics sink gets).
82
+ // reason ∈ { method-not-allowed (405), payload-too-large (413),
83
+ // invalid-json (400) } and always matches the status that was written.
84
+ var REASON_FOR_STATUS = { 405: "method-not-allowed", 413: "payload-too-large", 400: "invalid-json" };
85
+
86
+ async function testOnRejectFiresOnEveryRejection() {
87
+ // 405 — non-POST. Short-circuits before the body collector, so this
88
+ // path is deterministic.
89
+ var got405 = null;
90
+ var mw405 = b.middleware.cspReport({
91
+ onReject: function (req, res, info) { got405 = info; },
92
+ });
93
+ var res405 = _capRes();
94
+ await mw405({ method: "GET", headers: {} }, res405, function () {});
95
+ check("onReject: fired on 405",
96
+ got405 && got405.status === 405 && got405.reason === "method-not-allowed");
97
+ check("onReject: response still written on 405",
98
+ res405._sent.status === 405 && res405._sent.ended === true);
99
+
100
+ // POST refusal — onReject fires and its {status, reason} agrees with
101
+ // the status the middleware wrote. Asserting the mapping (rather than a
102
+ // hard-coded status) keeps this test correct independent of which body
103
+ // refusal a given input lands on.
104
+ var gotPost = null;
105
+ var postRes = _capRes();
106
+ var mwPost = b.middleware.cspReport({
107
+ onReject: function (req, res, info) { gotPost = info; },
108
+ });
109
+ await mwPost(_bodyReq("POST", {}, "{bad"), postRes, function () {});
110
+ check("onReject: fired on POST refusal", gotPost !== null);
111
+ check("onReject: reason maps to written status",
112
+ gotPost && REASON_FOR_STATUS[gotPost.status] === gotPost.reason &&
113
+ gotPost.status === postRes._sent.status);
114
+
115
+ // A throwing hook is swallowed — the endpoint still writes the refusal.
116
+ var resThrow = _capRes();
117
+ var mwThrow = b.middleware.cspReport({
118
+ onReject: function () { throw new Error("sink broke"); },
119
+ });
120
+ await mwThrow({ method: "GET", headers: {} }, resThrow, function () {});
121
+ check("onReject: throwing hook does not crash the endpoint",
122
+ resThrow._sent.status === 405 && resThrow._sent.ended === true);
123
+ }
124
+
125
+ function testOnRejectRejectsNonFunction() {
126
+ var threw = false;
127
+ try { b.middleware.cspReport({ onReject: "nope" }); }
128
+ catch (e) { threw = e instanceof TypeError; }
129
+ check("onReject: non-function rejected at config time", threw);
25
130
  }
26
131
 
27
132
  module.exports = { run: run };
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ /**
3
+ * dbSchema.reconcile / reconcileTable — opt-in schema-drift detection.
4
+ *
5
+ * reconcile is additive-only (CREATE IF NOT EXISTS + ALTER ADD COLUMN);
6
+ * it never drops columns. The onDrift opt adds detection of config-vs-
7
+ * live divergence without changing that non-destructive contract:
8
+ *
9
+ * - default (onDrift unset / "ignore") = pre-detection behavior; a
10
+ * live table with an extra column is tolerated silently.
11
+ * - "warn" = detect, never throw (report returned to the caller).
12
+ * - "refuse" = throw at boot on the first drifted table (strict-schema
13
+ * posture).
14
+ *
15
+ * Drift cases covered: an undeclared (extra) live column and a clean
16
+ * schema (no drift). Tested directly against a node:sqlite handle — the
17
+ * same handle shape db.init drives — so the unit under test is the
18
+ * reconcile diff, not the full vault/audit boot.
19
+ */
20
+
21
+ var helpers = require("../helpers");
22
+ var check = helpers.check;
23
+ var fs = helpers.fs;
24
+ var os = helpers.os;
25
+ var path = helpers.path;
26
+ var sqlite = require("node:sqlite");
27
+ var dbSchema = require("../../lib/db-schema");
28
+
29
+ function _openDb(tmpDir) {
30
+ return new sqlite.DatabaseSync(path.join(tmpDir, "drift.db"));
31
+ }
32
+
33
+ function _liveColumns(db, table) {
34
+ return db.prepare('PRAGMA table_info("' + table + '")').all()
35
+ .map(function (r) { return r.name; });
36
+ }
37
+
38
+ function threwMatching(fn, pattern) {
39
+ try { fn(); } catch (e) { return pattern.test(e.message) ? e : null; }
40
+ return null;
41
+ }
42
+
43
+ var SCHEMA = [{
44
+ name: "widgets",
45
+ columns: {
46
+ _id: "TEXT PRIMARY KEY",
47
+ label: "TEXT",
48
+ status: "TEXT DEFAULT 'active'",
49
+ },
50
+ indexes: ["status"],
51
+ }];
52
+
53
+ async function run() {
54
+ var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-schema-drift-"));
55
+
56
+ // ---- DRIFT_MODES exported ----
57
+ check("DRIFT_MODES exported as the three reaction modes",
58
+ Array.isArray(dbSchema.DRIFT_MODES) &&
59
+ dbSchema.DRIFT_MODES.join(",") === "ignore,warn,refuse");
60
+
61
+ // ---- clean schema → no drift in any mode ----
62
+ var dbClean = _openDb(tmp);
63
+ var cleanReport = dbSchema.reconcile(dbClean, SCHEMA, { onDrift: "refuse" });
64
+ check("clean schema reconcile reports no drift", cleanReport.drifted === false);
65
+ check("clean schema reconcile reports zero drifted tables", cleanReport.tables.length === 0);
66
+ // re-running refuse on a freshly-reconciled DB must not throw
67
+ check("clean schema refuse mode does not throw",
68
+ threwMatching(function () { dbSchema.reconcile(dbClean, SCHEMA, { onDrift: "refuse" }); }, /.*/) === null);
69
+ dbClean.close();
70
+
71
+ // ---- introduce an undeclared (extra) live column out of band ----
72
+ var dbDrift = _openDb(tmp);
73
+ dbSchema.reconcile(dbDrift, SCHEMA); // default: ignore (back-compat path)
74
+ dbSchema.runSql(dbDrift, 'ALTER TABLE "widgets" ADD COLUMN "rogue" TEXT');
75
+ check("setup: rogue column is present in the live table",
76
+ _liveColumns(dbDrift, "widgets").indexOf("rogue") !== -1);
77
+
78
+ // default (no opts) tolerates drift silently — return value present,
79
+ // not drifted is irrelevant; the key assertion is it does NOT throw.
80
+ check("default reconcile (no opts) tolerates the extra column",
81
+ threwMatching(function () { dbSchema.reconcile(dbDrift, SCHEMA); }, /.*/) === null);
82
+
83
+ // explicit "ignore" is identical to default — no throw, not flagged.
84
+ var ignoreReport = dbSchema.reconcile(dbDrift, SCHEMA, { onDrift: "ignore" });
85
+ check("onDrift:'ignore' does not flag the extra column", ignoreReport.drifted === false);
86
+
87
+ // "warn" detects but does NOT throw, and reports the extra column.
88
+ var warnReport;
89
+ check("onDrift:'warn' does not throw on the extra column",
90
+ threwMatching(function () { warnReport = dbSchema.reconcile(dbDrift, SCHEMA, { onDrift: "warn" }); }, /.*/) === null);
91
+ check("onDrift:'warn' flags drift", warnReport.drifted === true);
92
+ check("onDrift:'warn' identifies the drifted table",
93
+ warnReport.tables.length === 1 && warnReport.tables[0].table === "widgets");
94
+ check("onDrift:'warn' lists the undeclared column as extra",
95
+ warnReport.tables[0].extra.indexOf("rogue") !== -1);
96
+ check("onDrift:'warn' reports no missing declared columns",
97
+ warnReport.tables[0].missing.length === 0);
98
+
99
+ // "refuse" throws on the same drift.
100
+ var refuseErr = threwMatching(
101
+ function () { dbSchema.reconcile(dbDrift, SCHEMA, { onDrift: "refuse" }); },
102
+ /schema drift on table 'widgets'/);
103
+ check("onDrift:'refuse' throws on the extra column", !!refuseErr);
104
+ check("onDrift:'refuse' error names the undeclared column",
105
+ refuseErr && /undeclared column\(s\) \[rogue\]/.test(refuseErr.message));
106
+
107
+ // refuse mode never drops the column — non-destructive contract holds.
108
+ check("refuse mode left the rogue column in place (non-destructive)",
109
+ _liveColumns(dbDrift, "widgets").indexOf("rogue") !== -1);
110
+ dbDrift.close();
111
+
112
+ // ---- reconcileTable surfaced directly carries the same modes ----
113
+ var dbT = _openDb(tmp);
114
+ dbSchema.reconcileTable(dbT, SCHEMA[0]);
115
+ dbSchema.runSql(dbT, 'ALTER TABLE "widgets" ADD COLUMN "sneaky" TEXT');
116
+ var tReport = dbSchema.reconcileTable(dbT, SCHEMA[0], { onDrift: "warn" });
117
+ check("reconcileTable warn returns a drift report",
118
+ tReport && tReport.drift && tReport.drift.extra.indexOf("sneaky") !== -1);
119
+ check("reconcileTable refuse throws on drift",
120
+ !!threwMatching(function () { dbSchema.reconcileTable(dbT, SCHEMA[0], { onDrift: "refuse" }); },
121
+ /schema drift on table 'widgets'/));
122
+ dbT.close();
123
+
124
+ // ---- bad onDrift value is a config-time throw ----
125
+ var dbBad = _openDb(tmp);
126
+ var enumErr = threwMatching(
127
+ function () { dbSchema.reconcile(dbBad, SCHEMA, { onDrift: "loud" }); },
128
+ /onDrift must be one of/);
129
+ check("bad onDrift enum value throws at config time", !!enumErr);
130
+ check("bad onDrift enum value throws a TypeError",
131
+ enumErr instanceof TypeError);
132
+ check("non-string onDrift value throws",
133
+ !!threwMatching(function () { dbSchema.reconcile(dbBad, SCHEMA, { onDrift: 3 }); },
134
+ /onDrift must be one of/));
135
+ dbBad.close();
136
+
137
+ fs.rmSync(tmp, { recursive: true, force: true });
138
+ console.log("OK — db schema-drift detection tests");
139
+ }
140
+
141
+ module.exports = { run: run };
142
+ if (require.main === module) {
143
+ run().then(function () { process.exit(0); })
144
+ .catch(function (err) { process.exitCode = 1; throw err; });
145
+ }
@@ -132,6 +132,38 @@ async function run() {
132
132
  });
133
133
  check("denyResponse onDeny no-op -> default written (no hang)",
134
134
  res5.statusCode === 403 && res5.body === "default-after-noop");
135
+
136
+ // problem mode with problemCode but no problemType -> type derives
137
+ // <base>/<code> (RFC 9457 §3.1.1), not "about:blank". This is the
138
+ // shape rate-limit.js emits for a 429.
139
+ var res6 = _mkRes();
140
+ denyResponse(_mkReq(), res6, {
141
+ problem: true, status: 429, contentType: "text/plain", body: "no",
142
+ problemCode: "rate-limit-exceeded", problemTitle: "Too Many Requests",
143
+ info: { status: 429, reason: "rate-limit-exceeded" },
144
+ });
145
+ var p6 = _json(res6.body);
146
+ check("denyResponse problem: problemCode (no problemType) -> type ends with /rate-limit-exceeded",
147
+ res6.statusCode === 429 && typeof p6.type === "string" &&
148
+ p6.type !== "about:blank" && /\/rate-limit-exceeded$/.test(p6.type));
149
+
150
+ // onDeny commits via headersSent only (no writableEnded flip) ->
151
+ // deny-response must treat it as terminal and NOT call writeHead
152
+ // again ("headers already sent" otherwise). A wrapping consumer's
153
+ // streaming onDeny is the real-world shape.
154
+ var wroteHeadAgain = false;
155
+ var res7 = _mkRes();
156
+ res7.writeHead = function () { wroteHeadAgain = true; throw new Error("ERR_HTTP_HEADERS_SENT"); };
157
+ var caught = null;
158
+ try {
159
+ denyResponse(_mkReq(), res7, {
160
+ onDeny: function (req, rs) { rs.headersSent = true; /* commits headers, does not end */ },
161
+ status: 403, contentType: "text/plain", body: "should-not-write",
162
+ info: { status: 403, reason: "x" },
163
+ });
164
+ } catch (e) { caught = e; }
165
+ check("denyResponse onDeny sets headersSent (no writableEnded) -> no second writeHead, no throw",
166
+ caught === null && wroteHeadAgain === false);
135
167
  })();
136
168
 
137
169
  // ---- require-methods (405 / text/plain) ----
@@ -221,6 +221,125 @@ async function run() {
221
221
  });
222
222
  check("auth-failure audit carries attemptedTable", payrollRow.length >= 1);
223
223
 
224
+ // ---- requireTls posture gate (PCI-DSS v4.0 Req 4 / HIPAA §164.312(e)) ----
225
+ // Opt-in transport posture: refuse a non-TLS external-db connection at
226
+ // config time. Default OFF — a backend that omits requireTls is used
227
+ // exactly as supplied (back-compat preserved).
228
+ function _noopDriver() {
229
+ return {
230
+ connect: async function () { return {}; },
231
+ query: async function () { return { rows: [], rowCount: 0 }; },
232
+ close: async function () {},
233
+ };
234
+ }
235
+ function _initThrows(label, cfg, codeRe) {
236
+ b.externalDb._resetForTest();
237
+ var threw = null;
238
+ try { b.externalDb.init({ backends: { main: cfg } }); }
239
+ catch (e) { threw = e; }
240
+ check("requireTls rejects: " + label,
241
+ threw && (codeRe.test(threw.code || "") || codeRe.test(threw.message || "")));
242
+ b.externalDb._resetForTest();
243
+ }
244
+ function _initOk(label, cfg) {
245
+ b.externalDb._resetForTest();
246
+ var threw = null;
247
+ try { b.externalDb.init({ backends: { main: cfg } }); }
248
+ catch (e) { threw = e; }
249
+ check("requireTls accepts: " + label, threw === null);
250
+ b.externalDb._resetForTest();
251
+ }
252
+
253
+ // Default OFF — no requireTls → plaintext connection used as-is.
254
+ var nd = _noopDriver();
255
+ _initOk("absent requireTls (default off, back-compat)",
256
+ { connect: nd.connect, query: nd.query, close: nd.close });
257
+
258
+ // requireTls:false is an explicit no-gate.
259
+ _initOk("requireTls:false explicit off",
260
+ { connect: nd.connect, query: nd.query, requireTls: false });
261
+
262
+ // requireTls:true with no TLS declaration → refused.
263
+ _initThrows("requireTls true, no TLS declared",
264
+ { connect: nd.connect, query: nd.query, requireTls: true }, /TLS_REQUIRED/);
265
+
266
+ // requireTls:true with sslmode that permits plaintext fallback → refused.
267
+ _initThrows("requireTls true, sslmode 'prefer' (plaintext fallback)",
268
+ { connect: nd.connect, query: nd.query, requireTls: true, sslmode: "prefer" }, /TLS_REQUIRED/);
269
+ _initThrows("requireTls true, sslmode 'disable'",
270
+ { connect: nd.connect, query: nd.query, requireTls: true, sslmode: "disable" }, /TLS_REQUIRED/);
271
+
272
+ // requireTls:true with explicit non-TLS transport → refused.
273
+ _initThrows("requireTls true, tls:false",
274
+ { connect: nd.connect, query: nd.query, requireTls: true, tls: false }, /TLS_REQUIRED/);
275
+
276
+ // requireTls:true satisfied by tls:true.
277
+ _initOk("requireTls true, tls:true",
278
+ { connect: nd.connect, query: nd.query, requireTls: true, tls: true });
279
+
280
+ // requireTls:true satisfied by an ssl object.
281
+ _initOk("requireTls true, ssl object",
282
+ { connect: nd.connect, query: nd.query, requireTls: true, ssl: { rejectUnauthorized: true } });
283
+
284
+ // requireTls:true satisfied by guaranteed sslmode values.
285
+ _initOk("requireTls true, sslmode 'require'",
286
+ { connect: nd.connect, query: nd.query, requireTls: true, sslmode: "require" });
287
+ _initOk("requireTls true, sslmode 'verify-full'",
288
+ { connect: nd.connect, query: nd.query, requireTls: true, sslmode: "verify-full" });
289
+
290
+ // requireTls must be a boolean — non-boolean refused at config time.
291
+ _initThrows("requireTls non-boolean",
292
+ { connect: nd.connect, query: nd.query, requireTls: "yes" }, /INVALID_CONFIG|must be a boolean/);
293
+
294
+ // ---- OTel db.* semantic-convention attributes on the data emit path ----
295
+ // db.system / db.operation / db.statement / db.name ride the audit
296
+ // metadata so OTel dashboards correlate without a per-framework adapter.
297
+ // Mirrors the db.ddl.executed shape on the local SQLite side.
298
+ var otelRows = [];
299
+ var origEmit2 = b.audit && b.audit.safeEmit;
300
+ if (origEmit2) {
301
+ b.audit.safeEmit = function (rec) {
302
+ if (rec && typeof rec.action === "string" &&
303
+ rec.action.indexOf("system.externaldb.") === 0) otelRows.push(rec);
304
+ return origEmit2.apply(b.audit, arguments);
305
+ };
306
+ }
307
+ try {
308
+ var od = _instrumentingDriver();
309
+ b.externalDb._resetForTest();
310
+ b.externalDb.init({
311
+ backends: { pgmain: { dialect: "postgres", connect: od.connect, query: od.query, close: od.close } },
312
+ });
313
+ await b.externalDb.query("SELECT id FROM users WHERE id = $1", [7]); // default — no includeSqlInAudit
314
+ await b.externalDb.query("SELECT id FROM users WHERE id = $1", [7], { includeSqlInAudit: true }); // opted in
315
+ await b.externalDb.transaction(async function (tx) { await tx.query("SELECT 1"); });
316
+ await new Promise(function (r) { setImmediate(r); });
317
+ } finally {
318
+ if (origEmit2) b.audit.safeEmit = origEmit2;
319
+ }
320
+ var qRow = otelRows.filter(function (r) { return r.action === "system.externaldb.query"; });
321
+ check("OTel: db.system maps dialect to OTel registry value (postgres→postgresql)",
322
+ qRow.length >= 2 && qRow[0].metadata["db.system"] === "postgresql");
323
+ check("OTel: db.name carries the backend name",
324
+ qRow.length >= 2 && qRow[0].metadata["db.name"] === "pgmain");
325
+ check("OTel: db.operation is the leading SQL keyword (always emitted)",
326
+ qRow.length >= 2 && qRow[0].metadata["db.operation"] === "SELECT");
327
+ // db.statement carries the SQL text (which can hold operator-inlined PII /
328
+ // secrets), so it is gated behind the includeSqlInAudit opt-out: omitted by
329
+ // default, present only when the operator opts in. This is the regression
330
+ // guard for the privacy gate.
331
+ check("OTel: db.statement OMITTED by default (respects includeSqlInAudit opt-out)",
332
+ qRow.length >= 2 && qRow[0].metadata["db.statement"] === undefined);
333
+ check("OTel: db.statement present + sanitized only when includeSqlInAudit:true",
334
+ qRow.length >= 2 &&
335
+ qRow[1].metadata["db.statement"] === "SELECT id FROM users WHERE id = $1" &&
336
+ qRow[1].metadata["db.statement"].indexOf("7") === -1);
337
+ var txRow = otelRows.filter(function (r) { return r.action === "system.externaldb.transaction"; });
338
+ check("OTel: transaction span carries db.system + db.operation BEGIN",
339
+ txRow.length >= 1 &&
340
+ txRow[0].metadata["db.system"] === "postgresql" &&
341
+ txRow[0].metadata["db.operation"] === "BEGIN");
342
+
224
343
  b.externalDb._resetForTest();
225
344
  }
226
345