@blamejs/blamejs-shop 0.3.35 → 0.3.37

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.
@@ -88,6 +88,50 @@ async function run() {
88
88
  } catch (e) { badColMode = /mode must be/.test(e.message); }
89
89
  check("bad per-column mode throws at registerTable", badColMode);
90
90
 
91
+ // ---- computeNamespacedHash: pseudo-field indexed hash (FTS substrate) ----
92
+ // Wraps _computeDerivedHash for namespaces that have no registered
93
+ // derived-hash column (e.g. the sealed-token mail-store FTS index).
94
+ var ns = "bj-mail_messages-body:fts:";
95
+
96
+ // Default mode is salted-sha3 → byte-identical to the legacy hand-roll.
97
+ var saltHex = b.vault.getDerivedHashSalt().toString("hex");
98
+ var legacyRef = b.crypto.sha3Hash(saltHex + ns + "kubernetes");
99
+ var cnhDefault = b.cryptoField.computeNamespacedHash(ns, "kubernetes");
100
+ check("computeNamespacedHash default is salted-sha3 (128 hex)", cnhDefault.length === 128);
101
+ check("computeNamespacedHash default == legacy hand-roll", cnhDefault === legacyRef);
102
+
103
+ // truncateBytes slices the hex to that many bytes (2 hex chars each).
104
+ var cnhTrunc = b.cryptoField.computeNamespacedHash(ns, "kubernetes",
105
+ { mode: "salted-sha3", truncateBytes: 8 });
106
+ check("computeNamespacedHash truncateBytes:8 -> 16 hex", cnhTrunc.length === 16);
107
+ check("computeNamespacedHash truncate is a prefix of full", legacyRef.slice(0, 16) === cnhTrunc);
108
+
109
+ // Keyed mode differs from the salted default and is unforgeable
110
+ // without the MAC key.
111
+ var cnhKeyed = b.cryptoField.computeNamespacedHash(ns, "kubernetes",
112
+ { mode: "hmac-shake256", truncateBytes: 8 });
113
+ check("computeNamespacedHash hmac-shake256 -> 16 hex", /^[0-9a-f]{16}$/.test(cnhKeyed));
114
+ check("computeNamespacedHash keyed differs from salted", cnhKeyed !== cnhTrunc);
115
+ check("computeNamespacedHash is deterministic",
116
+ cnhKeyed === b.cryptoField.computeNamespacedHash(ns, "kubernetes",
117
+ { mode: "hmac-shake256", truncateBytes: 8 }));
118
+
119
+ // Config-time throws: bad mode, non-positive / non-integer truncateBytes.
120
+ var cnhBadMode = false;
121
+ try { b.cryptoField.computeNamespacedHash(ns, "x", { mode: "md5" }); }
122
+ catch (e) { cnhBadMode = /mode must be/.test(e.message); }
123
+ check("computeNamespacedHash bad mode throws", cnhBadMode);
124
+
125
+ var cnhBadTruncZero = false;
126
+ try { b.cryptoField.computeNamespacedHash(ns, "x", { truncateBytes: 0 }); }
127
+ catch (e) { cnhBadTruncZero = /truncateBytes must be/.test(e.message); }
128
+ check("computeNamespacedHash truncateBytes:0 throws", cnhBadTruncZero);
129
+
130
+ var cnhBadTruncFloat = false;
131
+ try { b.cryptoField.computeNamespacedHash(ns, "x", { truncateBytes: 2.5 }); }
132
+ catch (e) { cnhBadTruncFloat = /truncateBytes must be/.test(e.message); }
133
+ check("computeNamespacedHash non-integer truncateBytes throws", cnhBadTruncFloat);
134
+
91
135
  console.log("OK — crypto-field derived-hash tests");
92
136
  }
93
137
 
@@ -240,6 +240,212 @@ async function testQueryKeyMapping() {
240
240
  b.mailStore.fts.columnAndFieldFor("unknown") === null);
241
241
  }
242
242
 
