@blamejs/core 0.14.27 → 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.
Files changed (134) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +158 -77
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +228 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +82 -29
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +293 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. package/sbom.cdx.json +6 -6
package/lib/outbox.js CHANGED
@@ -70,6 +70,7 @@ var lazyRequire = require("./lazy-require");
70
70
  var safeAsync = require("./safe-async");
71
71
  var safeJson = require("./safe-json");
72
72
  var safeSql = require("./safe-sql");
73
+ var sql = require("./sql");
73
74
  var validateOpts = require("./validate-opts");
74
75
  var { defineClass } = require("./framework-error");
75
76
 
@@ -93,6 +94,15 @@ function _validateTableName(name) {
93
94
  return safeSql.quoteIdentifier(name);
94
95
  }
95
96
 
97
+ // Map the operator backend's dialect tag to the b.sql dialect vocabulary.
98
+ // b.sql's terminal toExternalSql() then emits $1..$N for postgres and `?`
99
+ // for sqlite / mysql, matching what the operator-supplied driver expects.
100
+ function _sqlDialect(externalDb) {
101
+ var d = externalDb && externalDb.dialect;
102
+ if (d === "postgres" || d === "mysql") return d;
103
+ return "sqlite";
104
+ }
105
+
96
106
  function _utcNowExpr(externalDb) {
97
107
  // The framework's externalDb backends wrap Postgres + SQLite. Both
98
108
  // accept a parameterized timestamp via JS Date → ISO string for
@@ -198,7 +208,10 @@ function create(opts) {
198
208
  }
199
209
  validateOpts.requireNonEmptyString(opts.table,
200
210
  "outbox.create: table", OutboxError, "outbox/bad-table");
201
- var quotedTable = _validateTableName(opts.table);
211
+ // Validate the table identifier at create-time so a bad name throws at
212
+ // boot, not at first query. b.sql re-quotes the name by construction on
213
+ // every emitted statement (the builder owns identifier quoting now).
214
+ _validateTableName(opts.table);
202
215
 
203
216
  if (typeof opts.publisher !== "function") {
204
217
  throw new OutboxError("outbox/bad-publisher",
@@ -302,38 +315,57 @@ function create(opts) {
302
315
  "outbox.enqueue: payload/headers must be JSON-serializable: " + e.message);
303
316
  }
304
317
 
305
- var sql = "INSERT INTO " + quotedTable +
306
- " (topic, payload, key, headers, enqueued_at, next_attempt_at, attempts, status)" +
307
- " VALUES ($1, $2, $3, $4, $5, $5, 0, 'pending')";
308
318
  var now = _utcNowExpr(externalDb);
309
- await txn.query(sql, [
310
- event.topic, payloadJson, event.key || null, headersJson, now,
311
- ]);
319
+ // enqueued_at and next_attempt_at both take the same publisher-clock
320
+ // moment; b.sql binds it as two separate `?` so the placeholder/param
321
+ // parity gate holds (no $5-reused-twice shorthand).
322
+ var stmt = sql.insert(opts.table, { dialect: _sqlDialect(externalDb) })
323
+ .values({
324
+ topic: event.topic,
325
+ payload: payloadJson,
326
+ key: event.key || null,
327
+ headers: headersJson,
328
+ enqueued_at: now,
329
+ next_attempt_at: now,
330
+ attempts: 0,
331
+ status: "pending",
332
+ })
333
+ .toExternalSql(_sqlDialect(externalDb));
334
+ await txn.query(stmt.sql, stmt.params);
312
335
  _emitMetric("enqueued", 1);
313
336
  }
314
337
 
315
338
  async function declareSchema(xdb) {
316
339
  var target = xdb || externalDb;
317
- var ddl =
318
- "CREATE TABLE IF NOT EXISTS " + quotedTable + " (" +
319
- "id BIGSERIAL PRIMARY KEY, " +
320
- "topic VARCHAR(255) NOT NULL, " +
321
- "payload TEXT NOT NULL, " +
322
- "key VARCHAR(255), " +
323
- "headers TEXT, " +
324
- "enqueued_at TIMESTAMPTZ NOT NULL, " +
325
- "next_attempt_at TIMESTAMPTZ NOT NULL, " +
326
- "published_at TIMESTAMPTZ, " +
327
- "attempts INTEGER NOT NULL DEFAULT 0, " +
328
- "last_error TEXT, " +
329
- "status VARCHAR(16) NOT NULL DEFAULT 'pending'" +
330
- ")";
331
- var idxName = _validateTableName(opts.table + "_pending_idx");
332
- var idx =
333
- "CREATE INDEX IF NOT EXISTS " + idxName + " ON " + quotedTable +
334
- " (next_attempt_at) WHERE status = 'pending'";
335
- await target.query(ddl, []);
336
- await target.query(idx, []);
340
+ var dialect = _sqlDialect(target);
341
+ // The identity PK renders dialect-correct (BIGSERIAL on postgres,
342
+ // INTEGER PRIMARY KEY AUTOINCREMENT on sqlite, BIGINT AUTO_INCREMENT
343
+ // on mysql) - the prior hand-rolled DDL hardcoded Postgres BIGSERIAL /
344
+ // TIMESTAMPTZ even on a sqlite backend, which the dialect-aware type
345
+ // map now corrects. A varchar-with-length / timestamp-with-zone is
346
+ // passed verbatim by the type map (it sits in type position after a
347
+ // quoted column name, so no identifier injection is possible).
348
+ var tsType = dialect === "postgres" ? "TIMESTAMPTZ" : "TIMESTAMP";
349
+ var ddl = sql.toExternalSql(sql.createTable(opts.table, [
350
+ { name: "id", serial: true },
351
+ { name: "topic", type: "VARCHAR(255)", notNull: true },
352
+ { name: "payload", type: "TEXT", notNull: true },
353
+ { name: "key", type: "VARCHAR(255)" },
354
+ { name: "headers", type: "TEXT" },
355
+ { name: "enqueued_at", type: tsType, notNull: true },
356
+ { name: "next_attempt_at", type: tsType, notNull: true },
357
+ { name: "published_at", type: tsType },
358
+ { name: "attempts", type: "INTEGER", notNull: true, default: 0 },
359
+ { name: "last_error", type: "TEXT" },
360
+ { name: "status", type: "VARCHAR(16)", notNull: true, default: "pending" },
361
+ ], { dialect: dialect }), dialect);
362
+ // Partial index on the pending pool (the publisher's claim path scans
363
+ // status='pending' ORDER BY next_attempt_at). The 'pending' literal is
364
+ // a builder-emitted static predicate, opted in via allowLiterals.
365
+ var idx = sql.toExternalSql(sql.createIndex(opts.table + "_pending_idx", opts.table,
366
+ ["next_attempt_at"], { dialect: dialect, where: "status = 'pending'" }), dialect);
367
+ await target.query(ddl.sql, ddl.params);
368
+ await target.query(idx.sql, idx.params);
337
369
  }
338
370
 
339
371
  // ---- Publisher worker ----
@@ -363,18 +395,24 @@ function create(opts) {
363
395
 
364
396
  async function _claimBatch() {
365
397
  var supportsSkipLocked = _supportsForUpdateSkipLocked();
398
+ var dialect = _sqlDialect(externalDb);
399
+ var CLAIM_COLS = ["id", "topic", "payload", "key", "headers", "attempts"];
366
400
  return await externalDb.transaction(async function (xdb) {
367
401
  var nowExpr = _utcNowExpr(externalDb);
368
- var selectSql =
369
- "SELECT id, topic, payload, key, headers, attempts" +
370
- " FROM " + quotedTable +
371
- " WHERE status = 'pending' AND next_attempt_at <= $1" +
372
- " ORDER BY next_attempt_at" +
373
- " LIMIT $2";
374
- if (supportsSkipLocked) {
375
- selectSql += " FOR UPDATE SKIP LOCKED";
376
- }
377
- var rows = await xdb.query(selectSql, [nowExpr, batchSize]);
402
+ // status='pending' is a builder-emitted static predicate (opted in
403
+ // via allowLiterals); next_attempt_at <= ? + the LIMIT both bind.
404
+ var selectBuilder = sql.select(opts.table, { dialect: dialect })
405
+ .columns(CLAIM_COLS)
406
+ .whereRaw("status = 'pending'", [], { allowLiterals: true })
407
+ .whereRaw("next_attempt_at <= ?", [nowExpr])
408
+ .orderBy("next_attempt_at")
409
+ .limit(batchSize);
410
+ // FOR UPDATE SKIP LOCKED on postgres / mysql; sqlite is a single
411
+ // writer with no row lock, so the claim there is the conservative
412
+ // mark-then-reselect path below (b.sql refuses forUpdate on sqlite).
413
+ if (supportsSkipLocked) selectBuilder.forUpdate({ skipLocked: true });
414
+ var selectSql = selectBuilder.toExternalSql(dialect);
415
+ var rows = await xdb.query(selectSql.sql, selectSql.params);
378
416
  if (!rows || !rows.rows || rows.rows.length === 0) return [];
379
417
  var ids = rows.rows.map(function (r) { return r.id; });
380
418
  // Atomic claim: when the dialect lacks SKIP LOCKED, the UPDATE
@@ -386,30 +424,33 @@ function create(opts) {
386
424
  // way Postgres does).
387
425
  var actuallyClaimed;
388
426
  if (supportsSkipLocked) {
389
- // Postgres/MySQL: row lock held; ANY($1) update is safe.
390
- await xdb.query(
391
- "UPDATE " + quotedTable + " SET status = 'in-flight' WHERE id = ANY($1)",
392
- [ids]
393
- );
427
+ // Postgres/MySQL: row lock held; whereInArray emits `id = ANY(?)`
428
+ // on postgres (the whole id set as one bound array) / expanded
429
+ // `IN (?, ?, ...)` on mysql.
430
+ var claimUpdate = sql.update(opts.table, { dialect: dialect })
431
+ .set({ status: "in-flight" })
432
+ .whereInArray("id", ids)
433
+ .toExternalSql(dialect);
434
+ await xdb.query(claimUpdate.sql, claimUpdate.params);
394
435
  actuallyClaimed = rows.rows;
395
436
  } else {
396
437
  // SQLite (or "other") path: emit a portable UPDATE that
397
438
  // refuses overlap by gating on status='pending'. After the
398
439
  // update we re-read the in-flight rows we own; rows that
399
- // another publisher beat us to are skipped.
400
- // Use placeholders so the SQL stays parameterized regardless
401
- // of dialect array semantics.
402
- var placeholders = ids.map(function (_, i) { return "$" + (i + 1); }).join(",");
403
- await xdb.query(
404
- "UPDATE " + quotedTable +
405
- " SET status = 'in-flight' WHERE status = 'pending' AND id IN (" + placeholders + ")",
406
- ids
407
- );
408
- var afterRows = await xdb.query(
409
- "SELECT id, topic, payload, key, headers, attempts FROM " + quotedTable +
410
- " WHERE status = 'in-flight' AND id IN (" + placeholders + ")",
411
- ids
412
- );
440
+ // another publisher beat us to are skipped. whereInArray expands
441
+ // to an `IN (?, ?, ...)` placeholder list on sqlite.
442
+ var markUpdate = sql.update(opts.table, { dialect: dialect })
443
+ .set({ status: "in-flight" })
444
+ .whereRaw("status = 'pending'", [], { allowLiterals: true })
445
+ .whereInArray("id", ids)
446
+ .toExternalSql(dialect);
447
+ await xdb.query(markUpdate.sql, markUpdate.params);
448
+ var afterSelect = sql.select(opts.table, { dialect: dialect })
449
+ .columns(CLAIM_COLS)
450
+ .whereRaw("status = 'in-flight'", [], { allowLiterals: true })
451
+ .whereInArray("id", ids)
452
+ .toExternalSql(dialect);
453
+ var afterRows = await xdb.query(afterSelect.sql, afterSelect.params);
413
454
  actuallyClaimed = (afterRows && afterRows.rows) || [];
414
455
  }
415
456
  return actuallyClaimed.map(function (r) {
@@ -426,29 +467,40 @@ function create(opts) {
426
467
  }
427
468
 
428
469
  async function _markPublished(id) {
429
- await externalDb.query(
430
- "UPDATE " + quotedTable +
431
- " SET status = 'published', published_at = $1 WHERE id = $2",
432
- [_utcNowExpr(externalDb), id]
433
- );
470
+ var dialect = _sqlDialect(externalDb);
471
+ var stmt = sql.update(opts.table, { dialect: dialect })
472
+ .set({ status: "published", published_at: _utcNowExpr(externalDb) })
473
+ .where("id", id)
474
+ .toExternalSql(dialect);
475
+ await externalDb.query(stmt.sql, stmt.params);
434
476
  }
435
477
 
436
478
  async function _markRetry(id, attempts, errMsg) {
479
+ var dialect = _sqlDialect(externalDb);
437
480
  var nextAt = new Date(Date.now() + _backoffMs(attempts + 1));
438
- await externalDb.query(
439
- "UPDATE " + quotedTable +
440
- " SET status = 'pending', attempts = $1, last_error = $2, next_attempt_at = $3" +
441
- " WHERE id = $4",
442
- [attempts + 1, String(errMsg).slice(0, 1024), nextAt, id] // error-message char cap
443
- );
481
+ var stmt = sql.update(opts.table, { dialect: dialect })
482
+ .set({
483
+ status: "pending",
484
+ attempts: attempts + 1,
485
+ last_error: String(errMsg).slice(0, 1024), // error-message char cap
486
+ next_attempt_at: nextAt,
487
+ })
488
+ .where("id", id)
489
+ .toExternalSql(dialect);
490
+ await externalDb.query(stmt.sql, stmt.params);
444
491
  }
445
492
 
446
493
  async function _markDead(id, attempts, errMsg) {
447
- await externalDb.query(
448
- "UPDATE " + quotedTable +
449
- " SET status = 'dead', attempts = $1, last_error = $2 WHERE id = $3",
450
- [attempts + 1, String(errMsg).slice(0, 1024), id] // error-message char cap
451
- );
494
+ var dialect = _sqlDialect(externalDb);
495
+ var stmt = sql.update(opts.table, { dialect: dialect })
496
+ .set({
497
+ status: "dead",
498
+ attempts: attempts + 1,
499
+ last_error: String(errMsg).slice(0, 1024), // error-message char cap
500
+ })
501
+ .where("id", id)
502
+ .toExternalSql(dialect);
503
+ await externalDb.query(stmt.sql, stmt.params);
452
504
  _emitAudit("system.outbox.deadletter", "failure", { id: id, attempts: attempts + 1 });
453
505
  _emitMetric("dead-letter", 1);
454
506
  }
@@ -516,19 +568,21 @@ function create(opts) {
516
568
  _emitAudit("system.outbox.stopped", "success", { name: name });
517
569
  }
518
570
 
519
- async function pendingCount() {
520
- var res = await externalDb.query(
521
- "SELECT COUNT(*) AS n FROM " + quotedTable + " WHERE status = 'pending'", []
522
- );
571
+ async function _statusCount(status) {
572
+ var dialect = _sqlDialect(externalDb);
573
+ // status is a fixed builder-internal literal ('pending' / 'dead'),
574
+ // never operator input; opted in via allowLiterals. COUNT(*) AS n is
575
+ // the count aggregate with an alias.
576
+ var stmt = sql.select(opts.table, { dialect: dialect })
577
+ .count("*", "n")
578
+ .whereRaw("status = '" + status + "'", [], { allowLiterals: true })
579
+ .toExternalSql(dialect);
580
+ var res = await externalDb.query(stmt.sql, stmt.params);
523
581
  return Number((res && res.rows && res.rows[0] && res.rows[0].n) || 0);
524
582
  }
525
583
 
526
- async function deadCount() {
527
- var res = await externalDb.query(
528
- "SELECT COUNT(*) AS n FROM " + quotedTable + " WHERE status = 'dead'", []
529
- );
530
- return Number((res && res.rows && res.rows[0] && res.rows[0].n) || 0);
531
- }
584
+ async function pendingCount() { return await _statusCount("pending"); }
585
+ async function deadCount() { return await _statusCount("dead"); }
532
586
 
533
587
  return {
534
588
  enqueue: enqueue,
package/lib/pqc-agent.js CHANGED
@@ -41,6 +41,33 @@ var PqcAgentError = defineClass("PqcAgentError", { alwaysPermanent: true });
41
41
  // cycles when pqc-agent is required during framework bootstrap.
42
42
  var audit = lazyRequire(function () { return require("./audit"); });
43
43
 
44
+ // Observe an outbound socket's negotiated TLS key-exchange group and audit a
45
+ // classical (non-PQC) downgrade. node:tls reports getEphemeralKeyInfo() as
46
+ // { type:"ECDH", name:"X25519", ... } for a classical group and as {} for an
47
+ // ML-KEM hybrid (it doesn't model the hybrid as ECDH). So a NON-empty name
48
+ // that doesn't carry "MLKEM" means the peer offered no hybrid and the
49
+ // handshake fell back to classical X25519 (the framework's last-resort
50
+ // group) — emit the downgrade so operators can see which dependencies are
51
+ // not yet PQC-ready. Best-effort + drop-silent: an audit failure must never
52
+ // break the request that triggered it.
53
+ function auditClassicalDowngrade(socket, meta) {
54
+ try {
55
+ if (!socket || typeof socket.getEphemeralKeyInfo !== "function") return;
56
+ var info = socket.getEphemeralKeyInfo() || {};
57
+ var group = info.name;
58
+ if (!group || /MLKEM/i.test(group)) return; // hybrid (or unreported) — not a downgrade
59
+ audit().safeEmit({
60
+ action: "tls.classical_downgrade",
61
+ outcome: "success",
62
+ metadata: {
63
+ group: group,
64
+ host: (meta && (meta.host || meta.servername)) || null,
65
+ port: (meta && meta.port) || null,
66
+ },
67
+ });
68
+ } catch (_e) { /* drop-silent — audit is best-effort; never break TLS */ }
69
+ }
70
+
44
71
  // IANA TLS Supported Groups Registry — every named-group identifier
45
72
  // the framework knows by name. Operators with `allowOperatorGroups:
46
73
  // true` may pass any entry from this registry; entries outside it
@@ -186,6 +213,19 @@ function create(opts) {
186
213
  var built = _buildAgentOpts(opts);
187
214
  var agent = new https.Agent(built);
188
215
  agent._builtOpts = built;
216
+ // Observe each NEW outbound socket's negotiated group (createConnection
217
+ // runs per fresh connection, not per keep-alive reuse). A classical
218
+ // negotiation means the peer offered no ML-KEM hybrid — audit the
219
+ // downgrade. Hybrid stays preferred on every handshake; this only fires on
220
+ // the classical fallback.
221
+ var _origCreateConnection = agent.createConnection.bind(agent);
222
+ agent.createConnection = function (options, cb) {
223
+ var socket = _origCreateConnection(options, cb);
224
+ if (socket && typeof socket.once === "function") {
225
+ socket.once("secureConnect", function () { auditClassicalDowngrade(socket, options); });
226
+ }
227
+ return socket;
228
+ };
189
229
  // Per-instance cert rotation. The pre-v0.10.9 path required process
190
230
  // restart for cert rotation on agents built via explicit `create()`
191
231
  // (only the framework's lazy default had `b.pqcAgent.reload()`).
@@ -345,6 +385,10 @@ module.exports = {
345
385
  create: create,
346
386
  createHttp: createHttp,
347
387
  reload: reload,
388
+ // Internal — shared with lib/http-client.js's h2 transport, which connects
389
+ // via node:http2 (not this agent) and so needs the same downgrade
390
+ // observation. Underscore-prefixed: not a public operator primitive.
391
+ _auditClassicalDowngrade: auditClassicalDowngrade,
348
392
  DEFAULT_OPTS: DEFAULT_OPTS,
349
393
  KNOWN_TLS_GROUPS: KNOWN_TLS_GROUPS,
350
394
  enforced: true,
@@ -22,6 +22,8 @@
22
22
  * publishedBy)`. Created by `lib/cluster-storage.js` migrations.
23
23
  */
24
24
  var clusterStorage = require("./cluster-storage");
25
+ var frameworkSchema = require("./framework-schema");
26
+ var sql = require("./sql");
25
27
  var C = require("./constants");
26
28
  var lazyRequire = require("./lazy-require");
27
29
  var validateOpts = require("./validate-opts");
@@ -31,6 +33,24 @@ var logger = lazyRequire(function () { return require("./log").boot("pubsub-clus
31
33
 
32
34
  var PubsubError = defineClass("PubsubError");
33
35
 
36
+ // Resolved once: the fan-out table's concrete name, honoring the
37
+ // configurable framework-table prefix. clusterStorage.execute leaves
38
+ // this self-prefixed name unrewritten (its rewrite map is identity-
39
+ // filtered for already-prefixed tables) and translates `?` to `$N` for
40
+ // Postgres, so b.sql emits the bare resolved name + `?` placeholders.
41
+ var MESSAGES_TABLE = frameworkSchema.tableName("_blamejs_pubsub_messages"); // allow:hand-rolled-sql — single canonical logical-name reference
42
+
43
+ // b.sql opts for every fan-out statement: thread the ACTIVE backend
44
+ // dialect (clusterStorage.dialect() — "sqlite" single-node, "postgres" |
45
+ // "mysql" in cluster mode) so the emitted identifier quoting matches the
46
+ // backend the SQL dispatches to. Without it b.sql defaults to "sqlite"
47
+ // and double-quotes identifiers — correct on Postgres (both double-quote)
48
+ // but read as STRING LITERALS by MySQL (no ANSI_QUOTES), turning the
49
+ // INSERT/SELECT/DELETE into syntax errors. clusterStorage.execute still
50
+ // rewrites table names + translates `?` placeholders at dispatch; this
51
+ // controls only the builder-side identifier quoting.
52
+ function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
53
+
34
54
  var DEFAULT_POLL_INTERVAL_MS = 100;
35
55
  var DEFAULT_RETENTION_MS = C.TIME.minutes(1);
36
56
  var DEFAULT_PRUNE_EVERY_MS = C.TIME.minutes(5);
@@ -65,11 +85,13 @@ function create(opts) {
65
85
 
66
86
  async function publishRemote(scopedChannel, payload) {
67
87
  var serialized = JSON.stringify(payload);
68
- await clusterStorage.execute(
69
- "INSERT INTO _blamejs_pubsub_messages " +
70
- "(topic, payload, publishedAt, publishedBy) VALUES (?, ?, ?, ?)",
71
- [scopedChannel, serialized, Date.now(), _nodeId()]
72
- );
88
+ var built = sql.insert(MESSAGES_TABLE, _sqlOpts()).values({
89
+ topic: scopedChannel,
90
+ payload: serialized,
91
+ publishedAt: Date.now(),
92
+ publishedBy: _nodeId(),
93
+ }).toSql();
94
+ await clusterStorage.execute(built.sql, built.params);
73
95
  return { remote: 1 };
74
96
  }
75
97
 
@@ -78,24 +100,25 @@ function create(opts) {
78
100
  var nodeId = _nodeId();
79
101
  try {
80
102
  // First poll: prime lastSeenId to the current MAX so we don't
81
- // re-dispatch every historical row on startup.
103
+ // re-dispatch every historical row on startup. MAX(id) is NULL on
104
+ // an empty table; Number(null) || 0 below maps that to 0 (the same
105
+ // result the prior COALESCE(MAX(id), 0) produced).
82
106
  if (!primed) {
83
- var primer = await clusterStorage.execute(
84
- "SELECT COALESCE(MAX(id), 0) AS maxId FROM _blamejs_pubsub_messages",
85
- []
86
- );
107
+ var primerBuilt = sql.select(MESSAGES_TABLE, _sqlOpts()).max("id", "maxId").toSql();
108
+ var primer = await clusterStorage.execute(primerBuilt.sql, primerBuilt.params);
87
109
  if (primer.rows && primer.rows[0]) {
88
110
  lastSeenId = Number(primer.rows[0].maxId) || 0;
89
111
  }
90
112
  primed = true;
91
113
  return;
92
114
  }
93
- var result = await clusterStorage.execute(
94
- "SELECT id, topic, payload, publishedAt, publishedBy " +
95
- "FROM _blamejs_pubsub_messages " +
96
- "WHERE id > ? AND publishedBy <> ? ORDER BY id ASC",
97
- [lastSeenId, nodeId]
98
- );
115
+ var pollBuilt = sql.select(MESSAGES_TABLE, _sqlOpts())
116
+ .columns(["id", "topic", "payload", "publishedAt", "publishedBy"])
117
+ .where("id", ">", lastSeenId)
118
+ .where("publishedBy", "<>", nodeId)
119
+ .orderBy("id", "asc")
120
+ .toSql();
121
+ var result = await clusterStorage.execute(pollBuilt.sql, pollBuilt.params);
99
122
  var rows = result.rows || [];
100
123
  for (var i = 0; i < rows.length; i++) {
101
124
  var row = rows[i];
@@ -116,10 +139,9 @@ function create(opts) {
116
139
  var now = Date.now();
117
140
  if (now - lastPruneAt >= pruneEveryMs) {
118
141
  lastPruneAt = now;
119
- await clusterStorage.execute(
120
- "DELETE FROM _blamejs_pubsub_messages WHERE publishedAt < ?",
121
- [now - retentionMs]
122
- );
142
+ var pruneBuilt = sql.delete(MESSAGES_TABLE, _sqlOpts())
143
+ .where("publishedAt", "<", now - retentionMs).toSql();
144
+ await clusterStorage.execute(pruneBuilt.sql, pruneBuilt.params);
123
145
  }
124
146
  } catch (e) {
125
147
  try { logger().warn("pubsub-cluster poll failed: " +