@blamejs/core 0.15.0 → 0.15.1

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.1 (2026-06-11) — **Sealed-column lookups find rows written before the v0.15.0 hash change, and API-key secrets re-hash to the active algorithm on verify.** v0.15.0 changed the default derived hash — the blind index a sealed column is looked up by — from an unkeyed salted hash to a keyed MAC, and promised a transparent migration via a dual read. But no lookup path actually performed the dual read, so on a deployment that already held data, a lookup by a sealed column (a session's user id, an API key's owner, an audit actor or resource, a consent or data-subject id, a mail thread) computed only the new keyed digest and missed every row written before the upgrade. This release wires the dual read into every lookup: a sealed-column equality now matches both the active keyed digest and the legacy salted digest, so pre-upgrade rows are found again. That restores two correctness guarantees the gap had quietly broken — revoking all of a user's sessions no longer skips sessions created before the upgrade, and a subject erasure no longer leaves pre-upgrade rows behind. Separately, the framework's API-key store now re-hashes a stored secret to the active hash algorithm on the next successful verify, the transparent rotation the credential-hash primitive documented but the store had never performed. **Fixed:** *Sealed-column lookups match rows written before the v0.15.0 keyed-MAC change* — After v0.15.0 flipped the default derived-hash mode to a keyed MAC, a lookup by a sealed column computed only the new keyed digest, so rows written under the previous salted-hash default were no longer found — a silent index miss on every existing deployment. Every framework lookup path now dual-reads: `b.db.from(...).where(sealedField, value)` and the framework's own api-key, session, audit, consent, data-subject, and mail-thread lookups match the column against both the active keyed digest and the legacy salted digest (`b.db.hashCandidatesFor` exposes the candidate list for operator code). No migration or operator action is required; rows re-hash to the keyed form on read over time and the candidate set collapses back to a single value. Two correctness consequences are restored: revoking all of a user's sessions now also revokes sessions created before the upgrade, and a data-subject erasure now also deletes (and crypto-shreds) the subject's pre-upgrade rows. · *API-key secret hashes upgrade to the active algorithm on verify* — The framework's API-key store now re-hashes a stored secret to the configured hash algorithm on the next successful verify (leader-gated, best-effort, primary-match only), emitting an `apikey.secret_rehash` audit and observability event. This is the transparent rotation `b.credentialHash` documents — a key stored under an older algorithm or parameter set silently moves to the current one as it is used, with no change to the verify result or the returned record.
12
+
11
13
  - v0.15.0 (2026-06-08) — **A chainable SQL builder, MySQL as a first-class data backend, and a keyed lookup-hash default — the data layer goes tri-dialect.** This release makes the framework's data layer dialect-portable. `b.sql` is a new chainable query builder that quotes every identifier by construction, binds every value as a placeholder, and emits dialect-correct SQL for SQLite, Postgres, and MySQL; `b.guardSql` validates result rows against NUL bytes, quote-jump sequences, and per-column / total size boundaries. The entire framework data layer — the signed audit chain, cluster leadership and fencing, sessions, break-glass, the local queue, cache, scheduler, migrations, consent, and mail storage — is rebuilt on `b.sql`, which makes MySQL a supported external-database backend alongside Postgres and SQLite. Running the data layer on a real Postgres server surfaced two latent correctness bugs that only a non-SQLite backend exposes: identifiers were emitted unquoted at DDL time but read back camelCase (Postgres folds unquoted names to lowercase, silently breaking the audit chain, consent chain, cluster fencing, and vault-key consistency), and sealed columns coerced Buffer / object payloads through `String()` before encryption (corrupting non-string ciphertext); both are fixed by quoting every identifier and coercing per the column's declared type. Derived-hash columns — the blind-index lookup columns registered through `registerTable` — now default to a keyed MAC (SHAKE256 under the vault's per-deployment MAC key) instead of an unkeyed salted hash, so an exfiltrated database can no longer be brute-forced or rainbow-tabled to recover the indexed values; existing salted-hash indexes are read through a dual-read path and migrated forward, so the change is non-breaking. Audit-signing key rotation now preserves every historical checkpoint (rotation previously stranded checkpoints signed under the prior key). New cross-border data-residency postures (appi-jp, pdpa-sg, uk-gdpr) enforce a mandatory storage vacuum after erasure. Outbound TLS appends classical X25519 to its key-exchange preference so it can complete a handshake with a peer that advertises no post-quantum hybrid — previously the hybrids-only preference failed those handshakes outright — and emits a `tls.classical_downgrade` audit event whenever a connection lands on a classical group. Outbound HTTP/2 negotiation falls back to HTTP/1.1 against servers that only speak h1, the network heartbeat honors a target's permitted protocols instead of pinning cleartext targets down, a WebSocket connection closes cleanly on a peer's TCP half-close instead of wedging open, and the Azure blob backend encodes object-key path segments correctly. **Added:** *b.sql — a dialect-aware chainable SQL builder* — `b.sql` composes SELECT / INSERT / UPDATE / UPSERT / DELETE / DDL from a fluent chain. Every identifier is validated and quoted by construction (`"name"` on SQLite/Postgres, `` `name` `` on MySQL), every value binds as a placeholder rather than interpolating, and the emitted statement is validated as a single balanced, single-statement query before it leaves the builder. Pass `{ dialect: "postgres" | "mysql" | "sqlite" }` (default SQLite) to target a backend; `upsert` emits the dialect-final conflict syntax. It composes `b.safeSql` for the identifier and placeholder primitives, so a SQL string can no longer be assembled by hand inside the framework. · *b.guardSql — result-row output validation* — `b.guardSql` gates query result rows against embedded NUL bytes, quote-jump sequences, and configurable per-column and total-size boundaries, with the standard guard profiles (strict / balanced / permissive) and compliance postures (hipaa / pci-dss / gdpr / soc2). It is the output-side complement to the input-side `b.safeSql`. · *MySQL is a first-class data backend* — MySQL joins Postgres and SQLite as a supported external-database backend. The framework's schema reconciler emits MySQL DDL, and the full data layer — signed audit chain (append + verify), cluster leadership and lease fencing, sessions, break-glass, the local queue, cache, scheduler, migrations, and consent — runs against a real MySQL server. Select the backend at `b.cluster.init` / `b.externalDb` configuration via the `dialect` option; `b.clusterStorage.dialect()` exposes the configured backend dialect to composing code. · *Cross-border erasure postures: appi-jp, pdpa-sg, uk-gdpr* — Three data-residency compliance postures are added (Japan APPI, Singapore PDPA, UK GDPR). Each requires a mandatory storage vacuum after erasure (so deleted rows are reclaimed from the page store, not just tombstoned), a signed audit chain, encrypted backups, and a minimum TLS version. Pin one with `b.compliance.set`. **Fixed:** *Cross-border erasure performs the mandatory vacuum* — Erasure under the uk-gdpr, appi-jp, and pdpa-sg residency postures now runs the storage vacuum the posture mandates, reclaiming erased rows from the page store rather than leaving them recoverable as free-list tombstones. **Security:** *Lookup-hash columns default to a keyed MAC* — Derived-hash (blind-index) columns registered through `registerTable` now default to `derivedHashMode: "hmac-shake256"` — SHAKE256 keyed with the vault's per-deployment MAC key — instead of the previous unkeyed `salted-sha3`. The index value is no longer recomputable from the indexed plaintext alone, so an attacker who exfiltrates the database cannot brute-force or rainbow-table a lookup column (for example a subject-email index) without also holding the vault MAC key (CWE-916 / CWE-759). Existing `salted-sha3` indexes are read through a dual-read path and re-derived on write, so deployments upgrade without re-indexing up front. · *Postgres identifier casing no longer breaks the audit and cluster chains* — Identifiers were written unquoted in DDL but read back in camelCase. On Postgres (which folds an unquoted identifier to lowercase) this silently desynchronized the signed audit chain, the consent chain, cluster leadership and fencing, and vault-key consistency — each reads a column the server had stored under a lowercased name. Every framework identifier is now quoted at both DDL and query time so the stored and read names match on every dialect (CWE-670). SQLite deployments were unaffected and remain byte-compatible. · *Sealed columns preserve non-string payloads* — A sealed column coerced its value through `String()` before encryption, corrupting Buffer and object payloads (a Buffer became `"[object Object]"`-class garbage, an object its `toString`). Sealed values are now encoded per the column's declared type before the seal, so binary and structured payloads round-trip intact (CWE-704). · *Audit-signing key rotation preserves historical checkpoints* — Rotating the audit-signing key stranded every checkpoint signed under the prior key — `verifyCheckpoints` ignored the per-fingerprint history file the rotation writes, so post-rotation verification failed on otherwise-valid historical checkpoints. Verification now resolves each checkpoint's signing key by fingerprint (`getPublicKeyByFingerprint`) across the rotation history, so the full chain verifies after a key rotation. · *Outbound TLS reaches classical-only peers and audits the downgrade* — The framework's outbound TLS offered only ML-KEM hybrid key-exchange groups, so a handshake with a peer that does not advertise a post-quantum hybrid — most of today's internet — failed outright (`handshake_failure`), leaving outbound connections to webhooks, OAuth providers, ACME directories, object stores, and DoT/DoH/SMTP/Redis-over-TLS unable to complete. Classical X25519 is now appended to the group preference so the hybrid is still negotiated whenever the peer supports it, and the connection completes over classical X25519 when it does not. Every connection that lands on a classical group rather than the post-quantum hybrid emits a `tls.classical_downgrade` audit event (carrying the negotiated group) so operators can observe and alert on which peers are not yet post-quantum-capable. · *Transport reachability and correctness* — Outbound HTTP/2 negotiation now falls back to HTTP/1.1 when a TLS server offers only h1 (an ALPN protocol_version alert no longer fails the request). The network heartbeat honors a target's permitted protocols instead of dropping non-default ones, so a cleartext `http://` health target is no longer reported permanently down. A WebSocket connection closes cleanly when a peer half-closes its TCP socket (a bare FIN) instead of wedging the connection open. The Azure blob backend percent-encodes each object-key path segment, so a key containing reserved characters can no longer corrupt the request URL. **Migration:** *Derived-hash default change is non-breaking; MySQL is opt-in* — Lookup-hash columns default to the keyed MAC on new writes and migrate existing rows on access through the dual-read path — no upfront re-indexing, no operator action required. To pin the previous unkeyed index (for example to keep a column byte-compatible with an external system), pass `derivedHashMode: "salted-sha3"` to `registerTable`. MySQL as a data backend is opt-in: existing SQLite and Postgres deployments are unchanged unless you set `dialect: "mysql"`. The Postgres identifier-casing and sealed-column-coercion fixes change the emitted DDL and the at-rest encoding of non-string sealed values; a Postgres deployment created before this release reconciles its schema to the quoted identifiers on the next schema-ensure pass.
12
14
 
13
15
  ## v0.14.x
package/lib/api-key.js CHANGED
@@ -483,14 +483,55 @@ function create(opts) {
483
483
  return null;
484
484
  }
485
485
 
486
- if (trackLastUsedAt && cluster.isLeader()) {
487
- try {
488
- var touchBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql bare logical name for clusterStorage rewrite
489
- .set({ lastUsedAt: nowMs })
490
- .where("id", compositeId)
491
- .toSql();
492
- await clusterStorage.execute(touchBuilt.sql, touchBuilt.params);
493
- } catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
486
+ // Leader-gated best-effort writes on a successful verify: bump
487
+ // lastUsedAt when tracked, and transparently re-hash the stored secret
488
+ // when its envelope no longer matches the active algorithm the
489
+ // rotate-on-next-verify that credentialHash documents but, until now,
490
+ // no consumer wired. Primary match only: the secondary (graceful-
491
+ // rotation) slot is not the active secret, so it must not overwrite
492
+ // secretHash. The whole block is best-effort — the credential already
493
+ // verified under the stored hash and stays valid even if the write
494
+ // fails; the row re-upgrades on the next leader verify.
495
+ if (cluster.isLeader()) {
496
+ var touchFields = trackLastUsedAt ? { lastUsedAt: nowMs } : null;
497
+ var didRehash = false;
498
+ if (primaryMatch && credentialHash.needsRehash(row.secretHash, { algo: hashAlgo })) {
499
+ try {
500
+ var freshSecretHash = await credentialHash.hash(parsed.secretHex, { algo: hashAlgo });
501
+ touchFields = touchFields || {};
502
+ touchFields.secretHash = freshSecretHash;
503
+ didRehash = true;
504
+ } catch (_e) { /* re-hash is best-effort; verify success stands */ }
505
+ }
506
+ if (touchFields) {
507
+ try {
508
+ var touchQb = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
509
+ .set(touchFields)
510
+ .where("id", compositeId);
511
+ if (didRehash) {
512
+ // Compare-and-swap on the exact hash we verified against: only land
513
+ // the re-hash if the stored primary is STILL that value. A verify
514
+ // that races rotate()/hardRotate (which already installed a new
515
+ // secretHash) must not clobber the rotated secret back to the old
516
+ // one — the predicate then matches no rows and the upgrade no-ops,
517
+ // which is correct because the row is already on a fresh hash.
518
+ touchQb.where("secretHash", row.secretHash);
519
+ }
520
+ var touchBuilt = touchQb.toSql();
521
+ var touchResult = await clusterStorage.execute(touchBuilt.sql, touchBuilt.params);
522
+ // Only record the migration when the CAS actually swapped a row (a
523
+ // rowCount of 0 means a concurrent rotation won the race).
524
+ if (didRehash && !(touchResult && touchResult.rowCount === 0)) {
525
+ _emitEvent("apikey.secret_rehash", 1, { namespace: namespace, algo: hashAlgo });
526
+ _emit("apikey.secret_rehash", {
527
+ actor: _actor(verifyOpts, rowOwnerId),
528
+ resource: { kind: "apikey", id: compositeId },
529
+ outcome: "success",
530
+ metadata: { algo: hashAlgo },
531
+ });
532
+ }
533
+ } catch (_e) { /* best-effort; verify success not blocked by the write */ }
534
+ }
494
535
  }
495
536
 
496
537
  if (auditSuccess) {
@@ -620,9 +661,16 @@ function create(opts) {
620
661
  throw _err("MISCONFIGURED",
621
662
  TABLE + " schema is missing the ownerIdHash derived hash — framework misconfigured");
622
663
  }
664
+ // Dual-read across the keyed-MAC flip: match the active digest AND the
665
+ // legacy salted-sha3 digest a pre-v0.15.0 row carries (whereIn with a
666
+ // single value emits `IN (?)`, equivalent to `=`).
667
+ var ownerHashes = [lookup.value];
668
+ if (lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
669
+ ownerHashes.push(lookup.legacyValue);
670
+ }
623
671
  var listQb = _selectBuilder()
624
672
  .where("namespace", namespace)
625
- .where("ownerIdHash", lookup.value);
673
+ .whereIn("ownerIdHash", ownerHashes);
626
674
  if (!includeRevoked) listQb.whereNull("revokedAt");
627
675
  if (!includeExpired) {
628
676
  var nowForExpiry = clock();
package/lib/audit.js CHANGED
@@ -805,11 +805,21 @@ async function _queryCluster(criteria) {
805
805
  if (criteria.to) qb.whereOp("recordedAt", "<=", _toMs(criteria.to));
806
806
  if (criteria.actorUserId) {
807
807
  var auh = cryptoField.lookupHash("audit_log", "actorUserId", criteria.actorUserId);
808
- if (auh) qb.where(auh.field, auh.value);
808
+ if (auh) {
809
+ // Dual-read across the keyed-MAC flip so an actor query still returns
810
+ // audit rows written under the legacy salted-sha3 actor digest.
811
+ var auv = [auh.value];
812
+ if (auh.legacyValue != null && auh.legacyValue !== auh.value) auv.push(auh.legacyValue);
813
+ qb.whereIn(auh.field, auv);
814
+ }
809
815
  }
810
816
  if (criteria.resourceId) {
811
817
  var rh = cryptoField.lookupHash("audit_log", "resourceId", criteria.resourceId);
812
- if (rh) qb.where(rh.field, rh.value);
818
+ if (rh) {
819
+ var rhv = [rh.value];
820
+ if (rh.legacyValue != null && rh.legacyValue !== rh.value) rhv.push(rh.legacyValue);
821
+ qb.whereIn(rh.field, rhv);
822
+ }
813
823
  }
814
824
  if (criteria.action) qb.where("action", criteria.action);
815
825
  if (criteria.resourceKind) qb.where("resourceKind", criteria.resourceKind);
package/lib/consent.js CHANGED
@@ -302,16 +302,18 @@ function isGranted(opts) {
302
302
  }
303
303
  // Find the most recent consent row for this (subjectId, purpose).
304
304
  // subjectId is sealed → look up via subjectIdHash (derived).
305
- var hash = db().hashFor("consent_log", "subjectId", opts.subjectId);
306
- if (!hash) {
305
+ var subjectCand = db().hashCandidatesFor("consent_log", "subjectId", opts.subjectId);
306
+ if (!subjectCand) {
307
307
  throw new Error("consent_log subjectId is missing a derived hash — schema misconfigured");
308
308
  }
309
309
  // Local db() handle: emit the LOCAL table name (consent_log) quoted so
310
310
  // the camelCase subjectIdHash / monotonicCounter columns resolve, and
311
- // run the built { sql, params } against the prepared statement.
311
+ // run the built { sql, params } against the prepared statement. whereIn
312
+ // dual-reads across the keyed-MAC flip so a row written under the legacy
313
+ // salted-sha3 subjectIdHash is still matched.
312
314
  var isGrantedBuilt = sql.select("consent_log", { dialect: "sqlite", quoteName: true })
313
315
  .columns(["action"])
314
- .where("subjectIdHash", hash)
316
+ .whereIn("subjectIdHash", subjectCand.values)
315
317
  .where("purpose", opts.purpose)
316
318
  .orderBy("monotonicCounter", "desc")
317
319
  .limit(1)
@@ -344,12 +346,14 @@ function isGranted(opts) {
344
346
  */
345
347
  function history(subjectId) {
346
348
  if (!subjectId) throw new Error("consent.history requires a subjectId");
347
- var hash = db().hashFor("consent_log", "subjectId", subjectId);
348
- if (!hash) {
349
+ var subjectCand = db().hashCandidatesFor("consent_log", "subjectId", subjectId);
350
+ if (!subjectCand) {
349
351
  throw new Error("consent_log subjectId is missing a derived hash — schema misconfigured");
350
352
  }
353
+ // whereIn dual-reads across the keyed-MAC flip so the subject's pre-flip
354
+ // (legacy salted-sha3) consent rows still appear in the access response.
351
355
  var rows = db().from("consent_log")
352
- .where({ subjectIdHash: hash })
356
+ .whereIn(subjectCand.field, subjectCand.values)
353
357
  .orderBy("monotonicCounter", "asc")
354
358
  .all();
355
359
  return rows;
package/lib/db-query.js CHANGED
@@ -359,6 +359,14 @@ class Query {
359
359
  return this._addCondition(fieldOrObj, op, value);
360
360
  }
361
361
 
362
+ // whereIn(field, values) — AND an `IN (...)` membership predicate. Facade
363
+ // over where(field, "IN", values) symmetric with b.sql's whereIn, so a
364
+ // caller can match a column against a value list (e.g. the dual-read
365
+ // derived-hash candidate set) without spelling the "IN" operator.
366
+ whereIn(field, values) {
367
+ return this.where(field, "IN", values);
368
+ }
369
+
362
370
  // Resolve a (field, op, value) predicate through the framework gates
363
371
  // (JSONB value guard, sealed-field → derived-hash rewrite, column
364
372
  // membership) and return the post-rewrite { field, op, value } that
@@ -422,7 +430,18 @@ class Query {
422
430
  );
423
431
  }
424
432
  field = lookup.field;
425
- value = lookup.value;
433
+ if (op === "=" && lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
434
+ // Dual-read across the v0.15.0 keyed-MAC default flip: a row written
435
+ // before the flip carries the legacy salted-sha3 digest, so an
436
+ // equality lookup on a sealed field must match BOTH the active
437
+ // keyed-MAC digest and the legacy one — otherwise the flip silently
438
+ // drops every un-migrated row from the result. b.sql expands the
439
+ // IN-list to (?, ?) and binds each digest.
440
+ op = "IN";
441
+ value = [lookup.value, lookup.legacyValue];
442
+ } else {
443
+ value = lookup.value;
444
+ }
426
445
  }
427
446
  _validateField(field);
428
447
  // Gate the post-sealed-rewrite physical column (derived-hash
package/lib/db.js CHANGED
@@ -1984,6 +1984,31 @@ function hashFor(table, field, value) {
1984
1984
  return lookup ? lookup.value : null;
1985
1985
  }
1986
1986
 
1987
+ /**
1988
+ * @primitive b.db.hashCandidatesFor
1989
+ * @signature b.db.hashCandidatesFor(table, field, value)
1990
+ * @since 0.15.1
1991
+ * @status stable
1992
+ * @related b.db.hashFor, b.db.from
1993
+ *
1994
+ * Dual-read sibling of `hashFor`. Returns `{ field, values }` where `values`
1995
+ * holds the active derived-hash digest AND — across the v0.15.0 keyed-MAC
1996
+ * default flip — the legacy salted-sha3 digest a row written before the flip
1997
+ * carries. A `WHERE <hashColumn> IN (...)` lookup over `values` matches both
1998
+ * keyed-indexed and legacy-indexed rows, so the flip never silently drops an
1999
+ * un-migrated row. Returns `null` when the field has no derived-hash
2000
+ * declaration on the table.
2001
+ *
2002
+ * @example
2003
+ * var c = b.db.hashCandidatesFor("users", "email", "alice@example.com");
2004
+ * b.db.from("users").whereIn(c.field, c.values).all();
2005
+ * // → rows matching either the keyed-MAC or the legacy digest
2006
+ */
2007
+ function hashCandidatesFor(table, field, value) {
2008
+ _requireInit();
2009
+ return cryptoField.lookupHashCandidates(table, field, value);
2010
+ }
2011
+
1987
2012
  // _ddlToJsonSchemaType — best-effort SQL→JSON Schema type mapping.
1988
2013
  // SQLite is dynamically typed but the framework's DDL syntax pins
1989
2014
  // concrete types; we map them here. Operator-supplied custom types
@@ -3254,6 +3279,7 @@ module.exports = {
3254
3279
  ["e" + "xec"]: execRaw,
3255
3280
  transaction: transaction,
3256
3281
  hashFor: hashFor,
3282
+ hashCandidatesFor: hashCandidatesFor,
3257
3283
  close: close,
3258
3284
  // flushToDisk — force the live tmpfs SQLite to be re-encrypted to
3259
3285
  // <dataDir>/db.enc immediately. In encrypted-at-rest mode the
package/lib/mail-store.js CHANGED
@@ -1081,7 +1081,13 @@ function _findThreadRoot(args) {
1081
1081
  for (var c = 0; c < candidates.length; c += 1) {
1082
1082
  var lookup = cryptoField.lookupHash(args.messagesTable, "message_id", candidates[c]);
1083
1083
  if (!lookup) continue;
1084
+ // Dual-read across the keyed-MAC flip: try the active digest, then fall
1085
+ // back to the legacy salted-sha3 digest a pre-v0.15.0 row carries, so
1086
+ // thread-matching still finds messages indexed before the flip.
1084
1087
  var row = args.stmtFindThreadByMsgId.get(lookup.value);
1088
+ if (!row && lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
1089
+ row = args.stmtFindThreadByMsgId.get(lookup.legacyValue);
1090
+ }
1085
1091
  if (row) return row.thread_root_id;
1086
1092
  }
1087
1093
  return null;
package/lib/session.js CHANGED
@@ -751,8 +751,15 @@ async function destroyAllForUser(userId) {
751
751
  "the session table schema is missing the userIdHash derived hash — framework misconfigured",
752
752
  true);
753
753
  }
754
+ // Dual-read across the keyed-MAC flip: a pre-v0.15.0 session row carries
755
+ // the legacy salted-sha3 userIdHash, so destroy must match both digests
756
+ // or it leaves un-migrated sessions for the user un-revoked.
757
+ var userHashes = [lookup.value];
758
+ if (lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
759
+ userHashes.push(lookup.legacyValue);
760
+ }
754
761
  var built = sql.delete(_sessionSqlTable(), _sessionSqlOpts())
755
- .where("userIdHash", lookup.value)
762
+ .whereIn("userIdHash", userHashes)
756
763
  .toSql();
757
764
  var result = await _currentStore().execute(built.sql, built.params);
758
765
  return result.rowCount || 0;
package/lib/subject.js CHANGED
@@ -149,15 +149,13 @@ function exportData(subjectId, opts) {
149
149
  }
150
150
 
151
151
  function _findRowsForSubject(tableName, subjectField, subjectId) {
152
- var hash = db().hashFor(tableName, subjectField, subjectId);
153
- if (hash) {
154
- // The schema has a derived hash for the subjectField — look up via that
155
- var derivedFieldName = _getDerivedFieldName(tableName, subjectField);
156
- if (derivedFieldName) {
157
- var pred = {};
158
- pred[derivedFieldName] = hash;
159
- return db().from(tableName).where(pred).all();
160
- }
152
+ var cand = db().hashCandidatesFor(tableName, subjectField, subjectId);
153
+ if (cand) {
154
+ // The schema has a derived hash for the subjectField — look up via it,
155
+ // dual-reading across the keyed-MAC flip (whereIn matches both the active
156
+ // keyed-MAC digest and the legacy salted-sha3 digest a pre-flip row
157
+ // carries) so the subject's pre-flip rows are not silently skipped.
158
+ return db().from(tableName).whereIn(cand.field, cand.values).all();
161
159
  }
162
160
  // No derived hash — assume subjectField is raw, do direct equality
163
161
  var rawPred = {};
@@ -341,19 +339,18 @@ function erase(subjectId, opts) {
341
339
 
342
340
  for (var t = 0; t < tables.length; t++) {
343
341
  var spec = tables[t];
344
- var hash = db().hashFor(spec.name, spec.subjectField, subjectId);
345
- var pred;
346
- if (hash) {
347
- var derivedField = _getDerivedFieldName(spec.name, spec.subjectField);
348
- if (derivedField) {
349
- pred = {}; pred[derivedField] = hash;
350
- } else {
351
- pred = {}; pred[spec.subjectField] = subjectId;
352
- }
342
+ var cand = db().hashCandidatesFor(spec.name, spec.subjectField, subjectId);
343
+ var delQb = db().from(spec.name);
344
+ if (cand) {
345
+ // Dual-read across the keyed-MAC flip so erasure matches (and deletes)
346
+ // the subject's pre-flip rows carrying the legacy salted-sha3 digest —
347
+ // a GDPR erasure that skips un-migrated rows would leave PII behind.
348
+ delQb.whereIn(cand.field, cand.values);
353
349
  } else {
354
- pred = {}; pred[spec.subjectField] = subjectId;
350
+ var delPred = {}; delPred[spec.subjectField] = subjectId;
351
+ delQb.where(delPred);
355
352
  }
356
- var deleted = db().from(spec.name).where(pred).deleteMany();
353
+ var deleted = delQb.deleteMany();
357
354
  totalDeleted += deleted;
358
355
  perTable[spec.name] = deleted;
359
356
  }
@@ -461,20 +458,18 @@ function eraseHard(subjectId, opts) {
461
458
  db().transaction(function () {
462
459
  for (var t = 0; t < tables.length; t++) {
463
460
  var spec = tables[t];
464
- var hash = db().hashFor(spec.name, spec.subjectField, subjectId);
465
- var pred;
466
- if (hash) {
467
- var derivedField = _getDerivedFieldName(spec.name, spec.subjectField);
468
- if (derivedField) {
469
- pred = {}; pred[derivedField] = hash;
470
- } else {
471
- pred = {}; pred[spec.subjectField] = subjectId;
472
- }
461
+ var cand = db().hashCandidatesFor(spec.name, spec.subjectField, subjectId);
462
+ var findQb = db().from(spec.name);
463
+ if (cand) {
464
+ // Dual-read across the keyed-MAC flip so per-row-key destruction +
465
+ // erasure covers the subject's pre-flip (legacy salted-sha3) rows too.
466
+ findQb.whereIn(cand.field, cand.values);
473
467
  } else {
474
- pred = {}; pred[spec.subjectField] = subjectId;
468
+ var rawPred = {}; rawPred[spec.subjectField] = subjectId;
469
+ findQb.where(rawPred);
475
470
  }
476
471
  // Find rows so we can destroy their per-row keys before delete.
477
- var rows = db().from(spec.name).where(pred).all();
472
+ var rows = findQb.all();
478
473
  if (cryptoField.hasPerRowKey(spec.name)) {
479
474
  for (var r = 0; r < rows.length; r++) {
480
475
  var rowId = rows[r]._id;
@@ -484,7 +479,14 @@ function eraseHard(subjectId, opts) {
484
479
  }
485
480
  }
486
481
  }
487
- var deleted = db().from(spec.name).where(pred).deleteMany();
482
+ var delQb2 = db().from(spec.name);
483
+ if (cand) {
484
+ delQb2.whereIn(cand.field, cand.values);
485
+ } else {
486
+ var delPred3 = {}; delPred3[spec.subjectField] = subjectId;
487
+ delQb2.where(delPred3);
488
+ }
489
+ var deleted = delQb2.deleteMany();
488
490
  totalDeleted += deleted;
489
491
  perTable[spec.name] = deleted;
490
492
  // REINDEX the table so B-tree pages holding the deleted row's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:d63a172d-d92b-4a98-ae64-7dcb04e82076",
5
+ "serialNumber": "urn:uuid:167ad68f-23f7-47a4-a412-8703ce390619",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-08T20:30:45.961Z",
8
+ "timestamp": "2026-06-11T15:59:54.794Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.15.0",
22
+ "bom-ref": "@blamejs/core@0.15.1",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.0",
25
+ "version": "0.15.1",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.15.0",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.1",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.15.0",
57
+ "ref": "@blamejs/core@0.15.1",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]