@blamejs/core 0.6.20 → 0.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **0.6.21** (2026-05-02) — closes the medium / low audit findings flagged after v0.6.18. **Cluster cache `invalidateTag`** now actually works — the cluster backend gained a `_blamejs_cache_tags` junction table (`(cacheKey, tag)` PK + index on `tag`), tag-aware `set` / `del` / `clear` / `_sweep`, plus `getTags(key)`. The old NOT_SUPPORTED-on-cluster path is gone; multi-tag rotation, mid-flight tag replacement on update, and namespace-scoped sweeps all covered. **Multi-column cursor pagination** — `b.pagination.cursor({ orderBy: [{column,direction},...] })` accepts a string (single column), an array of strings (multi, all using `opts.direction`), or an array of `{column,direction}` objects (mixed directions). The keyset WHERE expands to the standard OR cascade so successive pages can't skip or repeat rows when ties on the leading columns are broken by trailing ones. `_id` is appended as a tiebreaker if not in the chain. The Query class also gained chained `orderBy(col, dir)` calls — second-and-later calls extend a multi-column ORDER BY in the SQL. Cursor format is bumped to encode `{ orderKey, vals, forward }` instead of the old `{ orderBy, dir, orderByVal, id, forward }` — pre-1.0 break, no compat shim. **DoH POST mode** (RFC 8484 §4.1) — `b.network.dns.useDnsOverHttps({ method })` accepts `"GET" | "POST" | undefined` (auto). Auto switches to POST when the GET URL would exceed 2048 bytes (long DNS names). **DoT connection pooling** — per-`(host:port)` cached TLS socket with a 2-minute idle timeout, serialized in-flight queries per socket. Eliminates the per-query handshake. **INI parser** shipped as `b.parsers.ini` — covers Windows .ini / .gitconfig / systemd-unit / php.ini / tox.ini shape: sections (incl. `[parent.child]` / `[parent "child"]` nesting), `;` and `#` comments (inline + leading), single + double quoting with `\n` `\t` `\\` `\"` `\'` escapes, boolean coercion (`true`/`false`/`yes`/`no`/`on`/`off`), decimal + hex integers + floats. Prototype-pollution defense (`__proto__` / `constructor` / `prototype` rejected). Duplicate-key policy throws by default; `onDuplicate: "first" | "last"` opts in to silent shadowing. Section / per-section key / value-bytes caps configurable. **Cookie-jar file persistence** — `b.httpClient.cookieJar.create({ persist: "file", file: "/abs/path", vault: b.vault })` loads at construct, debounce-flushes on every set/clear, plus `flush()` and `close()` for explicit lifecycle. With `vault`, on-disk bytes are sealed; without, plaintext JSON (operator chooses). **Comment cleanups per rule §4** — `vault/index.js` stale `// later` removed, `mail.js` "future patch" wording rewritten as scope, `bundler.js` "What it does NOT do today (deliberately deferred)" rewritten as "Out of scope", `archive.js` "v1 scope cuts (deferred)" rewritten as "Out of scope", `framework-schema.js` "next release" wording rewritten to describe what's actually shipped, `http-client.js` "out of reach today" / "we'll plumb it through when h3 lands" rewritten as scope statements. **README CLI section** updated to reflect the v0.6.17 + v0.6.18 + v0.6.19 + v0.6.20 additions (security / config-drift / file-type / password / erase / retention) — was missing 6 subcommands.
11
12
  - **0.6.20** (2026-05-02) — CI / packaging fix-up. The npm-publish workflow's "Attach SBOM as release asset" step started failing with HTTP 422 ("Cannot upload assets to an immutable release") because the operator's manual `gh release create` had already been published when the workflow ran. Two changes: (1) `sbom.cyclonedx.json` is now bundled into the npm tarball (`files` block in package.json), so `npm install @blamejs/core && cat node_modules/@blamejs/core/sbom.cyclonedx.json` is the canonical SBOM access path. The prepack guard's known-allowed list covers the just-in-time generation. (2) The workflow's GH-release-attach step is now non-fatal: it tries to upload, logs a warning if the release is immutable, and lets the publish proceed regardless. The npm tarball is the load-bearing artifact; the GitHub release attachment was only ever supplementary. `.gitignore` adds `sbom.cyclonedx.json` so a stray local `npm sbom` doesn't pollute the repo.
