@blamejs/blamejs-shop 0.2.15 → 0.2.18

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 (52) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/README.md +2 -2
  3. package/lib/admin.js +81 -3
  4. package/lib/asset-manifest.json +5 -1
  5. package/lib/customers.js +71 -4
  6. package/lib/storefront.js +462 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  11. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  12. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  15. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  16. package/lib/vendor/blamejs/lib/cache.js +184 -23
  17. package/lib/vendor/blamejs/lib/cert.js +68 -11
  18. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  19. package/lib/vendor/blamejs/lib/db.js +205 -15
  20. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  21. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  22. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  23. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  24. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  25. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  26. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  27. package/lib/vendor/blamejs/lib/subject.js +8 -1
  28. package/lib/vendor/blamejs/package.json +1 -1
  29. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  38. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  39. package/lib/vendor/blamejs/test/20-db.js +191 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  49. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  52. package/package.json +1 -1
@@ -301,27 +301,27 @@ function create(opts) {
301
301
 
302
302
  async function cancel(args) {
303
303
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "cancel: args required");
304
- var record = await _load(args.grantId);
305
- if (!record) return { error: "grant-not-found", grantId: args.grantId };
306
- if (record.consumedAt !== null) return { error: "grant-already-consumed", grantId: record.grantId };
307
- if (record.revokedAt !== null) return { error: "grant-revoked", grantId: record.grantId };
308
- if (record.cancelledAt !== null) return { error: "grant-already-cancelled", grantId: record.grantId };
309
304
  var actorId = _actorIdOf(args.cancelledBy);
310
- if (actorId !== record.requestedBy) {
311
- // Cancellation by anyone other than the requester is a revoke,
312
- // not a cancel. Surface explicitly.
313
- return { error: "only-requester-can-cancel", grantId: record.grantId,
314
- requestedBy: record.requestedBy };
315
- }
316
- record.cancelledAt = Date.now();
317
- record.cancelledReason = args.reason || null;
318
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
319
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
305
+ var outcome = await cache.update(_key(args.grantId), function (record) {
306
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
307
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
308
+ if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId } } };
309
+ if (record.cancelledAt !== null) return { abort: { response: { error: "grant-already-cancelled", grantId: record.grantId } } };
310
+ // Cancellation by anyone other than the requester is a revoke, not a
311
+ // cancel — surface explicitly.
312
+ if (actorId !== record.requestedBy) {
313
+ return { abort: { response: { error: "only-requester-can-cancel", grantId: record.grantId, requestedBy: record.requestedBy } } };
314
+ }
315
+ record.cancelledAt = Date.now();
316
+ record.cancelledReason = args.reason || null;
317
+ return { value: record, expiresAt: record.expiresAt };
318
+ }, { ttlMs: ttlMs });
319
+ if (outcome.aborted) return outcome.aborted.response;
320
+ var rec = outcome.value;
320
321
  _emit("dual.grant.cancelled",
321
- { grantId: record.grantId, action: record.action,
322
- cancelledBy: actorId, reason: args.reason || null },
322
+ { grantId: rec.grantId, action: rec.action, cancelledBy: actorId, reason: args.reason || null },
323
323
  "success", args.req);
324
- return { grantId: record.grantId, status: "cancelled" };
324
+ return { grantId: rec.grantId, status: "cancelled" };
325
325
  }
326
326
 