243
+ // Compute the legacy (v1) salted-sha3-truncated token hash exactly the
244
+ // way the pre-keyed mail-store-fts.hashToken did: sha3Hash(saltHex + ns
245
+ // + token).slice(0, 16). Used to forge an old-format FTS index so the
246
+ // reindex-on-upgrade path has a stale index to rebuild.
247
+ function _legacyHashToken(table, field, token) {
248
+ if (typeof token !== "string" || token.length === 0) return "";
249
+ var ns = "bj-" + table + "-" + field + ":fts:";
250
+ var saltHex = b.vault.getDerivedHashSalt().toString("hex");
251
+ return b.crypto.sha3Hash(saltHex + ns + token).slice(0, 16);
252
+ }
253
+
254
+ function _legacyHashTokens(table, field, tokens) {
255
+ var seen = Object.create(null);
256
+ var out = [];
257
+ for (var i = 0; i < tokens.length; i += 1) {
258
+ var h = _legacyHashToken(table, field, tokens[i]);
259
+ if (!h || seen[h]) continue;
260
+ seen[h] = true;
261
+ out.push(h);
262
+ }
263
+ return out.join(" ");
264
+ }
265
+
266
+ // Rewrite the FTS index into the legacy (v1) format AND set the format
267
+ // marker stale, simulating an on-disk index written by a pre-keyed
268
+ // build. Reads the sealed messages table, unseals each row, retokenizes
269
+ // the plaintext, and reinserts under the legacy salted-sha3 hash.
270
+ // `markerValue` selects the stale marker: "1" (old format) or null
271
+ // (no marker row at all - pre-format-version store).
272
+ function _forgeLegacyIndex(db, prefix, table, markerValue) {
273
+ var ftsTable = '"' + prefix + '_messages_fts"';
274
+ var msgsTable = '"' + prefix + '_messages"';
275
+ var metaTable = '"' + prefix + '_meta"';
276
+ var rows = db.prepare("SELECT * FROM " + msgsTable).all();
277
+ db.prepare("DELETE FROM " + ftsTable).run();
278
+ var insert = db.prepare("INSERT INTO " + ftsTable +
279
+ " (objectid, subject_toks, addr_toks, body_toks) VALUES (?, ?, ?, ?)");
280
+ for (var i = 0; i < rows.length; i += 1) {
281
+ var clear = b.cryptoField.unsealRow(table, rows[i]);
282
+ var subjTokens = b.mailStore.fts.tokenize(clear.subject || "");
283
+ var addrTokens = b.mailStore.fts.tokenize(clear.from_addr || "")
284
+ .concat(b.mailStore.fts.tokenize(clear.to_addrs || ""));
285
+ var bodyTokens = b.mailStore.fts.tokenize(clear.body_text || "");
286
+ insert.run(
287
+ clear.objectid,
288
+ _legacyHashTokens(table, "subject", subjTokens),
289
+ _legacyHashTokens(table, "addr", addrTokens),
290
+ _legacyHashTokens(table, "body", bodyTokens)
291
+ );
292
+ }
293
+ if (markerValue === null) {
294
+ db.prepare("DELETE FROM " + metaTable + " WHERE key = ?").run("fts_format");
295
+ } else {
296
+ db.prepare("INSERT INTO " + metaTable + " (key, value) VALUES (?, ?) " +
297
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("fts_format", markerValue);
298
+ }
299
+ }
300
+
301
+ function _readMarker(db, prefix) {
302
+ var row = db.prepare('SELECT value FROM "' + prefix + '_meta" WHERE key = ?').get("fts_format");
303
+ return row ? row.value : null;
304
+ }
305
+
306
+ async function testFtsReindexOnUpgrade() {
307
+ // Forge an old-format (v1 salted-sha3) index, then construct a fresh
308
+ // store: create() must detect the stale marker, rebuild the index
309
+ // from the sealed messages table under the new keyed hash, find the
310
+ // seeded docs again, and advance the marker to the current format.
311
+ var fx = await _setupStore("reindex");
312
+ try {
313
+ var store = b.mailStore.create({ backend: fx.db });
314
+ store.appendMessage("INBOX", _msg("alice@example.com", "bob@example.com",
315
+ "Kubernetes deploy plan",
316
+ "We should deploy on kubernetes next monday."));
317
+ store.appendMessage("INBOX", _msg("carol@example.com", "bob@example.com", "Lunch?",
318
+ "Wanna grab lunch this week?"));
319
+
320
+ check("fresh store stamps current marker",
321
+ _readMarker(fx.db, fx.prefix) === String(b.mailStore.fts.FTS_FORMAT_VERSION));
322
+
323
+ // Rewrite the index into the legacy format + stale marker.
324
+ _forgeLegacyIndex(fx.db, fx.prefix, fx.prefix + "_messages", "1");
325
+ check("forged marker is stale", _readMarker(fx.db, fx.prefix) === "1");
326
+
327
+ // A new store handle against the same db triggers the reindex.
328
+ var store2 = b.mailStore.create({ backend: fx.db });
329
+
330
+ check("marker advanced to current after reindex",
331
+ _readMarker(fx.db, fx.prefix) === String(b.mailStore.fts.FTS_FORMAT_VERSION));
332
+
333
+ // Search now finds the seeded docs under the rebuilt keyed index.
334
+ var r1 = store2.search("INBOX", { text: "kubernetes" });
335
+ check("reindexed: text=kubernetes hits 1 row", r1.rows.length === 1);
336
+ check("reindexed: not flagged ftsUnavailable", r1.ftsUnavailable !== true);
337
+ var r2 = store2.search("INBOX", { text: "lunch" });
338
+ check("reindexed: text=lunch hits 1 row", r2.rows.length === 1);
339
+ var r3 = store2.search("INBOX", { from: "alice@example.com" });
340
+ check("reindexed: from=alice hits 1 row", r3.rows.length === 1);
341
+
342
+ // Idempotent - a third construction sees the current marker and does
343
+ // not rebuild (search still works).
344
+ var store3 = b.mailStore.create({ backend: fx.db });
345
+ var r4 = store3.search("INBOX", { text: "kubernetes" });
346
+ check("idempotent reindex: still 1 row", r4.rows.length === 1);
347
+ } finally { _teardown(fx); }
348
+ }
349
+
350
+ async function testFtsReindexRollsBackOnInterruption() {
351
+ // Inject a failure into the reindex INSERT LOOP: the Nth FTS insert
352
+ // throws. The whole rebuild must roll back, leaving the OLD index
353
+ // intact + queryable and the marker NOT advanced - a retriable state,
354
+ // never a silently half-built index.
355
+ var fx = await _setupStore("interrupt");
356
+ try {
357
+ var store = b.mailStore.create({ backend: fx.db });
358
+ store.appendMessage("INBOX", _msg("alice@example.com", "bob@example.com",
359
+ "Kubernetes deploy plan",
360
+ "We should deploy on kubernetes next monday."));
361
+ store.appendMessage("INBOX", _msg("carol@example.com", "bob@example.com",
362
+ "Picnic plan", "lets plan a picnic on saturday."));
363
+
364
+ var table = fx.prefix + "_messages";
365
+ _forgeLegacyIndex(fx.db, fx.prefix, table, "1");
366
+
367
+ // Snapshot the forged (old-format) index so we can prove it survives
368
+ // the rolled-back rebuild byte-for-byte.
369
+ var ftsTable = '"' + fx.prefix + '_messages_fts"';
370
+ var beforeRows = fx.db.prepare("SELECT objectid, subject_toks, addr_toks, body_toks FROM " +
371
+ ftsTable + " ORDER BY objectid").all();
372
+ check("forged index has 2 rows", beforeRows.length === 2);
373
+
374
+ // A pre-rebuild MATCH against the OLD keyed scheme must hit. Compute
375
+ // the legacy hash for "kubernetes" in the body namespace.
376
+ var legacyKube = _legacyHashToken(table, "body", "kubernetes");
377
+ var preHit = fx.db.prepare("SELECT objectid FROM " + ftsTable + " WHERE " +
378
+ ftsTable + " MATCH ?").all("body_toks:(" + legacyKube + ")");
379
+ check("old index queryable pre-interruption", preHit.length === 1);
380
+
381
+ // Wrap the backend so the SECOND FTS insert in the reindex loop
382
+ // throws. Every other call delegates to the real handle - this is a
383
+ // thin fault-injection shim around the live db, not a mock of the
384
+ // store logic.
385
+ var ftsInsertCalls = 0;
386
+ var wrapped = {
387
+ prepare: function (sql) {
388
+ var stmt = fx.db.prepare(sql);
389
+ if (/INSERT INTO .*_messages_fts/.test(sql)) {
390
+ return {
391
+ run: function () {
392
+ ftsInsertCalls += 1;
393
+ if (ftsInsertCalls === 2) {
394
+ throw new Error("injected backend failure on the 2nd FTS insert");
395
+ }
396
+ return stmt.run.apply(stmt, arguments);
397
+ },
398
+ get: function () { return stmt.get.apply(stmt, arguments); },
399
+ all: function () { return stmt.all.apply(stmt, arguments); },
400
+ };
401
+ }
402
+ return stmt;
403
+ },
404
+ };
405
+
406
+ var threw = false;
407
+ try {
408
+ b.mailStore.create({ backend: wrapped });
409
+ } catch (e) {
410
+ threw = true;
411
+ check("reindex failure surfaces as MailStoreError",
412
+ e && e.name === "MailStoreError");
413
+ }
414
+ check("interrupted reindex throws", threw);
415
+
416
+ // Marker did NOT advance to the current format - it's the sentinel
417
+ // (written before the DELETE), so a later create() retries.
418
+ check("marker did not advance to current",
419
+ _readMarker(fx.db, fx.prefix) !== String(b.mailStore.fts.FTS_FORMAT_VERSION));
420
+
421
+ // The OLD index survives the rollback intact, byte-for-byte.
422
+ var afterRows = fx.db.prepare("SELECT objectid, subject_toks, addr_toks, body_toks FROM " +
423
+ ftsTable + " ORDER BY objectid").all();
424
+ check("old index row count intact after rollback", afterRows.length === beforeRows.length);
425
+ var identical = afterRows.length === beforeRows.length;
426
+ for (var i = 0; i < afterRows.length && identical; i += 1) {
427
+ identical = afterRows[i].objectid === beforeRows[i].objectid &&
428
+ afterRows[i].subject_toks === beforeRows[i].subject_toks &&
429
+ afterRows[i].addr_toks === beforeRows[i].addr_toks &&
430
+ afterRows[i].body_toks === beforeRows[i].body_toks;
431
+ }
432
+ check("old index rows unchanged after rollback", identical);
433
+
434
+ // And the OLD index is still queryable under the legacy scheme.
435
+ var postHit = fx.db.prepare("SELECT objectid FROM " + ftsTable + " WHERE " +
436
+ ftsTable + " MATCH ?").all("body_toks:(" + legacyKube + ")");
437
+ check("old index still queryable after rollback", postHit.length === 1);
438
+
439
+ // Retriable: a clean create() against the real backend now completes
440
+ // the rebuild and advances the marker.
441
+ var store2 = b.mailStore.create({ backend: fx.db });
442
+ check("retry advances marker to current",
443
+ _readMarker(fx.db, fx.prefix) === String(b.mailStore.fts.FTS_FORMAT_VERSION));
444
+ var r = store2.search("INBOX", { text: "kubernetes" });
445
+ check("retry: search finds the doc", r.rows.length === 1);
446
+ } finally { _teardown(fx); }
447
+ }
448
+
243
449
  async function run() {
244
450
  // One-time vault init for the sync tokenizer + hash tests — the
245
451
  // hash routine reads vault.getDerivedHashSalt() so the salt MUST
@@ -263,6 +469,8 @@ async function run() {
263
469
  await testFtsRowIsSealed();
264
470
  await testSearchRespectsLimitCap();
265
471
  await testQueryKeyMapping();
472
+ await testFtsReindexOnUpgrade();
473
+ await testFtsReindexRollsBackOnInterruption();
266
474
 
267
475
  try { nodeFs.rmSync(bootDir, { recursive: true, force: true }); } catch (_e) {}
268
476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.35",
3
+ "version": "0.3.37",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {