@blamejs/blamejs-shop 0.4.87 → 0.4.88

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 (44) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/SECURITY.md +8 -0
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/vendor/MANIFEST.json +53 -31
  5. package/lib/vendor/blamejs/.clusterfuzzlite/Dockerfile +7 -2
  6. package/lib/vendor/blamejs/.github/dependabot.yml +12 -0
  7. package/lib/vendor/blamejs/.github/workflows/ci.yml +16 -12
  8. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +3 -1
  9. package/lib/vendor/blamejs/.github/workflows/release-container.yml +23 -0
  10. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  11. package/lib/vendor/blamejs/api-snapshot.json +10 -2
  12. package/lib/vendor/blamejs/examples/wiki/Dockerfile +19 -2
  13. package/lib/vendor/blamejs/lib/atomic-file.js +32 -9
  14. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +105 -40
  15. package/lib/vendor/blamejs/lib/middleware/dpop.js +54 -31
  16. package/lib/vendor/blamejs/lib/middleware/span-http-server.js +7 -0
  17. package/lib/vendor/blamejs/lib/network-tls.js +12 -2
  18. package/lib/vendor/blamejs/lib/queue-local.js +123 -46
  19. package/lib/vendor/blamejs/lib/queue.js +13 -9
  20. package/lib/vendor/blamejs/lib/request-helpers.js +90 -0
  21. package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +69 -7
  22. package/lib/vendor/blamejs/lib/self-update.js +11 -2
  23. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +11 -11
  24. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +6 -2
  25. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +689 -688
  26. package/lib/vendor/blamejs/oss-fuzz/projects/blamejs/Dockerfile +5 -0
  27. package/lib/vendor/blamejs/package.json +1 -1
  28. package/lib/vendor/blamejs/release-notes/v0.15.17.json +52 -0
  29. package/lib/vendor/blamejs/release-notes/v0.15.18.json +49 -0
  30. package/lib/vendor/blamejs/release-notes/v0.15.19.json +18 -0
  31. package/lib/vendor/blamejs/scripts/check-vendor-currency.js +24 -0
  32. package/lib/vendor/blamejs/test/integration/queue-cluster-mysql.test.js +419 -0
  33. package/lib/vendor/blamejs/test/integration/queue-cluster-pg.test.js +471 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt-rejection-envelope.test.js +309 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -8
  36. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-fd-read-errorfor-bypass.test.js +138 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +26 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-htu-peergating.test.js +227 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/queue-flow-repeat.test.js +83 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/self-update-poll-asset-digest.test.js +90 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/self-update-standalone-verifier-ecdsa-encoding.test.js +274 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/tls-ocsp-freshness.test.js +192 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/vendor-currency-classify.test.js +36 -0
  44. package/package.json +1 -1
@@ -5,6 +5,11 @@
5
5
  # and open a PR there. See `oss-fuzz/projects/blamejs/README.md` in
6
6
  # this repo for the full submission procedure.
7
7
 
8
+ # The base-builder is intentionally NOT digest-pinned here (unlike the
9
+ # .clusterfuzzlite/ copy): OSS-Fuzz's build infra expects projects to track
10
+ # the latest base-builder so the fuzzing toolchain stays current, and a digest
11
+ # pin in the upstream-submission template would diverge from that convention.
12
+ # This is a CI-only fuzzing image, never deployed.
8
13
  FROM gcr.io/oss-fuzz-base/base-builder-javascript
9
14
 