12
13
  - **0.6.19** (2026-05-02) — closes the critical + high gaps surfaced by the v0.6.18 audit. **Critical**: `b.network.ntp.nts.querySingle` now actually verifies the server reply with the s2cKey — extracts the AUTHENTICATOR_AND_ENC extension, AEAD-decrypts with AAD = bytes-before-authenticator, fails closed (`nts/auth-failed` / `nts/no-authenticator`) when verification fails. Server-supplied new cookies in the encrypted plaintext are appended to the cookie pool and the consumed cookie popped (real RFC 8915 cookie rotation). Previously the function returned `authenticated: true` while only checking the unique-identifier echo — any MITM that mirrored the request's 32-byte unique field could spoof timestamps. **High**: `azure-blob.presignedUploadPolicy` now throws `PRESIGN_NOT_SUPPORTED` instead of silently returning a SAS PUT URL when operators asked for POST policy semantics — Azure SAS has no body-size cap and the previous shape was a misleading mismatch (operator error message points at presignedUploadUrl + post-upload HEAD as the alternative). `b.auth.password.policy` now ships the SecLists top-10000 common-password list bundled (CC-BY-3.0, `lib/vendor/common-passwords-top-10000.txt`), loaded lazily on first `policy.check()` call; `password` / `dragon` / `qwerty` / etc. now reject with `policy/forbidden-common`; `useBundledCommon: false` per-policy bypasses if operator ships their own list. `b.network.tls` adds `removeCa(fingerprint256)` / `removeCaByLabel(label)` / `clearAll()` / `purgeExpired()` / `expiringSoon(windowMs)` so operators can rotate corp DPI CAs without process restart; every removal audits with subject + fingerprint + reason. `b.network.dns.setResultOrder("ipv6first")` now flips the order on the DoH / DoT dual-stack fallback paths too (was only sorting OS-resolver results). `b.network.dns.resolve4` / `resolve6` / `resolveAaaa` now use real DNS-protocol queries (`dns.promises.resolve4` / `_dohLookup` / `_dotLookup`) instead of aliasing `lookup()` — operator semantics now match Node's standard library (skips `/etc/hosts`, mDNS). `b.network.socket.setDefaultLinger` removed from the silent no-op path; now throws `socket/linger-not-supported` with operator guidance to use `socket.destroy()` (abort) vs `socket.end()` (graceful) since Node's public `net.Socket` has no `setLinger()`. **Internal**: every `new XxxError(...)` call across `lib/network*.js` was passing args in the wrong order (message-then-code instead of code-then-message), making `e.code` return human messages and `e.message` return slash-codes — operators relying on `e.code` for error handling got the wrong field. All 50+ throws fixed across `lib/network.js` / `network-dns.js` / `network-proxy.js` / `network-tls.js` / `network-heartbeat.js` / `network-nts.js`. **CI**: `.gitleaks.toml` adds `test/smoke.js` to the path allowlist and pins the historical commit + fingerprint that tripped the jwt rule on a `REDACTED` placeholder; `npm-publish.yml` job permissions bumped from `contents: read` to `contents: write` so `gh release upload sbom.cyclonedx.json` no longer 403s.
13
14
  - **0.6.18** (2026-05-02) — `b.network` primitive — single namespace for runtime-configurable network behaviour. `b.network.ntp` adds tunable warn / fatal drift thresholds, env-var bindings (`BLAMEJS_NTP_SERVERS` / `BLAMEJS_NTP_TIMEOUT_MS` / `BLAMEJS_NTP_DRIFT_WARN_MS` / `BLAMEJS_NTP_DRIFT_FATAL_MS`), and authenticated-time support: `b.network.ntp.nts.query(opts)` performs an NTS-KE handshake (RFC 8915) over TLS 1.3 with the framework's PQC-hybrid group preference, extracts C2S / S2C keys via the standardized TLS exporter, and authenticates NTPv4 packets with AES-SIV-CMAC-256 (mandatory-to-implement, in-house) or AEAD-CHACHA20-POLY1305 — no extra vendored deps. `b.network.dns` exposes operator-pinned resolvers, IPv4 / IPv6 / dual-stack family selection, ipv4first / verbatim / ipv6first ordering, DNS lookup timeout (Node's native `dns.lookup` has none), in-memory positive + negative cache, and DoH / DoT (cloudflare / google / quad9 / custom URL) — `b.ssrfGuard` and `b.httpClient` route through it when configured. `b.network.proxy` honours `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` / `ALL_PROXY` (lower- and upper-case) with CIDR + suffix + wildcard `NO_PROXY` matching, basic-auth via `BLAMEJS_PROXY_AUTH`, CONNECT tunnels for HTTPS through HTTP proxies; `b.httpClient` picks up the agent automatically. `b.network.tls` is a runtime-overridable trust store: `addCa(pemOrPath)` / `addCaBundle(path)` / `useSystemTrust()` / `getTrustStore()` for deep-packet-inspection deploys behind Zscaler / Netskope / corporate Squid + custom CA — Node's `NODE_EXTRA_CA_CERTS` only works at boot; this primitive accepts adds at any time and `b.pqcAgent` picks them up immediately. Every `addCa` audits with subject + issuer + fingerprint256 + validity + isSelfSigned. `b.security.assertProduction({ allowDpiTrust })` refuses to boot in production with installed CAs unless explicitly allowed. `b.network.heartbeat` adds application-level liveness probes for upstream services (HTTP / TCP / NTP probe types, healthy → degraded → down state machine with consecutive-failure threshold, audit on state change, observability counters per probe). `b.network.socket` gives operator-tunable defaults for `TCP_NODELAY` / `SO_KEEPALIVE` / `SO_LINGER`. `b.network.bootFromEnv()` reads every supported env var at startup and applies in the right order so configuration takes effect before the first outbound socket; the wiki app's docker-compose configs ship every knob with a default-empty value (production overlay tightens DNS lookup timeout + cache TTL + `NTP_STRICT=1` + `SOCKET_NO_DELAY=1`). `.gitleaks.toml` was previously gitignored by the deny-all-dotfiles allowlist; it is now allowlisted so CI's secret-scan job loads the framework's allowlist and stops failing on `.gitleaks.toml: no such file or directory`. New wiki page `/network-config` documents all six sub-primitives.