327
327
  async function _load(grantId) {
@@ -334,156 +334,152 @@ function create(opts) {
334
334
 
335
335
  async function approve(args) {
336
336
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "approve: args required");
337
- var record = await _load(args.grantId);
338
- if (!record) {
339
- return { error: "grant-not-found", grantId: args.grantId };
340
- }
341
- if (record.consumedAt !== null) {
342
- return { error: "grant-already-consumed", grantId: record.grantId };
343
- }
344
- if (record.revokedAt !== null) {
345
- return { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason };
346
- }
347
- if (record.cancelledAt !== null) {
348
- return { error: "grant-cancelled", grantId: record.grantId };
349
- }
350
- if (record.expiresAt < Date.now()) {
351
- _emit("dual.grant.expired", { grantId: record.grantId, action: record.action },
352
- "failure", args.req);
353
- await cache.del(_key(record.grantId));
354
- return { error: "grant-expired", grantId: record.grantId };
355
- }
356
337
  var approverId = _actorIdOf(args.approver);
357
338
  if (!approverId) throw _err("BAD_ARG", "approve: args.approver must be an actor with a stable id");
358
- if (forbidSelfApprove && approverId === record.requestedBy) {
359
- _emit("dual.grant.self_approval_denied",
360
- { grantId: record.grantId, action: record.action, approver: approverId },
361
- "denied", args.req);
362
- return { error: "self-approval-forbidden", grantId: record.grantId };
363
- }
364
- if (!_approverRoleOk(args.approver)) {
365
- _emit("dual.grant.role_denied",
366
- { grantId: record.grantId, action: record.action, approver: approverId,
367
- requiredRoles: approverRoles,
368
- actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] },
369
- "denied", args.req);
370
- return { error: "approver-role-required", grantId: record.grantId,
371
- requiredRoles: approverRoles };
372
- }
373
- if (record.approvedBy.indexOf(approverId) !== -1) {
374
- return { error: "already-approved-by-this-actor", grantId: record.grantId,
375
- approvedBy: record.approvedBy };
376
- }
339
+ // Pre-compute the pure, record-independent checks so the mutator stays
340
+ // side-effect-free (it may re-run under cluster CAS contention).
341
+ var roleOk = _approverRoleOk(args.approver);
377
342
  var reasonProblem = _checkReason(args.reason, "approve");
378
- if (reasonProblem) {
379
- return Object.assign({ grantId: record.grantId }, reasonProblem);
380
- }
381
- record.approvedBy.push(approverId);
382
- record.approvalsAt.push(Date.now());
383
- record.approvalReasons.push(args.reason || null);
384
- if (approverRoles && args.approver && Array.isArray(args.approver.roles)) {
385
- // Record which of the required roles satisfied the approval —
386
- // useful when an audit reviewer needs to confirm the actor
387
- // approved as e.g. their security-officer role and not their
388
- // engineer role.
389
- var hits = args.approver.roles.filter(function (r) { return approverRoles.indexOf(r) !== -1; });
390
- record.approverRoleHits.push(hits);
391
- }
392
- var status = "pending";
393
- if (record.approvedBy.length >= record.minApprovers) {
394
- status = "approved";
395
- if (record.quorumReachedAt === null) record.quorumReachedAt = Date.now();
343
+ var roleHits = (approverRoles && args.approver && Array.isArray(args.approver.roles))
344
+ ? args.approver.roles.filter(function (r) { return approverRoles.indexOf(r) !== -1; })
345
+ : null;
346
+
347
+ // Atomic read-modify-write: the duplicate-approver guard and the
348
+ // approvedBy append commit together, so two concurrent approvals (or a
349
+ // retried one) cannot each read a stale snapshot and append the same
350
+ // approver twice the quorum-bypass the get/set version allowed.
351
+ var outcome = await cache.update(_key(args.grantId), function (record) {
352
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
353
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
354
+ if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason } } };
355
+ if (record.cancelledAt !== null) return { abort: { response: { error: "grant-cancelled", grantId: record.grantId } } };
356
+ if (record.expiresAt < Date.now()) {
357
+ return { abort: { response: { error: "grant-expired", grantId: record.grantId },
358
+ event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
359
+ }
360
+ if (forbidSelfApprove && approverId === record.requestedBy) {
361
+ return { abort: { response: { error: "self-approval-forbidden", grantId: record.grantId },
362
+ event: "dual.grant.self_approval_denied",
363
+ meta: { grantId: record.grantId, action: record.action, approver: approverId }, outcome: "denied" } };
364
+ }
365
+ if (!roleOk) {
366
+ return { abort: { response: { error: "approver-role-required", grantId: record.grantId, requiredRoles: approverRoles },
367
+ event: "dual.grant.role_denied",
368
+ meta: { grantId: record.grantId, action: record.action, approver: approverId, requiredRoles: approverRoles,
369
+ actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] }, outcome: "denied" } };
370
+ }
371
+ if (record.approvedBy.indexOf(approverId) !== -1) {
372
+ return { abort: { response: { error: "already-approved-by-this-actor", grantId: record.grantId, approvedBy: record.approvedBy.slice() } } };
373
+ }
374
+ if (reasonProblem) return { abort: { response: Object.assign({ grantId: record.grantId }, reasonProblem) } };
375
+ record.approvedBy.push(approverId);
376
+ record.approvalsAt.push(Date.now());
377
+ record.approvalReasons.push(args.reason || null);
378
+ // Record which required role satisfied the approval — an audit
379
+ // reviewer can confirm the actor approved as e.g. security-officer.
380
+ if (roleHits) record.approverRoleHits.push(roleHits);
381
+ if (record.approvedBy.length >= record.minApprovers && record.quorumReachedAt === null) {
382
+ record.quorumReachedAt = Date.now();
383
+ }
384
+ return { value: record, expiresAt: record.expiresAt };
385
+ }, { ttlMs: ttlMs });
386
+
387
+ if (outcome.aborted) {
388
+ var ab = outcome.aborted;
389
+ if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
390
+ return ab.response;
396
391
  }
