@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.
- package/CHANGELOG.md +5 -1
- package/README.md +2 -2
- package/lib/admin.js +81 -3
- package/lib/asset-manifest.json +5 -1
- package/lib/customers.js +71 -4
- package/lib/storefront.js +462 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +20 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
- package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
- package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
- package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
- package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
- package/lib/vendor/blamejs/lib/cache.js +184 -23
- package/lib/vendor/blamejs/lib/cert.js +68 -11
- package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
- package/lib/vendor/blamejs/lib/db.js +205 -15
- package/lib/vendor/blamejs/lib/dual-control.js +139 -143
- package/lib/vendor/blamejs/lib/i18n.js +10 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
- package/lib/vendor/blamejs/lib/network-dns.js +10 -2
- package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
- package/lib/vendor/blamejs/lib/queue-local.js +2 -2
- package/lib/vendor/blamejs/lib/redis-client.js +14 -1
- package/lib/vendor/blamejs/lib/subject.js +8 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
- package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
- package/lib/vendor/blamejs/test/20-db.js +191 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
- 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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return { error: "
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
record.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
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
|
|
398
|
-
|
|
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:
|
|
401
|
-
approverCount:
|
|
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:
|
|
401
|
+
grantId: rec.grantId,
|
|
408
402
|
status: status,
|
|
409
|
-
approvedBy:
|
|
410
|
-
needs:
|
|
411
|
-
expiresAt:
|
|
412
|
-
consumeUnlockAt:
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return { error: "grant-already-consumed", grantId: record.grantId };
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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:
|
|
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:
|
|
425
|
+
return { grantId: rec.grantId, status: "revoked" };
|
|
433
426
|
}
|
|
434
427
|
|
|
435
428
|
async function consume(grantId, args) {
|
|
436
429
|
args = args || {};
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return { ready: false, reason: "
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return { ready: false, reason: "already-consumed" };
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
473
|
-
//
|
|
474
|
-
await cache.del(_key(
|
|
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:
|
|
477
|
-
approvedBy:
|
|
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:
|
|
483
|
-
action:
|
|
484
|
-
resource:
|
|
485
|
-
approvedBy:
|
|
486
|
-
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 =
|
|
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
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
129
|
+
// Test hooks — underlying 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
|
-
|
|
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
|
-
*
|
|
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
|