package/README.md CHANGED
@@ -71,18 +71,26 @@ Full primitive-by-primitive docs live at [blamejs.com](https://blamejs.com), whi
71
71
  `blamejs` ships an operator-facing CLI for the recurring ops work. Each subcommand boots a headless app instance from `--data-dir` (no HTTP listener), runs the operation, and shuts down. Same vault + DB + audit chain the running app uses.
72
72
 
73
73
  ```
74
- blamejs migrate up | down | status --db <path> [--dir <path>]
75
- blamejs seed run | status --db <path> --env <name> [--dir <path>]
76
- blamejs vault status | seal | unseal | rotate --data-dir <path>
77
- blamejs backup inspect | verify | extract --bundle <dir>
78
- blamejs api-key issue | revoke | list | rotate | verify --data-dir <path> --namespace <ns>
79
- blamejs mtls status | show-cert | init | issue | issue-p12 --data-dir <path>
80
- blamejs audit archive | export | verify-bundle | purge
81
- blamejs api-snapshot capture | compare --file <path>
82
- blamejs dev --command <cmd> [--watch <dir>...]
74
+ blamejs migrate up | down | status --db <path> [--dir <path>]
75
+ blamejs seed run | status --db <path> --env <name> [--dir <path>]
76
+ blamejs dev --command <cmd> [--watch <dir>...]
77
+ blamejs api-snapshot capture | compare --file <path>
78
+ blamejs api-key issue | revoke | list | rotate | verify --data-dir <path> --namespace <ns>
79
+ blamejs audit archive | export | verify | purge --data-dir <path>
80
+ blamejs backup inspect | verify | extract --bundle <path>
81
+ blamejs mtls status | show-cert | init | issue | issue-p12 --data-dir <path>
82
+ blamejs vault status | seal | unseal | rotate --data-dir <path>
83
+ blamejs security assert --data-dir <path>
84
+ blamejs config-drift inspect | verify --data-dir <path>
85
+ blamejs file-type detect <file> [--allowlist image,pdf,...]
86
+ blamejs password check --plaintext "..." [--profile pci-4.0|nist-aal2|hipaa-aal2] [--breach-check] [--email <e>] [--username <u>]
87
+ blamejs erase --table <t> --row-id <id> --confirm --data-dir <path>
88
+ blamejs retention preview | run --data-dir <path> --table <t> --age-field <col> --ttl-ms <n> [--action soft-delete|delete|erase] [--soft-delete-field <col>]
89
+ blamejs version
90
+ blamejs help [<command>]
83
91
  ```
84
92
 
85
- Pass `--help` to any subcommand for the full flag list. Passphrases for crypto-backed operations resolve from the appropriate env var (`BLAMEJS_VAULT_PASSPHRASE`, `BLAMEJS_BACKUP_PASSPHRASE`, `BLAMEJS_AUDIT_PASSPHRASE`) so they don't end up in shell history.
93
+ Pass `--help` to any subcommand for the full flag list (`blamejs api-key --help` etc.). Passphrases for crypto-backed operations resolve from the appropriate env var (`BLAMEJS_VAULT_PASSPHRASE`, `BLAMEJS_BACKUP_PASSPHRASE`, `BLAMEJS_AUDIT_PASSPHRASE`) so they don't end up in shell history.
86
94
 
87
95
  ## Reference app + deployment
88
96
 
package/lib/archive.js CHANGED
@@ -21,16 +21,17 @@
21
21
  * - UTF-8 file names (sets the EFS bit per APPNOTE 6.3.4)
22
22
  * - Modification time defaults to "now"; operators override per file
23
23
  *
24
- * v1 scope cuts (deferred):
25
- * - ZIP64 (>4 GiB archives, >65535 files) — operators with that
26
- * scale bring their own
24
+ * Out of scope:
25
+ * - ZIP64 (>4 GiB archives, >65535 files) — operators at that scale
26
+ * bring their own toolset
27
27
  * - Encryption — `b.crypto.encryptPacked` produces a sealed bundle
28
28
  * for the operator's encryption-at-rest needs; ZIP-native
29
29
  * password encryption is broken-by-design
30
- * - Streaming write (toStream) — toBuffer() is enough for the
31
- * "download my data" shape; operators streaming gigabytes
32
- * have a different toolset
33
- * - Reading / extraction — write-only for now
30
+ * - Streaming write (toStream) — toBuffer() covers the "download my
31
+ * data" shape; operators streaming gigabytes use the operator-side
32
+ * toolset
33
+ * - Reading / extraction — write-only; operators use node:zlib +
34
+ * yauzl (or unzip in shell) for read paths
34
35
  */
35
36
  var zlib = require("node:zlib");
36
37
  var fs = require("node:fs");
package/lib/bundler.js CHANGED
@@ -2,24 +2,24 @@
2
2
  /**
3
3
  * bundler — content-hashed asset pipeline + manifest.
4
4
  *
5
- * What this primitive does today:
5
+ * What this primitive does:
6
6
  * - Reads each named entry from disk
7
7
  * - Computes a content hash (SHA3-512, first 16 hex chars)
8
8
  * - Writes the entry to outdir/<name>.<hash>.<ext> for cache-busting
9
9
  * - Emits manifest.json mapping logical name → hashed filename
10
10
  * - Optionally watches entries and rebuilds on change
11
11
  *
12
- * What it does NOT do today (deliberately deferred):
12
+ * Out of scope (operators bring their own tool when they need these):
13
13
  * - Module-graph resolution (esbuild-style multi-file bundling)
14
14
  * - Tree shaking, dead-code elimination, AST transforms
15
15
  * - Source maps
16
- * - Minification (a regex-based pass mishandles comments inside
17
- * string and regex literals; AST-based minification waits for the
18
- * esbuild-wasm vendoring slice)
16
+ * - Minification (an AST-based pass would need a vendored ESM parser;
17
+ * the framework's `b.bundler` is the cache-bust + manifest layer,
18
+ * not a full bundler)
19
19
  *
20
- * Operators with multi-file ESM source either pre-concat manually and
21
- * point bundler at the result, or use an external tool today; the
22
- * bundler primitive's interface stays stable when full bundling lands.
20
+ * Operators with multi-file ESM source pre-concat manually and point
21
+ * bundler at the result, or use an external bundler that emits the
22
+ * concatenated entry `b.bundler` then hashes + manifests the output.
23
23
  *
24
24
  * var bundler = b.bundler.create({
25
25
  * entries: { app: "./public/js/app.js", styles: "./public/css/app.css" },
package/lib/cache.js CHANGED
@@ -74,11 +74,6 @@
74
74
  *
75
75
  * What is NOT in the box:
76
76
  *
77
- * - Tag invalidation on the cluster backend — invalidating tagged
78
- * entries across cluster nodes ties to a future distributed-pubsub
79
- * slice. invalidateTag against a cluster-backend cache throws
80
- * NOT_SUPPORTED today; operators wanting cluster-scope tag wipe
81
- * run their own DELETE against _blamejs_cache.
82
77
  * - maxBytes on the cluster backend — per-row size accounting against
83
78
  * a shared table would mean an aggregate query on every set. The
84
79
  * operator controls cluster-table size with their own pruning if
@@ -509,28 +504,98 @@ function _clusterBackend(cfg) {
509
504
  catch (_e) { return undefined; }
510
505
  }
511
506
 
512
- async function set(key, value, expiresAt) {
507
+ async function set(key, value, expiresAt, meta) {
513
508
  var json = JSON.stringify(value);
514
509
  var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
515
510
  var now = clock();
511
+ var ck = _composedKey(key);
516
512
  // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
517
513
  await clusterStorage.execute(
518
514
  "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
519
515
  "VALUES (?, ?, ?, ?) " +
520
516
  "ON CONFLICT (cacheKey) DO UPDATE SET " +
521
517
  "valueJson = ?, expiresAt = ?, updatedAt = ?",
522
- [_composedKey(key), json, storedExpires, now, json, storedExpires, now]
518
+ [ck, json, storedExpires, now, json, storedExpires, now]
519
+ );
520
+ // Tag handling: drop any prior tags for this key (tags can change
521
+ // across sets), then INSERT the new ones. The PRIMARY KEY on
522
+ // (cacheKey, tag) makes the INSERT idempotent if duplicate tags
523
+ // sneak in.
524
+ var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
525
+ await clusterStorage.execute(
526
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
527
+ [ck]
523
528
  );
529
+ if (tags && tags.length > 0) {
530
+ for (var i = 0; i < tags.length; i++) {
531
+ await clusterStorage.execute(
532
+ "INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
533
+ "ON CONFLICT (cacheKey, tag) DO NOTHING",
534
+ [ck, tags[i]]
535
+ );
536
+ }
537
+ }
524
538
  }
525
539
 
526
540
  async function del(key) {
541
+ var ck = _composedKey(key);
527
542
  var result = await clusterStorage.execute(
528
543
  "DELETE FROM _blamejs_cache WHERE cacheKey = ?",
529
- [_composedKey(key)]
544
+ [ck]
530
545
  );
546
+ // Drop any matching tag rows. Best-effort: a stale tag row pointing
547
+ // at a non-existent cacheKey is dropped on the next invalidateTag
548
+ // sweep (by the JOIN-shape DELETE) anyway.
549
+ await clusterStorage.execute(
550
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
551
+ [ck]
552
+ ).catch(function () { /* best-effort */ });
531
553
  return !!(result && result.rowCount && result.rowCount > 0);