10
15
  # Clone the framework into the build context. OSS-Fuzz builds against
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.16",
3
+ "version": "0.15.19",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.17",
4
+ "date": "2026-06-22",
5
+ "headline": "The job queue runs on a Postgres or MySQL cluster backend, and a set of correctness and confidentiality fixes land for encrypted-API rejections, flow-job ordering, the self-update signature verifier, and bounded file reads",
6
+ "summary": "The local job queue's lease path is now dialect-aware, so the queue works when its store is a Postgres or MySQL cluster backend rather than only single-node SQLite. The previous lease was a single SQLite-only statement: it double-quoted identifiers (which MySQL reads as string literals), updated a table named in its own subquery (MySQL error 1093), and used RETURNING (which MySQL rejects), so the first enqueue or lease against a cluster backend failed outright. The lease now resolves the active backend dialect and composes per-dialect SQL — a transactional SELECT ... FOR UPDATE SKIP LOCKED claim over the head of the queue followed by a guarded UPDATE against a literal id list, returning the claimed rows via RETURNING on Postgres and a re-select on MySQL — with backtick (MySQL) or double-quote (Postgres) identifier quoting, mirroring the framework's outbox claim. enqueue, lease, complete, fail, sweep, and dead-letter all run against the cluster backend, verified end-to-end against live MySQL and Postgres. Alongside the queue work: a request rejected on an established encrypted-API session is now returned inside the session envelope instead of leaking the rejection reason in cleartext; a flow child that declares dependencies can no longer be leased before those dependencies run during the brief window of the two-pass flow enqueue; the standalone self-update verifier classifies an ECDSA signature's encoding by its ASN.1 structure rather than its byte length and fails closed on an indeterminate shape; the self-update poller surfaces each asset's content digest; and the bounded synchronous file reader routes every failure branch through its typed error mapper.",
7
+ "sections": [
8
+ {
9
+ "heading": "Fixed",
10
+ "items": [
11
+ {
12
+ "title": "The job queue's lease runs dialect-correct SQL on a Postgres or MySQL cluster backend",
13
+ "body": "When the queue's store is a cluster backend, the lease previously failed at the first enqueue or claim: it was composed as one SQLite-only statement that double-quoted column identifiers (a syntax error on MySQL, which treats double quotes as string literals), updated the jobs table from a subquery that named the same table (MySQL error 1093), and relied on RETURNING (which MySQL does not support). The lease now resolves the active backend dialect and builds per-dialect SQL — a transactional SELECT ... FOR UPDATE SKIP LOCKED over the head of the queue, then a guarded UPDATE ... WHERE status = 'pending' AND id IN (...) with a literal id list (no self-referencing subquery), reading the claimed rows back via RETURNING on Postgres and a re-select by id on MySQL — and quotes identifiers with backticks on MySQL and double quotes on Postgres. enqueue, lease, complete, fail, sweep, and the dead-letter operations now work against a cluster backend, with sealed-at-rest job payloads round-tripping correctly; single-node SQLite continues to use the original single-statement claim. The behavior is verified end-to-end against live MySQL and Postgres."
14
+ },
15
+ {
16
+ "title": "Encrypted-API rejections that would disclose session state ride the session envelope",
17
+ "body": "On a per-session encrypted API channel, a rejection that reveals session state — an expired or rotated session, or a request admitted past the replay gate whose ciphertext then failed authentication — returned its reason as cleartext JSON, disclosing which check failed to a network observer of an otherwise-encrypted channel. Those rejections are now encrypted inside the session envelope under the response counter the server persists for them. The generic 'rejected' refusals that carry no session-lifecycle detail (a stale or duplicate request counter) stay plaintext: they reveal nothing a 400 status does not, and encrypting them would consume a response counter the server does not persist on those paths — desyncing the session's monotonic counter and bricking it for the next genuine request. Pre-session handshake failures (a malformed bootstrap, a stale timestamp, an unknown session) also remain plaintext, since no key exists yet to encrypt under."
18
+ },
19
+ {
20
+ "title": "A flow child with dependencies can no longer be leased before its dependencies",
21
+ "body": "b.queue.enqueueFlow inserts a flow's children in two passes (the second resolves dependency names to the sibling job ids assigned in the first). The first pass previously enqueued every child as immediately leaseable and relied on the second pass to park the dependency-bearing children, leaving a window in which a consumer polling between the two passes could lease a child before the jobs it depends on had run. A dependency-bearing child is now parked at enqueue time on the first pass, so it is never leaseable in that window; the second pass only rewrites its dependency names to the resolved job ids. Children with no dependencies remain immediately leaseable."
22
+ },
23
+ {
24
+ "title": "The self-update poller carries each asset's content digest",
25
+ "body": "b.selfUpdate.poll() now includes the content digest for each asset and its detached signature in the result it returns, so a caller can verify a downloaded asset against the digest advertised by the release feed."
26
+ },
27
+ {
28
+ "title": "Bounded file reads always return the typed error shape",
29
+ "body": "The bounded synchronous file reader could surface an unmapped error on a few failure branches — a refused symlink whose target had since been removed, a short read, an integrity mismatch — rather than the typed error its callers expect. Every failure branch now passes through the reader's error mapper, so a caller always receives the documented error kind."
30
+ }
31
+ ]
32
+ },
33
+ {
34
+ "heading": "Changed",
35
+ "items": [
36
+ {
37
+ "title": "Bundled Public Suffix List refreshed to current upstream",
38
+ "body": "The vendored Mozilla Public Suffix List — which b.publicSuffix uses to derive organizational domains for DMARC (psd= / np=), BIMI, cookie-scope checks, and same-site policy — is refreshed to the latest upstream revision. Domain classification reflects recent additions and removals to the list."
39
+ }
40
+ ]
41
+ },
42
+ {
43
+ "heading": "Security",
44
+ "items": [
45
+ {
46
+ "title": "The standalone self-update verifier detects ECDSA signature encoding by structure",
47
+ "body": "The standalone update-signature verifier inferred whether an ECDSA signature was DER- or IEEE-P1363-encoded from its byte length — a heuristic two distinct encodings can collide on, which could lead a valid-but-misclassified signature to be parsed under the wrong scheme. It now parses the signature's ASN.1 structure to classify the encoding and fails closed on an indeterminate shape rather than guessing."
48
+ }
49
+ ]
50
+ }
51
+ ]
52
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.18",
4
+ "date": "2026-06-22",
5
+ "headline": "OCSP response-freshness enforcement is restored, DPoP request-URI reconstruction peer-gates forwarded headers, and container/tool supply-chain pinning is tightened",
6
+ "summary": "Three hardening fixes. The stapled-OCSP evaluator parsed each response's thisUpdate / nextUpdate with Date.parse, but those fields are already numeric (unix-ms), so Date.parse returned NaN — the freshness guard then rejected every signature-valid response, fresh or stale, with a misleading \"missing thisUpdate\", and the real future-dated / past-nextUpdate window checks (RFC 6960 §4.2.2.1) were unreachable dead code. b.network.tls.ocsp.evaluate / requireGood now read the numeric fields directly: a stale or future-dated response is refused and a fresh \"good\" response is accepted, so OCSP stapling validation works again. b.middleware.dpop reconstructed the absolute request URI — the cryptographically-bound htu — trusting X-Forwarded-Proto / X-Forwarded-Host from any caller whenever trustForwardedHeaders was set, so a direct attacker could forge the scheme or authority and make a proof signed for one origin validate against another; both now resolve through the peer-gated b.requestHelpers.trustedProtocol / trustedHost (honored only from a declared trusted-proxy peer), matching csrf-protect / security-headers / cors, and the bare trustForwardedHeaders boolean is refused. The supply-chain pass pins the wiki container and ClusterFuzzLite fuzz base images to digests (Dependabot keeps them current) and the npm-publish bundle tools to exact versions.",
7
+ "sections": [
8
+ {
9
+ "heading": "Security",
10
+ "items": [
11
+ {
12
+ "title": "OCSP response-freshness enforcement restored (RFC 6960 §4.2.2.1)",
13
+ "body": "The stapled-OCSP evaluator computed thisUpdate / nextUpdate via Date.parse(), but the parser hands those fields back as unix-ms NUMBERS (Date.UTC(...)). Date.parse() coerces its argument to a string and a bare-integer string is not a recognized date, so the result was always NaN: the !isFinite guard then rejected every signature-valid response — fresh OR stale — with a misleading \"missing thisUpdate\", and the genuine staleness checks (future-dated thisUpdate, past nextUpdate) sat behind it as unreachable dead code, with the stale-rejection branch latently fail-open. b.network.tls.ocsp.evaluate / requireGood now read the numeric fields directly: a stale (past-nextUpdate) or future-dated response is refused, and a fresh \"good\" response is accepted — OCSP stapling validation, and its replay defense, work again."
14
+ },
15
+ {
16
+ "title": "DPoP htu reconstruction peer-gates X-Forwarded-Proto and X-Forwarded-Host",
17
+ "body": "b.middleware.dpop rebuilds the absolute request URI (scheme + authority + path) that the proof's cryptographically-bound htu claim (RFC 9449 §4.3) is verified against. When the legacy trustForwardedHeaders: true was set it derived the scheme and host from X-Forwarded-Proto / X-Forwarded-Host trusted from ANY caller, with no immediate-peer check — a direct attacker could forge X-Forwarded-Proto: https or a victim X-Forwarded-Host and make a proof signed for one origin validate against another (htu confusion). Both now resolve through the peer-gated b.requestHelpers.trustedProtocol / trustedHost, which honor the forwarded headers only when the immediate connection is a declared trusted proxy — the same fail-closed model csrf-protect (Secure cookie), security-headers (HSTS), cors (same-origin), and bot-guard (secure context) already use. A codebase-patterns detector now flags any further middleware that reads X-Forwarded-Proto / -Host directly for a scheme/authority decision."
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "heading": "Added",
23
+ "items": [
24
+ {
25
+ "title": "b.requestHelpers.requestHost and b.requestHelpers.trustedHost",
26
+ "body": "Peer-gated request-authority resolvers — the host companions to requestProtocol / trustedProtocol. trustedHost(opts) returns { resolve(req) => string|null, peerGated }: with trustedProxies (CIDRs) X-Forwarded-Host is honored only from a trusted-proxy peer; with hostResolver(req) the operator owns it; with neither, only the request's own Host header is used and a forged X-Forwarded-Host is ignored. requestHost(req, opts?) is the low-level resolver (default Host-only; a peer predicate gates the forwarded header). For reconstructing an absolute request URL (a DPoP htu, an origin/issuer string, a redirect base) behind a proxy without trusting a forgeable header."
27
+ }
28
+ ]
29
+ },
30
+ {
31
+ "heading": "Changed",
32
+ "items": [
33
+ {
34
+ "title": "Container and bundle-tool supply-chain pinning tightened",
35
+ "body": "The wiki container's base images (cgr.dev/chainguard/node runtime + builder stages) and the ClusterFuzzLite fuzz base (gcr.io/oss-fuzz-base/base-builder-javascript) are pinned to image digests; Dependabot's docker ecosystem keeps both current, so the pin is reproducible without freezing CVE patches. The npm-publish workflow's bundle tools (esbuild, postject) are pinned to exact versions, matching CI. The OSS-Fuzz project-submission Dockerfile is intentionally left tracking the upstream base-builder, per OSS-Fuzz convention."
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "heading": "Migration",
41
+ "items": [
42
+ {
43
+ "title": "DPoP: trustForwardedHeaders is replaced by trustedProxies",
44
+ "body": "b.middleware.dpop no longer honors the bare trustForwardedHeaders: true boolean — it trusted forgeable X-Forwarded-Proto / X-Forwarded-Host from any caller. Behind a reverse proxy, declare your proxy CIDRs via trustedProxies: [\"10.0.0.0/8\", …] (peer-gates both headers for the htu reconstruction), or own the reconstruction via protocolResolver(req) / hostResolver(req) / getHtu(req). A bare trustForwardedHeaders: true now throws at create() with that guidance. Apps not behind a proxy need no change — the default already derives the scheme from the TLS socket and the host from the request's Host header."
45
+ }
46
+ ]
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.19",
4
+ "date": "2026-06-22",
5
+ "headline": "Restores the wiki container build by keeping its base image on the continuously-patched rolling tag",
6
+ "summary": "A follow-up to 0.15.18. The framework code is unchanged from 0.15.18 — this patch reverts one part of that release's supply-chain pass: the example wiki container's Chainguard base images had been pinned to specific digests, but Chainguard rebuilds those images continuously to ship CVE fixes, so the frozen digest fell behind an upstream fix within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build (a fixed npm/undici denial-of-service the rolling tag already carried). For a deployed, Trivy-gated image, tracking the rolling, always-patched tag is the correct posture; the wiki Dockerfile is back on it. The ClusterFuzzLite fuzz base (not deployed, not release-gated) stays digest-pinned with Dependabot keeping it current.",
7
+ "sections": [
8
+ {
9
+ "heading": "Fixed",
10
+ "items": [
11
+ {
12
+ "title": "Wiki container builds again on a continuously-patched base",
13
+ "body": "0.15.18 digest-pinned the example wiki container's Chainguard base images (runtime + builder). Because Chainguard rebuilds those images continuously to ship CVE fixes, the pinned digest went stale within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build over an already-fixed npm/undici DoS (CVE-2026-12151) the rolling tag carried. The wiki Dockerfile tracks the rolling tag again, so the deployed image is always CVE-current and the build passes the release gate. To keep the Trivy-scanned image identical to the multi-arch image that is published (they are built separately), the release workflow resolves the rolling tag to a digest once at build time and feeds it to both builds via build-args — scan-equals-publish without a committed pin that goes stale. (This trades the OSSF Scorecard PinnedDependencies signal for CVE currency on a deployed, gate-scanned image — the right call here; the non-deployed ClusterFuzzLite fuzz base remains digest-pinned with Dependabot bumping it.) The framework package itself is identical to 0.15.18."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -201,12 +201,36 @@ function _httpGetText(url, redirectsLeft) {
201
201
  // or throws when neither side yields a comparable identifier (the
202
202
  // caller maps that to "registry-error" so an upstream-format change
203
203
  // surfaces loudly instead of silently passing the gate).
204
+ // Parse a "YYYY-MM-DD_HH-MM-SS_UTC" version header (the PSL's) into ms, or
205
+ // null when it isn't that timestamp shape.
206
+ function _parseVersionTs(v) {
207
+ var m = String(v || "").match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})_UTC$/);
208
+ if (!m) return null;
209
+ return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
210
+ }
211
+
204
212
  function _classifyContentCurrency(upstreamText, localText, special) {
205
213
  function pick(text, re) { var m = re && text.match(re); return (m && m[1]) || null; }
206
214
  var uCommit = pick(upstreamText, special.commitRe);
207
215
  var lCommit = pick(localText, special.commitRe);
208
216
  var uVer = pick(upstreamText, special.versionRe);
209
217
  var lVer = pick(localText, special.versionRe);
218
+ // Timestamp-versioned source (the PSL ships `// VERSION: <ts>`): "stale"
219
+ // means our bundle is genuinely BEHIND upstream, not merely different.
220
+ // publicsuffix.org is CDN-served and different edges can return an OLDER
221
+ // cached copy than the one we vendored; an exact commit/version inequality
222
+ // would then mis-flag our (newer) bundle as stale and fail the release gate
223
+ // non-deterministically. When both sides carry a parseable VERSION
224
+ // timestamp, compare them directionally — only a bundle older than upstream
225
+ // is stale; newer-or-equal is current. (The COMMIT remains the displayed
226
+ // identifier but cannot order two revisions, so it is not the staleness
227
+ // basis here.)
228
+ var uTs = _parseVersionTs(uVer);
229
+ var lTs = _parseVersionTs(lVer);
230
+ if (uTs !== null && lTs !== null) {
231
+ return { stale: lTs < uTs, basis: "version-timestamp", upstreamId: uVer, localId: lVer,
232
+ upstreamVersion: uVer, localVersion: lVer };
233
+ }
210
234
  if (uCommit && lCommit) {
211
235
  return { stale: uCommit !== lCommit, basis: "commit", upstreamId: uCommit, localId: lCommit,
212
236
  upstreamVersion: uVer, localVersion: lVer };
@@ -0,0 +1,419 @@
1
+ "use strict";
2
+ /**
3
+ * Live MySQL coverage for the queue-local CLUSTER lease path — the dialect-
4
+ * aware enqueue + the transactional FOR UPDATE SKIP LOCKED claim that only
5
+ * runs when the queue's store is a postgres/mysql cluster backend. Host smoke
6
+ * exercises ONLY the sqlite single-statement RETURNING path; nothing proved
7
+ * the b.sql the cluster path emits is MySQL-valid until this file. The three
8
+ * MySQL-specific traps the lease reshape exists to clear:
9
+ *
10
+ * - identifier quoting: b.sql must emit BACKTICK-quoted camelCase columns
11
+ * (`queueName`, `availableAt`, …), not the double-quotes MySQL reads as
12
+ * string literals (the pre-reshape builders defaulted to the sqlite
13
+ * dialect → "queueName" → ERROR 1064 on the very first enqueue);
14
+ * - no RETURNING: MySQL rejects `UPDATE … RETURNING`, so the lease re-SELECTs
15
+ * the claimed rows by their locked ids instead;
16
+ * - no self-referencing subquery UPDATE: the guarded UPDATE binds a LITERAL
17
+ * id list (`_id IN (?, ?)`) rather than updating a table named in its own
18
+ * subquery (MySQL ERROR 1093).
19
+ *
20
+ * Driven through the REAL queue-local consumer surface (the backend factory
21
+ * b.queue.init wires): enqueue / lease / complete / fail / sweepExpired /
22
+ * dlqList against a real MySQL 8 server. RED before the dialect reshape (the
23
+ * first enqueue throws 1064); GREEN after.
24
+ *
25
+ * HARNESS LIMIT — read before extending: the "driver" is the stateless
26
+ * docker-exec mysql shim shared with data-layer-cluster-mysql (every query()
27
+ * is a fresh `mysql -e`, so BEGIN / the body / COMMIT and FOR UPDATE SKIP
28
+ * LOCKED each land on a SEPARATE connection — the transaction is non-atomic
29
+ * and the row lock does not hold). This file therefore validates DIALECT
30
+ * CORRECTNESS of the cluster lease SQL, NOT lock-based double-lease
31
+ * prevention; the latter is covered by the sqlite single-writer path and by
32
+ * outbox._claimBatch (the canonical competing-consumer claim this mirrors)
33
+ * against a pooled driver. Do not add a concurrent-double-lease assertion
34
+ * here — it cannot be made meaningful on the stateless shim.
35
+ *
36
+ * RUN: node scripts/test-integration.js --skip-service-check queue-cluster-mysql
37
+ */
38
+
39
+ var execFileSync = require("node:child_process").execFileSync;
40
+ var fs = require("node:fs");
41
+ var os = require("node:os");
42
+ var path = require("node:path");
43
+ var helpers = require("../helpers");
44
+ var check = helpers.check;
45
+ var services = require("../helpers/services");
46
+ var b = require("../../");
47
+ var queueLocal = require("../../lib/queue-local");
48
+
49
+ var CONTAINER = "blamejs-test-mysql";
50
+ var DB_NAME = "blamejs_queue_cluster_test";
51
+
52
+ // ---- one-shot mysql (setup / teardown / out-of-band assertions) ----
53
+ function _mysqlRoot(sql, dbName) {
54
+ var args = ["exec", "-i", CONTAINER, "mysql", "-uroot", "-pblamejs_test_root", "--batch", "--raw"];
55
+ if (dbName) args.push(dbName);
56
+ args.push("-e", sql);
57
+ return execFileSync("docker", args, { stdio: ["pipe", "pipe", "pipe"] }).toString("utf8");
58
+ }
59
+
60
+ // ---- docker-exec mysql driver (faithful to a text-protocol driver) ----
61
+ // Identical contract to data-layer-cluster-mysql's shim: ROW_COUNT() is read
62
+ // in the SAME invocation as the DML so affectedRows is connection-consistent.
63
+ function _makeDockerMysqlDriver() {
64
+ return {
65
+ connect: async function () { return { id: 1 }; },
66
+ query: async function (_client, sql, params) {
67
+ params = params || [];
68
+ var bound = _bindParams(sql, params);
69
+ var t = bound.trim();
70
+ if (/^(CREATE|ALTER|INSERT|UPDATE|DELETE|DROP|REPLACE|TRUNCATE|BEGIN|START|COMMIT|ROLLBACK)\b/i.test(t)) {
71
+ // Transaction-control statements (BEGIN/COMMIT/ROLLBACK) are no-ops on
72
+ // the stateless shim — run them but don't read ROW_COUNT().
73
+ if (/^(BEGIN|START|COMMIT|ROLLBACK)\b/i.test(t)) { _exec(bound.replace(/;\s*$/, "")); return { rows: [], affectedRows: 0, rowCount: 0 }; }
74
+ var stmt = bound.replace(/;\s*$/, "");
75
+ var ar = _exec(stmt + "; SELECT ROW_COUNT() AS n");
76
+ var parsed = _parseBatch(ar);
77
+ var n = parsed.rows[0] ? Number(parsed.rows[0].n) : 0;
78
+ if (!isFinite(n) || n < 0) n = 0;
79
+ return { rows: [], affectedRows: n, rowCount: n };
80
+ }
81
+ var out = _exec(bound);
82
+ var parsedSel = _parseBatch(out);
83
+ return { rows: parsedSel.rows, rowCount: parsedSel.rows.length };
84
+ },
85
+ close: async function () { /* no-op */ },
86
+ dialect: "mysql",
87
+ };
88
+ }
89
+
90
+ function _exec(sql) {
91
+ try {
92
+ return execFileSync("docker",
93
+ ["exec", "-i", CONTAINER, "mysql", "-uroot", "-pblamejs_test_root",
94
+ "--batch", "--raw", DB_NAME, "-e", sql],
95
+ { stdio: ["pipe", "pipe", "pipe"] }).toString("utf8");
96
+ } catch (e) {
97
+ var msg = e.stderr ? e.stderr.toString("utf8") : (e.message || String(e));
98
+ var errLine = (msg.split(/\r?\n/).filter(function (l) {
99
+ return /ERROR \d+/.test(l);
100
+ })[0]) || msg.trim();
101
+ var err = new Error(errLine.trim());
102
+ var m = /ERROR (\d+) \(([0-9A-Za-z]{5})\)/.exec(msg);
103
+ if (m) { err.errno = Number(m[1]); err.code = m[2]; err.sqlState = m[2]; }
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ function _bindParams(sql, params) {
109
+ var i = 0;
110
+ return sql.replace(/\?/g, function () {
111
+ if (i >= params.length) throw new Error("placeholder/param count mismatch");
112
+ var p = params[i++];
113
+ if (p === null || p === undefined) return "NULL";
114
+ if (typeof p === "number") return String(p);
115
+ if (typeof p === "boolean") return p ? "1" : "0";
116
+ return "'" + String(p).replace(/\\/g, "\\\\").replace(/'/g, "''") + "'";
117
+ });
118
+ }
119
+
120
+ function _parseBatch(out) {
121
+ var lines = out.split(/\r?\n/).filter(function (l) { return l.length > 0; });
122
+ if (lines.length < 1) return { rows: [] };
123
+ var headers = lines[0].split("\t");
124
+ var rows = [];
125
+ for (var i = 1; i < lines.length; i++) {
126
+ var cells = lines[i].split("\t");
127
+ var row = {};
128
+ for (var j = 0; j < headers.length; j++) {
129
+ var v = cells[j];
130
+ row[headers[j]] = (v === "NULL" || v === undefined) ? null : v;
131
+ }
132
+ rows.push(row);
133
+ }
134
+ return { rows: rows };
135
+ }
136
+
137
+ function _countMysql(table, whereClause) {
138
+ var sql = "SELECT count(*) AS n FROM " + table +
139
+ (whereClause ? " WHERE " + whereClause : "");
140
+ var out = _mysqlRoot(sql, DB_NAME);
141
+ var parsed = _parseBatch(out);
142
+ return parsed.rows[0] ? Number(parsed.rows[0].n) : 0;
143
+ }
144
+
145
+ // One out-of-band column read for a single job row.
146
+ function _jobCol(jobId, col) {
147
+ var out = _mysqlRoot(
148
+ "SELECT `" + col + "` AS v FROM `_blamejs_jobs` WHERE `_id` = '" +
149
+ jobId.replace(/'/g, "''") + "'", DB_NAME);
150
+ var parsed = _parseBatch(out);
151
+ return parsed.rows[0] ? parsed.rows[0].v : null;
152
+ }
153
+
154
+ var OWNED_TABLES = [
155
+ "_blamejs_jobs",
156
+ "_blamejs_cluster_state",
157
+ "_blamejs_leader",
158
+ "_blamejs_audit_tip",
159
+ "_blamejs_consent_tip",
160
+ ];
161
+
162
+ function _ensureTipTables() {
163
+ _mysqlRoot(
164
+ "CREATE TABLE IF NOT EXISTS `_blamejs_audit_tip` (" +
165
+ " `scope` VARCHAR(64) PRIMARY KEY," +
166
+ " `atMonotonicCounter` BIGINT NOT NULL DEFAULT 0," +
167
+ " `rowHash` TEXT," +
168
+ " `signedAt` TEXT," +
169
+ " `fencingToken` BIGINT NOT NULL DEFAULT 0)", DB_NAME);
170
+ _mysqlRoot(
171
+ "CREATE TABLE IF NOT EXISTS `_blamejs_consent_tip` (" +
172
+ " `scope` VARCHAR(64) PRIMARY KEY," +
173
+ " `atMonotonicCounter` BIGINT NOT NULL DEFAULT 0," +
174
+ " `rowHash` TEXT," +
175
+ " `signedAt` TEXT," +
176
+ " `fencingToken` BIGINT NOT NULL DEFAULT 0)", DB_NAME);
177
+ }
178
+
179
+ // The jobs table, backtick-quoted camelCase columns matching JOB_COLS in
180
+ // queue-local. BIGINT holds the unix-ms timestamps AND the flow-blocked
181
+ // sentinel (Number.MAX_SAFE_INTEGER = 9.007e15, well inside BIGINT range).
182
+ function _createJobsTable() {
183
+ _mysqlRoot("DROP TABLE IF EXISTS `_blamejs_jobs`", DB_NAME);
184
+ _mysqlRoot(
185
+ "CREATE TABLE `_blamejs_jobs` (" +
186
+ " `_id` VARCHAR(64) PRIMARY KEY," +
187
+ " `queueName` VARCHAR(255) NOT NULL," +
188
+ " `payload` LONGTEXT," +
189
+ " `status` VARCHAR(32) NOT NULL," +
190
+ " `enqueuedAt` BIGINT NOT NULL," +
191
+ " `availableAt` BIGINT NOT NULL," +
192
+ " `leasedAt` BIGINT," +
193
+ " `leaseExpiresAt` BIGINT," +
194
+ " `attempts` BIGINT NOT NULL DEFAULT 0," +
195
+ " `maxAttempts` BIGINT NOT NULL DEFAULT 5," +
196
+ " `lastError` LONGTEXT," +
197
+ " `finishedAt` BIGINT," +
198
+ " `traceId` VARCHAR(255)," +
199
+ " `classification` VARCHAR(255)," +
200
+ " `priority` BIGINT NOT NULL DEFAULT 0," +
201
+ " `repeatCron` VARCHAR(255)," +
202
+ " `repeatTimezone` VARCHAR(255)," +
203
+ " `flowId` VARCHAR(255)," +
204
+ " `flowChildName` VARCHAR(255)," +
205
+ " `dependsOn` LONGTEXT," +
206
+ " KEY `lease_idx` (`queueName`, `status`, `availableAt`)" +
207
+ ")", DB_NAME);
208
+ }
209
+
210
+ function _dropOwned() {
211
+ for (var i = 0; i < OWNED_TABLES.length; i++) {
212
+ try { _mysqlRoot("DROP TABLE IF EXISTS `" + OWNED_TABLES[i] + "`", DB_NAME); } catch (_e) {}
213
+ }
214
+ }
215
+
216
+ async function run() {
217
+ var svc = await services.requireService("mysql");
218
+ if (!svc.ok) throw new Error("mysql unreachable: " + svc.reason);
219
+
220
+ _mysqlRoot("CREATE DATABASE IF NOT EXISTS " + DB_NAME);
221
+ _dropOwned();
222
+
223
+ var driver = _makeDockerMysqlDriver();
224
+ b.cluster._resetForTest();
225
+ b.externalDb._resetForTest();
226
+ b.db._resetForTest(); // clears the cryptoField table registry between runs
227
+ b.externalDb.init({
228
+ backends: {
229
+ ops: {
230
+ connect: driver.connect, query: driver.query, close: driver.close,
231
+ dialect: "mysql",
232
+ },
233
+ },
234
+ });
235
+
236
+ var vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-q-cl-my-"));
237
+ await helpers.setupVaultOnly(vaultDir);
238
+ // Re-register the _blamejs_jobs sealed-column map (payload + lastError):
239
+ // db._resetForTest cleared the cryptoField registry, and no db.init runs in
240
+ // this standalone process, so seal-at-rest would silently pass through
241
+ // without it. This proves the cluster path seals + unseals on MySQL too.
242
+ queueLocal._ensureSealTable();
243
+
244
+ _ensureTipTables();
245
+ _createJobsTable();
246
+
247
+ await b.cluster.init({
248
+ nodeId: "queue-node-my",
249
+ role: "leader",
250
+ leaseTtl: b.constants.TIME.seconds(60),
251
+ heartbeatInterval: b.constants.TIME.seconds(20),
252
+ externalDbBackend: "ops",
253
+ dialect: "mysql",
254
+ });
255
+
256
+ try {
257
+ check("queue-cluster (mysql): cluster mode routes the queue store to MySQL",
258
+ b.cluster.isClusterMode() === true && b.clusterStorage.dialect() === "mysql");
259
+
260
+ var q = queueLocal.create({}); // default store = clusterStorage → external "ops" (MySQL)
261
+
262
+ await _proveEnqueueAndClusterLease(q);
263
+ await _proveComplete(q);
264
+ await _proveFailRetryThenFinalDlq(q);
265
+ await _proveSweepExpired(q);
266
+ await _proveDelayedNotLeased(q);
267
+ } finally {
268
+ try { await b.cluster.shutdown(); } catch (_e) {}
269
+ b.cluster._resetForTest();
270
+ try { await b.externalDb.shutdown(); } catch (_e) {}
271
+ b.externalDb._resetForTest();
272
+ try { helpers.teardownVaultOnly(vaultDir); } catch (_e) {}
273
+ _dropOwned();
274
+ }
275
+ }
276
+
277
+ // ======================================================================
278
+ // 1. enqueue (dialect INSERT) + the transactional cluster lease — the #35
279
+ // headline. Three jobs, distinct priorities; lease(2) must hand back the
280
+ // two highest-priority head-of-queue jobs in order, flip them inflight,
281
+ // increment attempts, and round-trip the sealed payload — all through
282
+ // MySQL-valid SQL (backticks, no RETURNING, literal id-list UPDATE).
283
+ // ======================================================================
284
+ async function _proveEnqueueAndClusterLease(q) {
285
+ var Q = "q-claim";
286
+ await q.enqueue(Q, { job: "low" }, { priority: 0 });
287
+ await q.enqueue(Q, { job: "high" }, { priority: 10 });
288
+ await q.enqueue(Q, { job: "mid" }, { priority: 5 });
289
+
290
+ check("enqueue (mysql): three INSERTs landed (backtick-quoted camelCase, no 1064)",
291
+ _countMysql("`_blamejs_jobs`", "`queueName` = 'q-claim'") === 3);
292
+ check("enqueue (mysql): all three start pending",
293
+ _countMysql("`_blamejs_jobs`", "`queueName` = 'q-claim' AND `status` = 'pending'") === 3);
294
+ check("enqueue (mysql): the sealed payload is NOT stored in cleartext",
295
+ _countMysql("`_blamejs_jobs`", "`queueName` = 'q-claim' AND `payload` LIKE '%\"job\"%'") === 0);
296
+
297
+ var leased = await q.lease(Q, b.constants.TIME.seconds(30), 2);
298
+ check("cluster-lease (mysql): leased exactly 2 (FOR UPDATE SKIP LOCKED + guarded UPDATE ran)",
299
+ Array.isArray(leased) && leased.length === 2);
300
+ // The ORDER BY priority desc in the candidate SELECT governs WHICH rows are
301
+ // claimed (the two highest-priority head-of-queue jobs), not the order of
302
+ // the returned array — the MySQL readback re-SELECT is not itself ordered
303
+ // (nor is RETURNING on PG/sqlite). Assert the claimed SET, and separately
304
+ // that the low-priority job is left behind (the priority guarantee).
305
+ var leasedJobs = leased.map(function (l) { return l.payload && l.payload.job; });
306
+ check("cluster-lease (mysql): the two HIGHEST-priority jobs were claimed (high + mid)",
307
+ leasedJobs.indexOf("high") !== -1 && leasedJobs.indexOf("mid") !== -1 &&
308
+ leasedJobs.indexOf("low") === -1);
309
+ check("cluster-lease (mysql): both sealed payloads round-tripped through unseal to objects",
310
+ leased[0].payload && typeof leased[0].payload.job === "string" &&
311
+ leased[1].payload && typeof leased[1].payload.job === "string");
312
+ check("cluster-lease (mysql): leased rows report attempts incremented to 1",
313
+ leased[0].attempts === 1 && leased[1].attempts === 1);
314
+ check("cluster-lease (mysql): the two leased rows flipped to inflight on the server",
315
+ _countMysql("`_blamejs_jobs`", "`queueName` = 'q-claim' AND `status` = 'inflight'") === 2);
316
+ check("cluster-lease (mysql): the un-leased low-priority job stays pending",
317
+ _countMysql("`_blamejs_jobs`", "`queueName` = 'q-claim' AND `status` = 'pending'") === 1);
318
+ check("cluster-lease (mysql): server-side attempts column incremented to 1",
319
+ Number(_jobCol(leased[0].jobId, "attempts")) === 1);
320
+
321
+ // stash the leased ids for the next sections
322
+ _proveEnqueueAndClusterLease._leased = leased;
323
+ }
324
+
325
+ // ======================================================================
326
+ // 2. complete() — SELECT-then-guarded-UPDATE pair, both dialect-routed.
327
+ // ======================================================================
328
+ async function _proveComplete(q) {
329
+ var leased = _proveEnqueueAndClusterLease._leased;
330
+ var high = leased[0];
331
+ var ok = await q.complete(high.jobId);
332
+ check("complete (mysql): inflight→done flip returned true", ok === true);
333
+ check("complete (mysql): status is done with finishedAt set on the server",
334
+ _jobCol(high.jobId, "status") === "done" && _jobCol(high.jobId, "finishedAt") !== null);
335
+
336
+ var again = await q.complete(high.jobId);
337
+ check("complete (mysql): a second complete() on the done row no-ops (status guard)",
338
+ again === false);
339
+ }
340
+
341
+ // ======================================================================
342
+ // 3. fail() — retry CASE branch (attempts < maxAttempts → pending) and the
343
+ // final-failure branch (→ failed, surfaced via dlqList with the sealed
344
+ // lastError decrypted). The CASE-expression UPDATE is the cross-dialect
345
+ // no-transaction fail path; prove it is MySQL-valid + correct.
346
+ // ======================================================================
347
+ async function _proveFailRetryThenFinalDlq(q) {
348
+ // --- retry branch: the 'mid' job (attempts 1, maxAttempts default 5) ---
349
+ var mid = _proveEnqueueAndClusterLease._leased[1];
350
+ var retried = await q.fail(mid.jobId, "transient blip", { retryDelayMs: 0 });
351
+ check("fail-retry (mysql): inflight→pending retry returned true", retried === true);
352
+ check("fail-retry (mysql): retried job is pending again (attempts 1 < max 5)",
353
+ _jobCol(mid.jobId, "status") === "pending");
354
+ check("fail-retry (mysql): lastError is sealed, not cleartext on the server",
355
+ _jobCol(mid.jobId, "lastError") !== null &&
356
+ !/transient blip/.test(String(_jobCol(mid.jobId, "lastError"))));
357
+
358
+ // the retried job + the still-pending low job are both leasable again
359
+ var released = await q.lease("q-claim", b.constants.TIME.seconds(30), 5);
360
+ check("fail-retry (mysql): the retried job re-enters the lease set",
361
+ released.length === 2);
362
+
363
+ // --- final-failure branch: a fresh queue, maxAttempts 1 ---
364
+ var FQ = "q-final";
365
+ await q.enqueue(FQ, { job: "doomed" }, { maxAttempts: 1 });
366
+ var fl = await q.lease(FQ, b.constants.TIME.seconds(30), 1);
367
+ check("fail-final (mysql): the doomed job leased (attempts→1 == maxAttempts)",
368
+ fl.length === 1 && fl[0].attempts === 1);
369
+ var finalFail = await q.fail(fl[0].jobId, "fatal error", { retryDelayMs: 0 });
370
+ check("fail-final (mysql): the exhausted job's fail returned true", finalFail === true);
371
+ check("fail-final (mysql): exhausted job moved to failed (DLQ) on the server",
372
+ _jobCol(fl[0].jobId, "status") === "failed" && _jobCol(fl[0].jobId, "finishedAt") !== null);
373
+ check("fail-final (mysql): dlqSize counts the failed job",
374
+ (await q.dlqSize(FQ)) === 1);
375
+ var dlq = await q.dlqList(FQ);
376
+ check("fail-final (mysql): dlqList unseals lastError back to cleartext",
377
+ dlq.length === 1 && dlq[0].lastError === "fatal error" &&
378
+ dlq[0].payload && dlq[0].payload.job === "doomed");
379
+ }
380
+
381
+ // ======================================================================
382
+ // 4. sweepExpired() — a lease whose expiry has passed returns to pending.
383
+ // Single dialect-routed UPDATE with a leaseExpiresAt < now guard.
384
+ // ======================================================================
385
+ async function _proveSweepExpired(q) {
386
+ var SQ = "q-sweep";
387
+ await q.enqueue(SQ, { job: "sweepable" });
388
+ var leased = await q.lease(SQ, 1, 1); // leaseExpiresAt = now + 1ms
389
+ check("sweep (mysql): job leased before sweep", leased.length === 1);
390
+ await helpers.passiveObserve(30, "queue-cluster (mysql): lease ms window elapses before sweep");
391
+ var swept = await q.sweepExpired();
392
+ check("sweep (mysql): sweepExpired re-queued at least the expired lease",
393
+ swept >= 1);
394
+ check("sweep (mysql): the swept job is pending again with leaseExpiresAt cleared",
395
+ _jobCol(leased[0].jobId, "status") === "pending" &&
396
+ _jobCol(leased[0].jobId, "leaseExpiresAt") === null);
397
+ }
398
+
399
+ // ======================================================================
400
+ // 5. a job scheduled in the future is NOT leasable (availableAt > now guard
401
+ // in the head-of-queue SELECT), but still counts toward size().
402
+ // ======================================================================
403
+ async function _proveDelayedNotLeased(q) {
404
+ var DQ = "q-delay";
405
+ await q.enqueue(DQ, { job: "later" }, { availableAt: Date.now() + b.constants.TIME.minutes(5) });
406
+ var leased = await q.lease(DQ, b.constants.TIME.seconds(30), 5);
407
+ check("delay (mysql): a future-availableAt job is not leased", leased.length === 0);
408
+ check("delay (mysql): the future job still counts toward size()",
409
+ (await q.size(DQ)) === 1);
410
+ }
411
+
412
+ module.exports = { run: run };
413
+
414
+ if (require.main === module) {
415
+ run().then(
416
+ function () { console.log("OK — " + helpers.getChecks() + " checks passed"); process.exit(0); },
417
+ function (e) { console.error("FAIL:", e.stack || e); process.exit(1); }
418
+ );
419
+ }