397
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
398
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
392
+ var rec = outcome.value;
393
+ var status = (rec.approvedBy.length >= rec.minApprovers) ? "approved" : "pending";
394
+ var consumeUnlockAt = rec.quorumReachedAt !== null ? rec.quorumReachedAt + rec.consumeLockMs : null;
399
395
  _emit("dual.grant.approved",
400
- { grantId: record.grantId, action: record.action, approver: approverId,
401
- approverCount: record.approvedBy.length, needs: record.minApprovers,
402
- status: status, reason: args.reason || null,
403
- consumeUnlockAt: record.quorumReachedAt !== null
404
- ? record.quorumReachedAt + record.consumeLockMs : null },
396
+ { grantId: rec.grantId, action: rec.action, approver: approverId,
397
+ approverCount: rec.approvedBy.length, needs: rec.minApprovers,
398
+ status: status, reason: args.reason || null, consumeUnlockAt: consumeUnlockAt },
405
399
  "success", args.req);
406
400
  return {
407
- grantId: record.grantId,
401
+ grantId: rec.grantId,
408
402
  status: status,
409
- approvedBy: record.approvedBy.slice(),
410
- needs: record.minApprovers,
411
- expiresAt: record.expiresAt,
412
- consumeUnlockAt: record.quorumReachedAt !== null
413
- ? record.quorumReachedAt + record.consumeLockMs : null,
403
+ approvedBy: rec.approvedBy.slice(),
404
+ needs: rec.minApprovers,
405
+ expiresAt: rec.expiresAt,
406
+ consumeUnlockAt: consumeUnlockAt,
414
407
  };
415
408
  }
416
409
 
417
410
  async function revoke(args) {
418
411
  if (!args || typeof args !== "object") throw _err("BAD_ARG", "revoke: args required");
419
- var record = await _load(args.grantId);
420
- if (!record) return { error: "grant-not-found", grantId: args.grantId };
421
- if (record.consumedAt !== null) {
422
- return { error: "grant-already-consumed", grantId: record.grantId };
423
- }
424
- record.revokedAt = Date.now();
425
- record.revokedReason = args.reason || null;
426
- var ttlRemaining = Math.max(1, record.expiresAt - Date.now());
427
- await cache.set(_key(record.grantId), record, { ttlMs: ttlRemaining });
412
+ var revokedById = _actorIdOf(args.revokedBy);
413
+ var outcome = await cache.update(_key(args.grantId), function (record) {
414
+ if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
415
+ if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
416
+ record.revokedAt = Date.now();
417
+ record.revokedReason = args.reason || null;
418
+ return { value: record, expiresAt: record.expiresAt };
419
+ }, { ttlMs: ttlMs });
420
+ if (outcome.aborted) return outcome.aborted.response;
421
+ var rec = outcome.value;
428
422
  _emit("dual.grant.denied",
429
- { grantId: record.grantId, action: record.action,
430
- revokedBy: _actorIdOf(args.revokedBy), reason: args.reason || null },
423
+ { grantId: rec.grantId, action: rec.action, revokedBy: revokedById, reason: args.reason || null },
431
424
  "denied", args.req);
432
- return { grantId: record.grantId, status: "revoked" };
425
+ return { grantId: rec.grantId, status: "revoked" };
433
426
  }
434
427
 
435
428
  async function consume(grantId, args) {
436
429
  args = args || {};
437
- var record = await _load(grantId);
438
- if (!record) return { ready: false, reason: "grant-not-found" };
439
- if (record.revokedAt !== null) {
440
- return { ready: false, reason: "revoked" };
441
- }
442
- if (record.cancelledAt !== null) {
443
- return { ready: false, reason: "cancelled" };
444
- }
445
- if (record.consumedAt !== null) {
446
- return { ready: false, reason: "already-consumed" };
447
- }
448
- if (record.expiresAt < Date.now()) {
449
- _emit("dual.grant.expired", { grantId: record.grantId, action: record.action },
450
- "failure", args.req);
451
- await cache.del(_key(record.grantId));
452
- return { ready: false, reason: "expired" };
453
- }
454
- if (record.approvedBy.length < record.minApprovers) {
455
- return { ready: false, reason: "not-enough-approvers",
456
- approvedBy: record.approvedBy.slice(), needs: record.minApprovers };
457
- }
458
- // Cooling-off lock: ANY approval-quorum-reached grant can't consume
459
- // until consumeLockMs has passed since the final approval. Defends
460
- // against rapid-burst compromise of requester+approver.
461
- if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
462
- var unlockAt = record.quorumReachedAt + record.consumeLockMs;
463
- if (Date.now() < unlockAt) {
464
- _emit("dual.grant.consume_locked",
465
- { grantId: record.grantId, action: record.action,
466
- unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
467
- "denied", args.req);
468
- return { ready: false, reason: "consume-locked", unlockAt: unlockAt,
469
- waitMs: unlockAt - Date.now() };
430
+ // Atomic read-modify-write: the consumedAt check and the consumedAt set
431
+ // commit together (compare-and-set), so two concurrent consumes cannot
432
+ // both observe consumedAt === null and both proceed — exactly one wins
433
+ // and runs the destructive operation. The get/set version let a
434
+ // single-use grant be consumed twice.
435
+ var outcome = await cache.update(_key(grantId), function (record) {
436
+ if (!record) return { abort: { response: { ready: false, reason: "grant-not-found" } } };
437
+ if (record.revokedAt !== null) return { abort: { response: { ready: false, reason: "revoked" } } };
438
+ if (record.cancelledAt !== null) return { abort: { response: { ready: false, reason: "cancelled" } } };
439
+ if (record.consumedAt !== null) return { abort: { response: { ready: false, reason: "already-consumed" } } };
440
+ if (record.expiresAt < Date.now()) {
441
+ return { abort: { response: { ready: false, reason: "expired" }, del: true,
442
+ event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
470
443
  }
444
+ if (record.approvedBy.length < record.minApprovers) {
445
+ return { abort: { response: { ready: false, reason: "not-enough-approvers",
446
+ approvedBy: record.approvedBy.slice(), needs: record.minApprovers } } };
447
+ }
448
+ // Cooling-off lock: a quorum-reached grant can't consume until
449
+ // consumeLockMs has passed since the final approval — defends against
450
+ // rapid-burst compromise of requester + approver.
451
+ if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
452
+ var unlockAt = record.quorumReachedAt + record.consumeLockMs;
453
+ if (Date.now() < unlockAt) {
454
+ return { abort: { response: { ready: false, reason: "consume-locked", unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
455
+ event: "dual.grant.consume_locked",
456
+ meta: { grantId: record.grantId, action: record.action, unlockAt: unlockAt, waitMs: unlockAt - Date.now() }, outcome: "denied" } };
457
+ }
458
+ }
459
+ record.consumedAt = Date.now();
460
+ return { value: record, expiresAt: record.expiresAt };
461
+ }, { ttlMs: ttlMs });
462
+
463
+ if (outcome.aborted) {
464
+ var ab = outcome.aborted;
465
+ if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
466
+ if (ab.del) { try { await cache.del(_key(grantId)); } catch (_e) { /* sweep handles it */ } }
467
+ return ab.response;
471
468
  }
472
- record.consumedAt = Date.now();
473
- // Drop the grant from the cache after consume single-use by design.
474
- await cache.del(_key(record.grantId));
469
+ var rec = outcome.value;
470
+ // Single-use by design drop the (now consumed) grant from the cache.
471
+ await cache.del(_key(rec.grantId));
475
472
  _emit("dual.grant.consumed",
476
- { grantId: record.grantId, action: record.action,
477
- approvedBy: record.approvedBy.slice(),
478
- approvalReasons: record.approvalReasons.slice() },
473
+ { grantId: rec.grantId, action: rec.action,
474
+ approvedBy: rec.approvedBy.slice(), approvalReasons: rec.approvalReasons.slice() },
479
475
  "success", args.req);
480
476
  return {
481
477
  ready: true,
482
- grantId: record.grantId,
483
- action: record.action,
484
- resource: record.resource,
485
- approvedBy: record.approvedBy.slice(),
486
- requestedBy:record.requestedBy,
478
+ grantId: rec.grantId,
479
+ action: rec.action,
480
+ resource: rec.resource,
481
+ approvedBy: rec.approvedBy.slice(),
482
+ requestedBy:rec.requestedBy,
487
483
  };
488
484
  }
489
485
 
@@ -64,6 +64,15 @@ var requestHelpers = require("./request-helpers");
64
64
  var safeJson = require("./safe-json");
65
65
  var validateOpts = require("./validate-opts");
66
66
  var { I18nError } = require("./framework-error");
67
+ var { boundedMap } = require("./bounded-map");
68
+
69
+ // Per-instance formatter caches are keyed on (locale, JSON.stringify
70
+ // formatOpts). A fixed `locales` set bounds the locale axis, but operators
71
+ // can pass fresh formatOpts shapes per call (and `Intl` options are open-
72
+ // ended), so the key space is request-influenced — cap it so it can't grow
73
+ // without limit. Evict-oldest is free here: a formatter is pure-derived and
74
+ // re-created on the next miss.
75
+ var FORMATTER_CACHE_MAX_ENTRIES = 512;
67
76
 
68
77
  var observability = lazyRequire(function () { return require("./observability"); });
69
78
 
@@ -353,7 +362,7 @@ function _interpolate(template, vars, interpolation) {
353
362
  // only with operators handing in fresh literals every call (they
354
363
  // usually pass the same shape).
355
364
  function _makeFormatterCache(make, kind, emitObs) {
356
- var cache = new Map();
365
+ var cache = boundedMap({ maxEntries: FORMATTER_CACHE_MAX_ENTRIES, policy: "evict-oldest" });
357
366
  return function getFormatter(locale, formatOpts) {
358
367
  var optsKey = formatOpts ? JSON.stringify(formatOpts) : "";
359
368
  var cacheKey = locale + "\x1f" + optsKey;
@@ -386,6 +386,25 @@ function _verifySignerInfo(si, msgBytes, signerPublicKey, auditHandle) {
386
386
  return { sigAlg: sigAlg, digestAlg: digestAlg };
387
387
  }
388
388
 
389
+ // Does this cert's public key match the raw signer key that verified the
390
+ // signature? signerBytes is the key passed to the PQC/classical verify; a
391
+ // cert exposes its key as SPKI DER. The raw subjectPublicKey is the tail of
392
+ // the SPKI (its last element), so a suffix match catches the raw-key form
393
+ // while a full-length compare catches a caller who passed the SPKI itself.
394
+ // If the key can't be exported (an algorithm this Node build can't parse),
395
+ // the cert can't be bound — return false so the caller fails closed rather
396
+ // than trusting an unverifiable binding.
397
+ function _certKeyMatches(cert, signerBytes) {
398
+ var spki;
399
+ try { spki = Buffer.from(cert.publicKey.export({ format: "der", type: "spki" })); }
400
+ catch (_e) { return false; }
401
+ if (spki.length === signerBytes.length) return Buffer.compare(spki, signerBytes) === 0;
402
+ if (signerBytes.length < spki.length) {
403
+ return Buffer.compare(spki.subarray(spki.length - signerBytes.length), signerBytes) === 0;
404
+ }
405
+ return false;
406
+ }
407
+
389
408
  function _verifyTrustChain(sd, trustAnchorCertsPem, signerPublicKey, auditHandle) {
390
409
  if (sd.certificates.length === 0) {
391
410
  throw new MailCryptoError("mail-crypto/smime/no-certs",
@@ -411,13 +430,24 @@ function _verifyTrustChain(sd, trustAnchorCertsPem, signerPublicKey, auditHandle
411
430
  "trustAnchorCertsPem[" + idx + "] parse failed: " + ((e && e.message) || String(e)));
412
431
  }
413
432
  });
414
- // Pick the leaf the cert whose public key matches the verified
415
- // signature. signerPublicKey is the PQC raw bytes; we compare
416
- // against each chain cert's exported jwk x / SPKI. Hardest path:
417
- // PQC isn't in node:crypto X509Certificate yet, so the leaf might
418
- // be ECDSA / RSA. Fall back to picking the first cert when no
419
- // other comparison applies (operator's chain is operator-curated).
420
- var leaf = chain[0];
433
+ // Bind the leaf to the key that ACTUALLY verified the signature
434
+ // (signerPublicKey). Without this, a validly-chained certificate for a
435
+ // DIFFERENT identity would pass chain validation while the signature was
436
+ // verified under an unrelated key chainVerified would assert a binding
437
+ // the code never made. Find the chain cert whose public key matches
438
+ // signerPublicKey; if none does, the supplied chain does not correspond
439
+ // to the verified signer, so fail closed.
440
+ var signerBytes = Buffer.from(signerPublicKey);
441
+ var leaf = null;
442
+ for (var lci = 0; lci < chain.length; lci += 1) {
443
+ if (_certKeyMatches(chain[lci], signerBytes)) { leaf = chain[lci]; break; }
444
+ }
445
+ if (!leaf) {
446
+ throw new MailCryptoError("mail-crypto/smime/signer-not-in-chain",
447
+ "trust-chain validation: no certificate in SignedData.certificates carries the " +
448
+ "public key that verified the signature — the supplied chain does not correspond " +
449
+ "to the verified signer");
450
+ }
421
451
  // Validity window check (RFC 5280 §4.1.2.5) — every cert in chain
422
452
  // must be within validFrom..validTo at the current wall-clock.
423
453
  var nowMs = Date.now();
@@ -444,8 +474,8 @@ function _verifyTrustChain(sd, trustAnchorCertsPem, signerPublicKey, auditHandle
444
474
  if (current.issuer === r.subject) {
445
475
  try {
446
476
  if (current.verify(r.publicKey)) {
447
- void signerPublicKey; void auditHandle;
448
- return; // chain validates
477
+ void auditHandle;
478
+ return; // chain validates (and the leaf is the verified signer)
449
479
  }
450
480
  } catch (_e) { /* fall through to next root */ }
451
481
  }
@@ -787,4 +817,8 @@ module.exports = {
787
817
  ALLOWED_HASHES: ALLOWED_HASHES,
788
818
  REFUSED_HASHES: REFUSED_HASHES,
789
819
  RSA_MIN_BITS: RSA_MIN_BITS,
820
+ // Exposed for tests — the leaf↔signer-key binding used by trust-chain
821
+ // validation (a cert matches the verified signer key iff its SPKI public
822
+ // key equals, or has as a suffix, the raw signer key bytes).
823
+ _certKeyMatches: _certKeyMatches,
790
824
  };
@@ -13,6 +13,7 @@ var safeBuffer = require("./safe-buffer");
13
13
  var safeUrl = require("./safe-url");
14
14
  var validateOpts = require("./validate-opts");
15
15
  var { defineClass } = require("./framework-error");
16
+ var { boundedMap } = require("./bounded-map");
16
17
 
17
18
  var DnsError = defineClass("DnsError", { alwaysPermanent: false });
18
19
 
@@ -89,8 +90,15 @@ function _ensureSecureDefault() {
89
90
  STATE.doh = { url: DEFAULT_DOH_URL, method: null, ca: null };
90
91
  }
91
92
 
92
- var POSITIVE_CACHE = new Map();
93
- var NEGATIVE_CACHE = new Map();
93
+ // Resolver caches are keyed on (hostname, family). Hostnames reaching the
94
+ // resolver are request-influenced (outbound HTTP targets, mail MX lookups,
95
+ // operator-supplied URLs), and expired entries are only reclaimed lazily on
96
+ // a re-query of the SAME key — so a stream of unique hostnames would grow
97
+ // these without bound. Cap them; evict-oldest is free (DNS re-resolves on
98
+ // the next miss). The cap bounds peak memory even with no periodic sweep.
99
+ var DNS_CACHE_MAX_ENTRIES = 4096;
100
+ var POSITIVE_CACHE = boundedMap({ maxEntries: DNS_CACHE_MAX_ENTRIES, policy: "evict-oldest" });
101
+ var NEGATIVE_CACHE = boundedMap({ maxEntries: DNS_CACHE_MAX_ENTRIES, policy: "evict-oldest" });
94
102
 
95
103
  function _now() { return Date.now(); }
96
104
 
@@ -45,10 +45,18 @@ var clusterStorage = require("./cluster-storage");
45
45
  var C = require("./constants");
46
46
  var safeAsync = require("./safe-async");
47
47
  var { defineClass } = require("./framework-error");
48
+ var { boundedMap } = require("./bounded-map");
48
49
 
49
50
  var NonceStoreError = defineClass("NonceStoreError");
50
51
 
51
52
  var DEFAULT_SWEEP_INTERVAL_MS = C.TIME.minutes(5);
53
+ // Memory-backend ceiling. Each request carries an attacker-choosable unique
54
+ // nonce, so between sweeps the store would otherwise grow without bound (a
55
+ // memory-amplification DoS). Capped — but a replay-protection store must
56
+ // NOT evict a live nonce to admit a new one (that reopens a replay window
57
+ // for the evicted nonce), so the cap uses the "reject" policy and the
58
+ // backend fails CLOSED at capacity (see checkAndInsert).
59
+ var DEFAULT_MAX_ENTRIES = 1000000;
52
60
 
53
61
  function _err(code, message) {
54
62
  return new NonceStoreError(code, message, true);
@@ -58,14 +66,22 @@ function _err(code, message) {
58
66
 
59
67
  function _memoryBackend(opts) {
60
68
  var sweepIntervalMs = opts.sweepIntervalMs || DEFAULT_SWEEP_INTERVAL_MS;
61
- var seen = new Map(); // nonce -> expireAt
69
+ var maxEntries = opts.maxEntries || DEFAULT_MAX_ENTRIES;
70
+ // policy "reject" — never evict a live nonce (that would reopen a replay
71
+ // window for the dropped one). At capacity the backend fails closed.
72
+ var seen = boundedMap({ maxEntries: maxEntries, policy: "reject" });
73
+ var capacityRejects = 0;
62
74
 
63
- var sweepTimer = safeAsync.repeating(function () {
75
+ function _purgeExpiredSync() {
64
76
  var now = Date.now();
77
+ var removed = 0;
65
78
  for (var entry of seen) {
66
- if (entry[1] <= now) seen.delete(entry[0]);
79
+ if (entry[1] <= now) { seen.delete(entry[0]); removed++; }
67
80
  }
68
- }, sweepIntervalMs, { name: "nonce-sweep" });
81
+ return removed;
82
+ }
83
+
84
+ var sweepTimer = safeAsync.repeating(_purgeExpiredSync, sweepIntervalMs, { name: "nonce-sweep" });
69
85
 
70
86
  function checkAndInsert(nonce, expireAt) {
71
87
  if (typeof nonce !== "string" || nonce.length === 0) {
@@ -78,17 +94,26 @@ function _memoryBackend(opts) {
78
94
  if (existing !== undefined && existing > Date.now()) {
79
95
  return Promise.resolve(false); // replay
80
96
  }
81
- seen.set(nonce, expireAt);
97
+ var stored = seen.set(nonce, expireAt);
98
+ if (!stored) {
99
+ // At capacity. Reclaim expired entries inline, then retry once.
100
+ _purgeExpiredSync();
101
+ stored = seen.set(nonce, expireAt);
102
+ }
103
+ if (!stored) {
104
+ // Still full of LIVE nonces — a genuine flood. FAIL CLOSED: we
105
+ // cannot record this nonce, so we cannot prove it is first-seen.
106
+ // Refuse it (report as "seen") rather than admit an unprotected
107
+ // request. Evicting a live nonce to make room would reopen a replay
108
+ // window, so we never evict — the request is rejected instead.
109
+ capacityRejects += 1;
110
+ return Promise.resolve(false);
111
+ }
82
112
  return Promise.resolve(true);
83
113
  }
84
114
 
85
115
  function purgeExpired() {
86
- var now = Date.now();
87
- var removed = 0;
88
- for (var entry of seen) {
89
- if (entry[1] <= now) { seen.delete(entry[0]); removed++; }
90
- }
91
- return Promise.resolve(removed);
116
+ return Promise.resolve(_purgeExpiredSync());
92
117
  }
93
118
 
94
119
  function close() {
@@ -101,8 +126,10 @@ function _memoryBackend(opts) {
101
126
  checkAndInsert: checkAndInsert,
102
127
  purgeExpired: purgeExpired,
103
128
  close: close,
104
- // Test hookdirect read of the underlying Map size
129
+ // Test hooksunderlying entry count + count of capacity fail-closed
130
+ // rejections (a nonce flood that hit the ceiling).
105
131
  _size: function () { return seen.size; },
132
+ _capacityRejects: function () { return capacityRejects; },
106
133
  };
107
134
  }
108
135
 
@@ -88,7 +88,7 @@ function _shapeLeasedRow(raw) {
88
88
  return {
89
89
  jobId: unsealed._id,
90
90
  queueName: unsealed.queueName,
91
- payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
91
+ payload: unsealed.payload ? safeJson.parse(unsealed.payload, { maxBytes: C.BYTES.mib(64) }) : null,
92
92
  attempts: Number(unsealed.attempts),
93
93
  maxAttempts: Number(unsealed.maxAttempts),
94
94
  traceId: unsealed.traceId,
@@ -270,7 +270,7 @@ function create(_config) {
270
270
  var cron = scheduler.parseCron(unsealedRow.repeatCron);
271
271
  var nextMs = scheduler.nextCronFire(cron, new Date(nowMs), unsealedRow.repeatTimezone || null);
272
272
  await enqueue(unsealedRow.queueName,
273
- unsealedRow.payload ? safeJson.parse(unsealedRow.payload) : null,
273
+ unsealedRow.payload ? safeJson.parse(unsealedRow.payload, { maxBytes: C.BYTES.mib(64) }) : null,
274
274
  {
275
275
  // availableAt is the precise next-fire ms — pass it alone.
276
276
  // Don't also pass delaySeconds (the v0.6.22 / v0.6.23 fix
@@ -182,6 +182,11 @@ function create(opts) {
182
182
  var connected = false;
183
183
  var connecting = false;
184
184
  var closing = false;
185
+ // Tracked + unref'd reconnect timer. Tracked so close() can cancel a
186
+ // pending backoff (otherwise a reconnect scheduled before close fires
187
+ // after it and opens a fresh socket); unref'd so a backoff window doesn't
188
+ // by itself keep the event loop alive (the process-won't-exit class).
189
+ var reconnectTimer = null;
185
190
  var rxBuffer = Buffer.alloc(0);
186
191
  // FIFO of in-flight commands awaiting a response
187
192
  var pending = [];
@@ -210,7 +215,11 @@ function create(opts) {
210
215
  }
211
216
  reconnectAttempt++;
212
217
  var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
213
- setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
218
+ reconnectTimer = setTimeout(function () {
219
+ reconnectTimer = null;
220
+ _connect().catch(function () { /* will reschedule */ });
221
+ }, delay);
222
+ if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
214
223
  }
215
224
 
216
225
  function _drainPending(err) {
@@ -290,6 +299,9 @@ function create(opts) {
290
299
  }
291
300
 
292
301
  async function _connect() {
302
+ // A reconnect timer scheduled before close() can still fire afterward;
303
+ // refuse to re-open once closing so it doesn't leak a fresh socket.
304
+ if (closing) return;
293
305
  if (connected) return;
294
306
  if (connecting) {
295
307
  // Wait until current connect attempt resolves
@@ -423,6 +435,7 @@ function create(opts) {
423
435
 
424
436
  async function close() {
425
437
  closing = true;
438
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
426
439
  var err = _err("CLOSED", "redis client closed");
427
440
  _drainPending(err);
428
441
  if (socket) {
@@ -257,6 +257,11 @@ function rectify(subjectId, opts) {
257
257
  * override an active hold (FRCP Rule 26/37(e), GDPR Art 17(3)(e),
258
258
  * SEC Rule 17a-4, HIPAA §164.530(j)(2)).
259
259
  *
260
+ * Security: any `actor` recorded here is an audit-record field, NOT
261
+ * authentication. This primitive gates the deletion on acknowledgements
262
+ * and the legal-hold registry, not on caller identity — the caller MUST be
263
+ * authenticated and authorized by your route before invoking.
264
+ *
260
265
  * Returns `{ rowsDeleted, perTable }`. Use `b.subject.eraseHard` when
261
266
  * residual ciphertext in WAL / replicas / backups must also be made
262
267
  * undecryptable.
@@ -383,7 +388,9 @@ function erase(subjectId, opts) {
383
388
  * Art. 17 erasure shape the framework offers.
384
389
  *
385
390
  * Same legal-hold + acknowledgement gates as `b.subject.erase`.
386
- * Leader-only. Returns `{ rowsDeleted, perRowKeysDestroyed, perTable }`.
391
+ * Security: the `actor` is an audit-record field, not authentication —
392
+ * authorize the caller upstream. Leader-only.
393
+ * Returns `{ rowsDeleted, perRowKeysDestroyed, perTable }`.
387
394
  *
388
395
  * @opts
389
396
  * reason: string, // ticket reference recorded in the audit event