532
554
  }
533
555
 
556
+ async function invalidateTag(tag) {
557
+ // Find every cacheKey carrying the tag (namespace-scoped via the LIKE
558
+ // on the composed key), delete from the cache table + the junction.
559
+ var like = namespace + ":%";
560
+ var keysResult = await clusterStorage.execute(
561
+ "SELECT cacheKey FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
562
+ [tag, like]
563
+ );
564
+ var keys = (keysResult && keysResult.rows) || [];
565
+ if (keys.length === 0) {
566
+ // Nothing to invalidate; still drop any orphan tag rows for
567
+ // this tag scoped to our namespace.
568
+ await clusterStorage.execute(
569
+ "DELETE FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
570
+ [tag, like]
571
+ );
572
+ return 0;
573
+ }
574
+ var purged = 0;
575
+ for (var i = 0; i < keys.length; i++) {
576
+ var ck = keys[i].cacheKey;
577
+ var r = await clusterStorage.execute(
578
+ "DELETE FROM _blamejs_cache WHERE cacheKey = ?",
579
+ [ck]
580
+ );
581
+ if (r && r.rowCount > 0) purged += r.rowCount;
582
+ await clusterStorage.execute(
583
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
584
+ [ck]
585
+ );
586
+ }
587
+ return purged;
588
+ }
589
+
590
+ async function getTags(key) {
591
+ var result = await clusterStorage.execute(
592
+ "SELECT tag FROM _blamejs_cache_tags WHERE cacheKey = ?",
593
+ [_composedKey(key)]
594
+ );
595
+ if (!result || !result.rows) return [];
596
+ return result.rows.map(function (r) { return r.tag; });
597
+ }
598
+
534
599
  async function has(key) {
535
600
  // Existence check without recency bump — cluster backend doesn't
536
601
  // track LRU at all, so "without bumping" is automatic. Honors
@@ -551,6 +616,11 @@ function _clusterBackend(cfg) {
551
616
  "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ?",
552
617
  [like]
553
618
  );
619
+ // Drop matching tag rows in the same namespace.
620
+ await clusterStorage.execute(
621
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey LIKE ?",
622
+ [like]
623
+ ).catch(function () { /* best-effort */ });
554
624
  return (result && result.rowCount) || 0;
555
625
  }
556
626
 
@@ -568,10 +638,24 @@ function _clusterBackend(cfg) {
568
638
  async function _sweep() {
569
639
  var now = clock();
570
640
  var like = namespace + ":%";
641
+ // Capture the to-be-purged keys first so we can drop matching tag
642
+ // rows in the same sweep — keeps the junction table free of orphans
643
+ // pointing at expired cacheKeys.
644
+ var expiredResult = await clusterStorage.execute(
645
+ "SELECT cacheKey FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
646
+ [like, now]
647
+ );
648
+ var expiredKeys = (expiredResult && expiredResult.rows) || [];
571
649
  await clusterStorage.execute(
572
650
  "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
573
651
  [like, now]
574
652
  );
653
+ for (var i = 0; i < expiredKeys.length; i++) {
654
+ await clusterStorage.execute(
655
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
656
+ [expiredKeys[i].cacheKey]
657
+ ).catch(function () { /* best-effort */ });
658
+ }
575
659
  }
576
660
 
577
661
  function _startSweep(intervalMs) {
@@ -583,15 +667,17 @@ function _clusterBackend(cfg) {
583
667
  }
584
668
 
585
669
  return {
586
- name: "cluster",
587
- get: get,
588
- set: set,
589
- del: del,
590
- has: has,
591
- clear: clear,
592
- size: size,
593
- close: close,
594
- _startSweep: _startSweep,
670
+ name: "cluster",
671
+ get: get,
672
+ set: set,
673
+ del: del,
674
+ has: has,
675
+ clear: clear,
676
+ size: size,
677
+ close: close,
678
+ invalidateTag: invalidateTag,
679
+ getTags: getTags,
680
+ _startSweep: _startSweep,
595
681
  };
596
682
  }
597
683
 
@@ -865,9 +951,8 @@ function create(opts) {
865
951
  if (typeof backend.invalidateTag !== "function") {
866
952
  throw _err("NOT_SUPPORTED",
867
953
  "cache.invalidateTag: backend '" + (backend.name || "custom") +
868
- "' does not support tag invalidation. Tags are memory-backend only " +
869
- "in this release; the cluster backend gains tag support alongside " +
870
- "the distributed-invalidation slice.");
954
+ "' does not implement invalidateTag. Operator-supplied custom backends " +
955
+ "must export invalidateTag(tag) number to participate in tag-based wipes.");
871
956
  }
872
957
  var purged;
873
958
  try { purged = await backend.invalidateTag(tag); }
package/lib/db-query.js CHANGED
@@ -148,7 +148,21 @@ class Query {
148
148
  if (direction !== "asc" && direction !== "desc") {
149
149
  throw new Error("orderBy direction must be 'asc' or 'desc'");
150
150
  }
151
- this._orderBy = { field: field, direction: direction.toUpperCase() };
151
+ var entry = { field: field, direction: direction.toUpperCase() };
152
+ if (this._orderBy === null) {
153
+ // First call — keep the back-compat single-object shape so any
154
+ // legacy reader that does `query._orderBy.field` keeps working.
155
+ this._orderBy = entry;
156
+ return this;
157
+ }
158
+ // Second-or-later call — promote to an array. Multi-column ORDER BY
159
+ // is the keyset-pagination tiebreaker pattern: ORDER BY createdAt
160
+ // DESC, _id DESC means same-second rows still have a total order.
161
+ if (Array.isArray(this._orderBy)) {
162
+ this._orderBy.push(entry);
163
+ } else {
164
+ this._orderBy = [this._orderBy, entry];
165
+ }
152
166
  return this;
153
167
  }
154
168
 
@@ -173,7 +187,12 @@ class Query {
173
187
  _orderLimitOffset() {
174
188
  var s = "";
175
189
  if (this._orderBy) {
176
- s += ' ORDER BY "' + this._orderBy.field + '" ' + this._orderBy.direction;
190
+ var entries = Array.isArray(this._orderBy) ? this._orderBy : [this._orderBy];
191
+ var fragments = [];
192
+ for (var i = 0; i < entries.length; i++) {
193
+ fragments.push('"' + entries[i].field + '" ' + entries[i].direction);
194
+ }
195
+ s += " ORDER BY " + fragments.join(", ");
177
196
  }
178
197
  if (this._limit !== null) s += " LIMIT " + this._limit;
179
198
  if (this._offset !== null) s += " OFFSET " + this._offset;
package/lib/db.js CHANGED
@@ -399,6 +399,21 @@ var FRAMEWORK_SCHEMA = [
399
399
  indexes: ["expiresAt"],
400
400
  sealedFields: [],
401
401
  },
402
+ {
403
+ // _blamejs_cache_tags — junction table for tag→cacheKey lookup
404
+ // backing b.cache.invalidateTag(t) on the cluster backend. Composite
405
+ // PK (cacheKey, tag) lets one cacheKey carry many tags; index on
406
+ // tag makes invalidation a single indexed scan. Cleared together
407
+ // with the matching _blamejs_cache rows on del / clear / sweep.
408
+ name: "_blamejs_cache_tags",
409
+ columns: {
410
+ cacheKey: "TEXT NOT NULL",
411
+ tag: "TEXT NOT NULL",
412
+ },
413
+ primaryKey: ["cacheKey", "tag"],
414
+ indexes: ["tag"],
415
+ sealedFields: [],
416
+ },
402
417
  {
403
418
  // _blamejs_seeders — registry of applied seed files for the
404
419
  // b.seeders primitive (lib/seeders.js). Composite PK (env, name)
@@ -16,20 +16,26 @@
16
16
  * _blamejs_audit_log external-db name
17
17
  *
18
18
  * The mapping is exposed via tableName(local) so write-dispatch code
19
- * (next release) can use a single name reference.
19
+ * (cluster-storage.js) uses a single name reference and the
20
+ * dialect-aware ensureSchema fans out the DDL to either the local-
21
+ * SQLite (db.js's FRAMEWORK_SCHEMA) or the external-db backend.
20
22
  *
21
23
  * Dialects: Postgres + SQLite. Both support CREATE TABLE IF NOT EXISTS,
22
24
  * CREATE INDEX IF NOT EXISTS, and the same column types modulo
23
- * INTEGER/BIGINT and BLOB/BYTEA differences. MySQL is not yet
24
- * supported — operators on MySQL must wait for that adapter.
25
+ * INTEGER/BIGINT and BLOB/BYTEA differences. MySQL is not currently
26
+ * supported — operators on MySQL must use one of the supported
27
+ * dialects until a MySQL adapter ships.
25
28
  *
26
29
  * What ensureSchema does NOT do:
27
30
  * - Migrate existing audit_log rows from local SQLite into external-db.
28
31
  * That migration belongs to a separate operator-driven tool.
29
32
  * - Verify chain integrity in external-db. That happens at boot via
30
- * the audit module's regular verify() path once dispatch lands.
31
- * - Install append-only triggers. Trigger syntax differs across
32
- * dialects and is being deferred to the next release.
33
+ * the audit module's regular verify() path on every read.
34
+ * - Install append-only triggers. The framework's tamper-evidence
35
+ * model is the audit chain's hash linkage + SLH-DSA-signed
36
+ * checkpoints — triggers would add a defense-in-depth layer
37
+ * but they're not load-bearing for the threat model. Operators
38
+ * who want triggers add them per their dialect's syntax.
33
39
  *
34
40
  * Public API:
35
41
  * await frameworkSchema.ensureSchema({ externalDbBackend, dialect })
@@ -114,6 +120,11 @@ var LOCAL_TO_EXTERNAL = Object.freeze({
114
120
  // values, BIGINT expiresAt for ttl. Indexed on expiresAt for the
115
121
  // periodic prune query.
116
122
  _blamejs_cache: "_blamejs_cache",
123
+ // _blamejs_cache_tags — junction table for tag-based cache
124
+ // invalidation on the cluster backend. Composite PK
125
+ // (cacheKey, tag) lets a single cacheKey carry many tags;
126
+ // index on tag makes invalidateTag(t) a single indexed scan.
127
+ _blamejs_cache_tags: "_blamejs_cache_tags",
117
128
  // _blamejs_seeders — registry of applied seed files for b.seeders
118
129
  // (lib/seeders.js). Composite PK (env, name) lets the same filename
119
130
  // apply per env. Mirrors the local-SQLite shape in db.js
@@ -561,6 +572,26 @@ function _cacheDDL(dialect) {
561
572
  };
562
573
  }
563
574
 
575
+ // _blamejs_cache_tags — tag→cacheKey junction for cluster-backend
576
+ // tag invalidation. b.cache.invalidateTag(t) finds matching cacheKeys
577
+ // via the indexed `tag` column, deletes them from _blamejs_cache, and
578
+ // drops the junction rows. Cleared on cache.clear() and del() too.
579
+ function _cacheTagsDDL(_dialect) {
580
+ // Junction table is TEXT-only — no dialect-specific INT / BLOB needed.
581
+ var name = LOCAL_TO_EXTERNAL._blamejs_cache_tags;
582
+ return {
583
+ create:
584
+ "CREATE TABLE IF NOT EXISTS " + name + " (" +
585
+ " cacheKey TEXT NOT NULL," +
586
+ " tag TEXT NOT NULL," +
587
+ " PRIMARY KEY (cacheKey, tag)" +
588
+ ")",
589
+ indexes: [
590
+ "CREATE INDEX IF NOT EXISTS idx_" + name + "_tag ON " + name + " (tag)",
591
+ ],
592
+ };
593
+ }
594
+
564
595
  // _blamejs_break_glass_policies — column-level break-glass policy
565
596
  // registry. One row per (table) declares which columns are
566
597
  // glass-locked + the operator's grant rules. Sealed columns hide
@@ -658,6 +689,7 @@ async function ensureSchema(opts) {
658
689
  _sessionsDDL(dialect),
659
690
  _jobsDDL(dialect),
660
691
  _cacheDDL(dialect),
692
+ _cacheTagsDDL(dialect),
661
693
  _seedersDDL(dialect),
662
694
  _seedersLockDDL(dialect),
663
695
  _breakGlassPoliciesDDL(dialect),
@@ -6,24 +6,41 @@
6
6
  * (login → list → mutate → logout, OAuth code-exchange → userinfo, etc.)
7
7
  * carry the right Cookie header without operators threading it by hand.
8
8
  * RFC 6265 attribute coverage: Domain / Path / Expires / Max-Age /
9
- * HttpOnly / Secure / SameSite. Public Suffix List awareness is
10
- * deliberately deferred — operators wiring jars against trusted
11
- * domains don't need it; re-open if a real cross-eTLD bug surfaces.
9
+ * HttpOnly / Secure / SameSite. Public Suffix List (PSL) awareness is
10
+ * out of scope — operators wiring jars against trusted upstream
11
+ * domains don't need it; for cross-eTLD safety in untrusted contexts,
12
+ * use a per-domain jar and validate the host against an allowlist.
12
13
  *
13
14
  * var jar = b.httpClient.cookieJar.create(); // in-memory
14
15
  * await b.httpClient.request({ url: loginUrl, method: "POST", body, jar });
15
16
  * await b.httpClient.request({ url: meUrl, jar }); // session cookie attaches
16
17
  *
17
- * Vault-encrypted persistence — every cookie value is sealed via
18
- * b.vault.seal before it lands in the jar's store, so a memory dump or
19
- * core file doesn't expose plaintext values:
18
+ * Three persistence modes:
20
19
  *
21
- * var jar = b.httpClient.cookieJar.create({ persist: "vault", vault: b.vault });
20
+ * memory — in-process Map. Restart loses everything.
21
+ * vault — every cookie value is sealed via b.vault.seal before it
22
+ * lands in the in-process Map, so a memory dump or core
23
+ * file doesn't expose plaintext values:
22
24
  *
23
- * The jar's lifecycle is per-process — restart loses cookies regardless
24
- * of persist mode. File-backed and cluster-shared persistence are
25
- * deferred; operators wanting durability serialize via getAll() and
26
- * restore via setFromSerialized() at boot.
25
+ * b.httpClient.cookieJar.create({ persist: "vault", vault: b.vault })
26
+ *
27
+ * file — on-disk persistence at opts.file (absolute path). Loaded
28
+ * at create() if the file exists; flushed (debounced via
29
+ * opts.flushDebounceMs, default 100ms) on every set / clear
30
+ * / setFromSerialized. Pass `vault` alongside `file` to
31
+ * seal the on-disk bytes; without vault the file is
32
+ * plaintext JSON (operator chose the threat model).
33
+ *
34
+ * b.httpClient.cookieJar.create({
35
+ * persist: "file",
36
+ * file: "/var/lib/myapp/jar.json",
37
+ * vault: b.vault, // optional but recommended
38
+ * })
39
+ *
40
+ * The file mode survives process restart. Cluster-shared persistence
41
+ * (multiple nodes sharing one jar) is out of scope; operators with
42
+ * that need wire a custom jar via the same shape as the returned
43
+ * object (setFromResponse / cookieHeaderFor / getAll / etc.).
27
44
  *
28
45
  * Outbound filtering follows RFC 6265 §5.4:
29
46
  * - Domain: exact-host match by default; Domain attribute allows
@@ -43,6 +60,8 @@
43
60
  * }
44
61
  */
45
62
 
63
+ var fs = require("node:fs");
64
+ var path = require("node:path");
46
65
  var C = require("./constants");
47
66
  var safeUrl = require("./safe-url");
48
67
  var { defineClass } = require("./framework-error");
@@ -51,10 +70,11 @@ var CookieJarError = defineClass("CookieJarError", { alwaysPermanent: true });
51
70
  var _err = CookieJarError.factory;
52
71
 
53
72
  var DEFAULTS = Object.freeze({
54
- persist: "memory",
73
+ persist: "memory",
74
+ flushDebounceMs: 100,
55
75
  });
56
76
 
57
- var VALID_PERSIST = new Set(["memory", "vault"]);
77
+ var VALID_PERSIST = new Set(["memory", "vault", "file"]);
58
78
  var VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
59
79
 
60
80
  // ---- Set-Cookie parser ----
@@ -130,7 +150,7 @@ function create(opts) {
130
150
  opts = opts || {};
131
151
  var persist = opts.persist === undefined ? DEFAULTS.persist : opts.persist;
132
152
  if (!VALID_PERSIST.has(persist)) {
133
- throw _err("BAD_OPT", "cookieJar.create: persist must be 'memory' or 'vault', got " +
153
+ throw _err("BAD_OPT", "cookieJar.create: persist must be 'memory' | 'vault' | 'file', got " +
134
154
  JSON.stringify(persist));
135
155
  }
136
156
  var vault = opts.vault || null;
@@ -140,6 +160,22 @@ function create(opts) {
140
160
  "cookieJar.create: persist: 'vault' requires opts.vault with seal/unseal (pass b.vault)");
141
161
  }
142
162
  }
163
+ var filePath = null;
164
+ if (persist === "file") {
165
+ if (typeof opts.file !== "string" || opts.file.length === 0) {
166
+ throw _err("BAD_OPT",
167
+ "cookieJar.create: persist: 'file' requires opts.file (absolute path)");
168
+ }
169
+ filePath = opts.file;
170
+ // Refuse relative paths so a process running in a different cwd
171
+ // doesn't accidentally serialize to a sibling directory.
172
+ if (!path.isAbsolute(filePath)) {
173
+ throw _err("BAD_OPT",
174
+ "cookieJar.create: opts.file must be an absolute path, got " + JSON.stringify(filePath));
175
+ }
176
+ }
177
+ var flushDebounceMs = (typeof opts.flushDebounceMs === "number" && opts.flushDebounceMs >= 0)
178
+ ? opts.flushDebounceMs : DEFAULTS.flushDebounceMs;
143
179
  var clock = typeof opts.clock === "function" ? opts.clock : Date.now;
144
180
 
145
181
  // Storage map keyed by `<domain>|<path>|<name>` so a (domain, path)
@@ -377,14 +413,78 @@ function create(opts) {
377
413
  return rows;
378
414
  }
379
415
 
416
+ // ---- File persistence ----
417
+ // When persist === "file", load on construct + flush on every write
418
+ // (debounced). On-disk format is JSON of getAll() output. If a vault
419
+ // is also passed, the file is sealed via vault.seal so the on-disk
420
+ // bytes are encrypted at rest; otherwise plaintext (operator chose
421
+ // the threat model by passing or omitting vault).
422
+ var flushTimer = null;
423
+ function _flushSync() {
424
+ if (!filePath) return;
425
+ var rows = getAll();
426
+ var serialized = JSON.stringify(rows);
427
+ var blob = vault ? vault.seal(serialized) : serialized;
428
+ fs.writeFileSync(filePath, blob);
429
+ }
430
+ function _scheduleFlush() {
431
+ if (!filePath) return;
432
+ if (flushTimer) return;
433
+ flushTimer = setTimeout(function () {
434
+ flushTimer = null;
435
+ try { _flushSync(); } catch (_e) { /* operator can call flush() to retry */ }
436
+ }, flushDebounceMs);
437
+ if (flushTimer.unref) flushTimer.unref();
438
+ }
439
+ function flush() {
440
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
441
+ _flushSync();
442
+ }
443
+ function close() {
444
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
445
+ if (filePath) try { _flushSync(); } catch (_e) { /* best-effort */ }
446
+ }
447
+
448
+ // Wrap mutating entrypoints so each write schedules a flush. The
449
+ // wrappers go on the returned object — the underlying function
450
+ // declarations stay intact so other internal callers reach them.
451
+ var setFromResponseAndFlush = function (reqUrl, hdr) {
452
+ setFromResponse(reqUrl, hdr); _scheduleFlush();
453
+ };
454
+ var clearAndFlush = function (filter) {
455
+ var n = clear(filter); _scheduleFlush(); return n;
456
+ };
457
+ var setFromSerializedAndFlush = function (rows) {
458
+ setFromSerialized(rows); _scheduleFlush();
459
+ };
460
+
461
+ // Initial load from file. Missing file is fine (first run).
462
+ if (filePath && fs.existsSync(filePath)) {
463
+ try {
464
+ var raw = fs.readFileSync(filePath, "utf8");
465
+ var serialized = vault ? vault.unseal(raw) : raw;
466
+ if (serialized && serialized.length > 0) {
467
+ var rows = JSON.parse(serialized);
468
+ setFromSerialized(rows);
469
+ }
470
+ } catch (e) {
471
+ throw _err("LOAD_FAILED",
472
+ "cookieJar.create: failed to load persist file '" + filePath + "': " +
473
+ (e.message || String(e)));
474
+ }
475
+ }
476
+
380
477
  return {
381
- setFromResponse: setFromResponse,
478
+ setFromResponse: filePath ? setFromResponseAndFlush : setFromResponse,
382
479
  cookieHeaderFor: cookieHeaderFor,
383
480
  getAll: getAll,
384
- clear: clear,
481
+ clear: filePath ? clearAndFlush : clear,
385
482
  size: size,
386
- setFromSerialized: setFromSerialized,
483
+ setFromSerialized: filePath ? setFromSerializedAndFlush : setFromSerialized,
484
+ flush: flush,
485
+ close: close,
387
486
  persist: persist,
487
+ file: filePath,
388
488
  _storeForTest: _storeForTest,
389
489
  };
390
490
  }