@blamejs/core 0.14.26 → 0.15.0

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 (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
@@ -72,7 +72,7 @@ var C = require("./constants");
72
72
  var validateOpts = require("./validate-opts");
73
73
  var numericBounds = require("./numeric-bounds");
74
74
  var safeJson = require("./safe-json");
75
- var safeSql = require("./safe-sql");
75
+ var sql = require("./sql");
76
76
  var { defineClass } = require("./framework-error");
77
77
  var lazyRequire = require("./lazy-require");
78
78
 
@@ -213,33 +213,40 @@ function create(opts) {
213
213
  // / `messageId` / `archivedAt` / `sizeBytes` / `regimes` / `legalHold`
214
214
  // queryable without unsealing the payload. The payload (headers +
215
215
  // body) lives in the WORM bucket sealed via b.cryptoField.sealRow.
216
- // Route every identifier through safeSql.quoteIdentifier — the
217
- // shared substrate validates the unquoted name AND emits the
218
- // dialect-correct quoted form. Index names must be built from the
219
- // unquoted base then quoted independently; appending suffixes to
220
- // an already-quoted token produces invalid SQL like
221
- // `"_mail_journal_x"_archived_at_idx`.
222
- var rawTable = "_mail_journal_" + namespace.replace(/-/g, "_");
223
- var qTable = safeSql.quoteIdentifier(rawTable);
224
- var qIdxArchAt = safeSql.quoteIdentifier(rawTable + "_archived_at_idx");
225
- var qIdxMsgId = safeSql.quoteIdentifier(rawTable + "_message_id_idx");
226
- opts.db.runSql(
227
- "CREATE TABLE IF NOT EXISTS " + qTable + " (" +
228
- "journal_id TEXT PRIMARY KEY, " +
229
- "direction TEXT NOT NULL, " +
230
- "actor_id TEXT, " +
231
- "message_id TEXT, " +
232
- "archived_at INTEGER NOT NULL, " +
233
- "size_bytes INTEGER NOT NULL, " +
234
- "regimes TEXT NOT NULL, " +
235
- "floor_until INTEGER NOT NULL, " +
236
- "legal_hold INTEGER NOT NULL DEFAULT 0, " +
237
- "storage_key TEXT NOT NULL UNIQUE, " +
238
- "sealed_payload BLOB NOT NULL" +
239
- ");" +
240
- "CREATE INDEX IF NOT EXISTS " + qIdxArchAt + " ON " + qTable + " (archived_at);" +
241
- "CREATE INDEX IF NOT EXISTS " + qIdxMsgId + " ON " + qTable + " (message_id);"
242
- );
216
+ //
217
+ // The journal table is an operator-namespaced local table (NOT a
218
+ // framework `_blamejs_` table that clusterStorage rewrites), so every
219
+ // statement composes b.sql with quoteName:true b.sql validates the
220
+ // identifier through b.safeSql and emits the dialect-quoted form,
221
+ // running against opts.db (the local sqlite handle) directly. `_t()`
222
+ // opens each verb builder pre-bound to this table so the name resolves
223
+ // in exactly one place.
224
+ var rawTable = "_mail_journal_" + namespace.replace(/-/g, "_");
225
+ var TBL_OPTS = { dialect: "sqlite", quoteName: true };
226
+ function _t(verb) { return sql[verb](rawTable, TBL_OPTS); }
227
+
228
+ // Bootstrap DDL CREATE TABLE + the archived_at / message_id indexes.
229
+ // runSql is a multi-statement helper, so the three b.sql DDL strings
230
+ // join with `;` into one call (each b.sql build is a single validated
231
+ // statement; the join is the multi-statement boundary runSql expects).
232
+ var ddl = [
233
+ sql.createTable(rawTable, [
234
+ { name: "journal_id", type: "text", primaryKey: true },
235
+ { name: "direction", type: "text", notNull: true },
236
+ { name: "actor_id", type: "text" },
237
+ { name: "message_id", type: "text" },
238
+ { name: "archived_at", type: "int", notNull: true },
239
+ { name: "size_bytes", type: "int", notNull: true },
240
+ { name: "regimes", type: "text", notNull: true },
241
+ { name: "floor_until", type: "int", notNull: true },
242
+ { name: "legal_hold", type: "int", notNull: true, default: 0 },
243
+ { name: "storage_key", type: "text", notNull: true, unique: true },
244
+ { name: "sealed_payload", type: "blob", notNull: true },
245
+ ], TBL_OPTS).sql,
246
+ sql.createIndex(rawTable + "_archived_at_idx", rawTable, ["archived_at"], TBL_OPTS).sql,
247
+ sql.createIndex(rawTable + "_message_id_idx", rawTable, ["message_id"], TBL_OPTS).sql,
248
+ ].join(";");
249
+ opts.db.runSql(ddl);
243
250
 
244
251
  async function record(req) {
245
252
  validateOpts.requireObject(req, "mail.journal.record",
@@ -292,13 +299,24 @@ function create(opts) {
292
299
 
293
300
  var regimesJson = JSON.stringify(opts.regimes);
294
301
  var floorUntil = archivedAt + floorMs;
295
- opts.db.runSql(
296
- "INSERT INTO " + qTable + " (journal_id, direction, actor_id, message_id, " +
297
- "archived_at, size_bytes, regimes, floor_until, legal_hold, storage_key, sealed_payload) " +
298
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?);",
299
- [journalId, req.direction, req.actorId, req.messageId,
300
- archivedAt, sizeBytes, regimesJson, floorUntil, storageKey, sealedBlob]
301
- );
302
+ // legal_hold is omitted from the INSERT so the column's
303
+ // `NOT NULL DEFAULT 0` applies (the prior inline `0` SQL literal) — b.sql
304
+ // binds every value it is given, so leaving the column out keeps the row
305
+ // unsealed-at-rest default without binding a redundant constant. b.sql
306
+ // quotes every column + binds every remaining value as a placeholder.
307
+ var insBuilt = _t("insert").values({
308
+ journal_id: journalId,
309
+ direction: req.direction,
310
+ actor_id: req.actorId,
311
+ message_id: req.messageId,
312
+ archived_at: archivedAt,
313
+ size_bytes: sizeBytes,
314
+ regimes: regimesJson,
315
+ floor_until: floorUntil,
316
+ storage_key: storageKey,
317
+ sealed_payload: sealedBlob,
318
+ }).toSql();
319
+ opts.db.runSql(insBuilt.sql, insBuilt.params);
302
320
 
303
321
  _emit("mail.journal.record", "success", {
304
322
  journalId: journalId,
@@ -317,11 +335,12 @@ function create(opts) {
317
335
  throw new MailJournalError("mail-journal/bad-id",
318
336
  "mail.journal.getById: journalId must be a non-empty string");
319
337
  }
320
- var rows = opts.db.runSql(
321
- "SELECT direction, message_id, archived_at, size_bytes, regimes, floor_until, " +
322
- "legal_hold, storage_key, sealed_payload FROM " + qTable + " WHERE journal_id = ?;",
323
- [journalId]
324
- );
338
+ var gbBuilt = _t("select")
339
+ .columns(["direction", "message_id", "archived_at", "size_bytes", "regimes",
340
+ "floor_until", "legal_hold", "storage_key", "sealed_payload"])
341
+ .where("journal_id", journalId)
342
+ .toSql();
343
+ var rows = opts.db.runSql(gbBuilt.sql, gbBuilt.params);
325
344
  if (!rows || rows.length === 0) return null;
326
345
  var r = rows[0];
327
346
  var unsealed = safeJson.parse(opts.vault.unseal(r.sealed_payload));
@@ -344,30 +363,27 @@ function create(opts) {
344
363
 
345
364
  function list(filter) {
346
365
  filter = filter || {};
366
+ var limit = numericBounds.isPositiveFiniteInt(filter.limit) ? Math.min(filter.limit, 1000) : 100; // list page cap
367
+ // Each filter term is an optional .where() leaf (AND-composed); b.sql
368
+ // quotes the columns + binds the values. A diagnostic clause list is
369
+ // kept for the audit metadata (the prior `filter: clauses` field).
347
370
  var clauses = [];
348
- var args = [];
371
+ var qb = _t("select").columns(["journal_id", "direction", "actor_id", "message_id",
372
+ "archived_at", "size_bytes", "regimes", "floor_until", "legal_hold", "storage_key"]);
349
373
  if (filter.direction && ALLOWED_DIRECTIONS[filter.direction]) {
350
- clauses.push("direction = ?");
351
- args.push(filter.direction);
374
+ qb.where("direction", filter.direction); clauses.push("direction = ?");
352
375
  }
353
376
  if (typeof filter.since === "number" && numericBounds.isPositiveFiniteInt(filter.since)) {
354
- clauses.push("archived_at >= ?");
355
- args.push(filter.since);
377
+ qb.whereOp("archived_at", ">=", filter.since); clauses.push("archived_at >= ?");
356
378
  }
357
379
  if (typeof filter.until === "number" && numericBounds.isPositiveFiniteInt(filter.until)) {
358
- clauses.push("archived_at < ?");
359
- args.push(filter.until);
380
+ qb.whereOp("archived_at", "<", filter.until); clauses.push("archived_at < ?");
360
381
  }
361
382
  if (filter.actorId && typeof filter.actorId === "string") {
362
- clauses.push("actor_id = ?");
363
- args.push(filter.actorId);
383
+ qb.where("actor_id", filter.actorId); clauses.push("actor_id = ?");
364
384
  }
365
- var limit = numericBounds.isPositiveFiniteInt(filter.limit) ? Math.min(filter.limit, 1000) : 100; // list page cap
366
- var where = clauses.length > 0 ? " WHERE " + clauses.join(" AND ") : "";
367
- var sql = "SELECT journal_id, direction, actor_id, message_id, archived_at, " +
368
- "size_bytes, regimes, floor_until, legal_hold, storage_key FROM " +
369
- qTable + where + " ORDER BY archived_at DESC LIMIT " + limit + ";";
370
- var rows = opts.db.runSql(sql, args);
385
+ var listBuilt = qb.orderBy("archived_at", "desc").limit(limit).toSql();
386
+ var rows = opts.db.runSql(listBuilt.sql, listBuilt.params);
371
387
  _emit("mail.journal.list", "success", { count: rows ? rows.length : 0, filter: clauses });
372
388
  return (rows || []).map(function (r) {
373
389
  return {
@@ -387,11 +403,15 @@ function create(opts) {
387
403
 
388
404
  function expireSurface(now) {
389
405
  if (now === undefined) now = Date.now();
390
- var rows = opts.db.runSql(
391
- "SELECT journal_id, archived_at, floor_until, message_id, regimes FROM " +
392
- qTable + " WHERE floor_until < ? AND legal_hold = 0 ORDER BY archived_at ASC LIMIT 1000;", // expiry-surface cap
393
- [now]
394
- );
406
+ // legal_hold = 0 binds as a value (the prior inline `0` literal).
407
+ var esBuilt = _t("select")
408
+ .columns(["journal_id", "archived_at", "floor_until", "message_id", "regimes"])
409
+ .whereOp("floor_until", "<", now)
410
+ .where("legal_hold", 0)
411
+ .orderBy("archived_at", "asc")
412
+ .limit(1000) // expiry-surface cap
413
+ .toSql();
414
+ var rows = opts.db.runSql(esBuilt.sql, esBuilt.params);
395
415
  _emit("mail.journal.expire_surface", "success", { count: rows ? rows.length : 0, now: now });
396
416
  return (rows || []).map(function (r) {
397
417
  return {
@@ -409,10 +429,11 @@ function create(opts) {
409
429
  throw new MailJournalError("mail-journal/bad-id",
410
430
  "mail.journal.setLegalHold: journalId required");
411
431
  }
412
- opts.db.runSql(
413
- "UPDATE " + qTable + " SET legal_hold = ? WHERE journal_id = ?;",
414
- [onHold ? 1 : 0, journalId]
415
- );
432
+ var lhBuilt = _t("update")
433
+ .set("legal_hold", onHold ? 1 : 0)
434
+ .where("journal_id", journalId)
435
+ .toSql();
436
+ opts.db.runSql(lhBuilt.sql, lhBuilt.params);
416
437
  _emit("mail.journal.legal_hold_change", "success", { journalId: journalId, onHold: !!onHold });
417
438
  }
418
439
 
package/lib/mail-rbl.js CHANGED
@@ -86,6 +86,7 @@ var C = require("./constants");
86
86
  var { defineClass } = require("./framework-error");
87
87
  var lazyRequire = require("./lazy-require");
88
88
  var ipUtils = require("./ip-utils");
89
+ var gateContract = require("./gate-contract");
89
90
 
90
91
  var audit = lazyRequire(function () { return require("./audit"); });
91
92
 
@@ -105,12 +106,7 @@ var PROFILES = Object.freeze({
105
106
  permissive: { maxConcurrent: 32, perListTimeoutMs: C.TIME.seconds(20), maxListsPerQuery: 64 }, // list-count cap
106
107
  });
107
108
 
108
- var COMPLIANCE_POSTURES = Object.freeze({
109
- hipaa: "strict",
110
- "pci-dss": "strict",
111
- gdpr: "strict",
112
- soc2: "strict",
113
- });
109
+ var COMPLIANCE_POSTURES = gateContract.ALL_STRICT_POSTURES;
114
110
 
115
111
  /**
116
112
  * @primitive b.mail.rbl.create
package/lib/mail-scan.js CHANGED
@@ -85,6 +85,7 @@ var lazyRequire = require("./lazy-require");
85
85
  var validateOpts = require("./validate-opts");
86
86
  var numericBounds = require("./numeric-bounds");
87
87
  var safeIcap = require("./safe-icap");
88
+ var gateContract = require("./gate-contract");
88
89
 
89
90
  var audit = lazyRequire(function () { return require("./audit"); });
90
91
  var guardArchive = lazyRequire(function () { return require("./guard-archive"); });
@@ -107,12 +108,7 @@ var PROFILES = Object.freeze({
107
108
  permissive: { timeoutMs: C.TIME.seconds(120), maxMessageBytes: C.BYTES.mib(150), maxResponseBytes: C.BYTES.mib(300) }, // operator-facing default mailbox cap
108
109
  });
109
110
 
110
- var COMPLIANCE_POSTURES = Object.freeze({
111
- hipaa: "strict",
112
- "pci-dss": "strict",
113
- gdpr: "strict",
114
- soc2: "strict",
115
- });
111
+ var COMPLIANCE_POSTURES = gateContract.ALL_STRICT_POSTURES;
116
112
 
117
113
  var ALLOWED_PROTOCOLS = Object.freeze({
118
114
  "icap": true,
@@ -316,6 +316,33 @@ function create(opts) {
316
316
  return cur;
317
317
  }
318
318
 
319
+ // ---- Account authorization (RFC 8620 §3.6.1 accountNotFound) ------------
320
+ //
321
+ // The set of accountIds an actor may touch is whatever
322
+ // `opts.accountsFor(actor)` enumerates in its `accounts` map — the same
323
+ // source the session resource advertises. Resolving it ONCE per request
324
+ // and rejecting any client-supplied accountId outside that set is the
325
+ // cross-tenant authorization control: without it, a tenant's request can
326
+ // name another tenant's accountId and reach the operator's method/blob
327
+ // handler, which must then independently re-check or leak. The listener
328
+ // owns this gate so every account-scoped op (method dispatch + blob
329
+ // upload/download) is covered uniformly.
330
+ async function _permittedAccountIds(actor) {
331
+ var info = await opts.accountsFor(actor);
332
+ info = info || {};
333
+ var accounts = info.accounts || {};
334
+ // A Set of the accountIds the actor is enumerated for. An empty/garbage
335
+ // accounts map yields an empty set → every account-scoped reference is
336
+ // rejected (fail-closed), which is the correct posture for an actor the
337
+ // operator declined to grant any account.
338
+ var set = Object.create(null);
339
+ if (accounts && typeof accounts === "object") {
340
+ var ids = Object.keys(accounts);
341
+ for (var i = 0; i < ids.length; i += 1) set[ids[i]] = true;
342
+ }
343
+ return set;
344
+ }
345
+
319
346
  // ---- Dispatch ------------------------------------------------------------
320
347
  //
321
348
  // `dispatch(actor, body)` is the operator-callable form — accepts a
@@ -340,6 +367,20 @@ function create(opts) {
340
367
  return _refusalResponse(errType, (e && e.message) || "request refused");
341
368
  }
342
369
 
370
+ // Resolve the actor's permitted accountId set ONCE for the whole
371
+ // request (RFC 8620 §3.6.1). Every account-scoped method call is gated
372
+ // against it below, BEFORE the operator handler runs, so a client can't
373
+ // name another tenant's accountId and reach the backend.
374
+ var permittedAccounts;
375
+ try {
376
+ permittedAccounts = await _permittedAccountIds(actor);
377
+ } catch (e) {
378
+ _emit("mail.server.jmap.accounts_for_threw",
379
+ { error: (e && e.message) || String(e) }, "failure");
380
+ return _refusalResponse("urn:ietf:params:jmap:error:serverFail",
381
+ "account authorization unavailable");
382
+ }
383
+
343
384
  var methodResponses = [];
344
385
  var byClientId = Object.create(null);
345
386
  for (var i = 0; i < parsed.methodCalls.length; i += 1) {
@@ -361,6 +402,24 @@ function create(opts) {
361
402
  description: "Method '" + methodName + "' not implemented on this server" }, clientId]);
362
403
  continue;
363
404
  }
405
+ // Cross-tenant gate (RFC 8620 §3.6.1): if the call names an accountId,
406
+ // it MUST be one the actor is enumerated for. Rejected BEFORE the
407
+ // operator handler runs so a forged/foreign accountId never reaches
408
+ // the backend. Calls without an accountId (account-agnostic methods)
409
+ // pass through unchanged.
410
+ if (resolvedArgs && typeof resolvedArgs === "object" &&
411
+ resolvedArgs.accountId !== undefined && resolvedArgs.accountId !== null) {
412
+ var callAccountId = resolvedArgs.accountId;
413
+ if (typeof callAccountId !== "string" || !permittedAccounts[callAccountId]) {
414
+ _emit("mail.server.jmap.account_not_found",
415
+ { method: methodName, accountId: typeof callAccountId === "string" ? callAccountId : null,
416
+ clientId: clientId }, "denied");
417
+ methodResponses.push(["error",
418
+ { type: "urn:ietf:params:jmap:error:accountNotFound",
419
+ description: "accountId is not accessible to this actor" }, clientId]);
420
+ continue;
421
+ }
422
+ }
364
423
  if (!_legacyDeprecationEmitted && registry.source(methodName) === "builtin") {
365
424
  _legacyDeprecationEmitted = true;
366
425
  _emit("mail.server.jmap.methods_opt_deprecated",
@@ -813,6 +872,40 @@ function create(opts) {
813
872
  if (refused) return;
814
873
  var bytes = collector.result();
815
874
  Promise.resolve()
875
+ // Cross-tenant gate (RFC 8620 §3.6.1): the accountId in the upload
876
+ // URL must be one the actor is enumerated for, else accountNotFound
877
+ // — the foreign accountId never reaches uploadBlob.
878
+ .then(function () { return _permittedAccountIds(actor); })
879
+ .then(function (permitted) {
880
+ if (!permitted[accountId]) {
881
+ _emit("mail.server.jmap.account_not_found",
882
+ { op: "upload", accountId: accountId }, "denied");
883
+ res.statusCode = 404;
884
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
885
+ res.end(JSON.stringify({
886
+ type: "urn:ietf:params:jmap:error:accountNotFound",
887
+ description: "accountId is not accessible to this actor",
888
+ }));
889
+ return;
890
+ }
891
+ return _completeUpload(bytes);
892
+ })
893
+ .catch(function (err) {
894
+ _emit("mail.server.jmap.upload_threw",
895
+ { accountId: accountId, error: (err && err.message) || String(err) }, "failure");
896
+ if (!res.headersSent) {
897
+ res.statusCode = 500;
898
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
899
+ res.end(JSON.stringify({
900
+ type: "urn:ietf:params:jmap:error:serverFail",
901
+ description: "Upload failed",
902
+ }));
903
+ }
904
+ });
905
+ });
906
+
907
+ function _completeUpload(bytes) {
908
+ return Promise.resolve()
816
909
  .then(function () { return opts.mailStore.uploadBlob(actor, accountId, contentType, bytes); })
817
910
  .then(function (meta) {
818
911
  if (!meta || typeof meta !== "object" || typeof meta.blobId !== "string") {
@@ -827,18 +920,10 @@ function create(opts) {
827
920
  type: meta.type || contentType,
828
921
  size: typeof meta.size === "number" ? meta.size : bytes.length,
829
922
  }));
830
- })
831
- .catch(function (err) {
832
- _emit("mail.server.jmap.upload_threw",
833
- { accountId: accountId, error: (err && err.message) || String(err) }, "failure");
834
- res.statusCode = 500;
835
- res.setHeader("Content-Type", "application/json; charset=utf-8");
836
- res.end(JSON.stringify({
837
- type: "urn:ietf:params:jmap:error:serverFail",
838
- description: "Upload failed",
839
- }));
840
923
  });
841
- });
924
+ // Errors from uploadBlob propagate to the req.on("end") chain's
925
+ // .catch (single serverFail responder, headersSent-guarded).
926
+ }
842
927
  req.on("error", function () {
843
928
  if (!refused) {
844
929
  refused = true;
@@ -944,9 +1029,29 @@ function create(opts) {
944
1029
  }));
945
1030
  return;
946
1031
  }
1032
+ var downloadDenied = false;
947
1033
  Promise.resolve()
948
- .then(function () { return opts.mailStore.downloadBlob(actor, accountId, blobId); })
1034
+ // Cross-tenant gate (RFC 8620 §3.6.1): the accountId in the download
1035
+ // URL must be one the actor is enumerated for, else accountNotFound —
1036
+ // the foreign accountId never reaches downloadBlob.
1037
+ .then(function () { return _permittedAccountIds(actor); })
1038
+ .then(function (permitted) {
1039
+ if (!permitted[accountId]) {
1040
+ downloadDenied = true;
1041
+ _emit("mail.server.jmap.account_not_found",
1042
+ { op: "download", accountId: accountId, blobId: blobId }, "denied");
1043
+ res.statusCode = 404;
1044
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
1045
+ res.end(JSON.stringify({
1046
+ type: "urn:ietf:params:jmap:error:accountNotFound",
1047
+ description: "accountId is not accessible to this actor",
1048
+ }));
1049
+ return undefined;
1050
+ }
1051
+ return opts.mailStore.downloadBlob(actor, accountId, blobId);
1052
+ })
949
1053
  .then(function (result) {
1054
+ if (downloadDenied) return;
950
1055
  if (!result || (typeof result !== "object" && !Buffer.isBuffer(result))) {
951
1056
  res.statusCode = 404;
952
1057
  res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -70,6 +70,7 @@
70
70
  var { defineClass } = require("./framework-error");
71
71
  var lazyRequire = require("./lazy-require");
72
72
  var validateOpts = require("./validate-opts");
73
+ var gateContract = require("./gate-contract");
73
74
 
74
75
  var audit = lazyRequire(function () { return require("./audit"); });
75
76
 
@@ -90,12 +91,7 @@ var PROFILES = Object.freeze({
90
91
  permissive: { threshold: 10.0, maxReasons: MAX_REASONS, maxReasonBytes: MAX_REASON_BYTES },
91
92
  });
92
93
 
93
- var COMPLIANCE_POSTURES = Object.freeze({
94
- hipaa: "strict",
95
- "pci-dss": "strict",
96
- gdpr: "strict",
97
- soc2: "strict",
98
- });
94
+ var COMPLIANCE_POSTURES = gateContract.ALL_STRICT_POSTURES;
99
95
 
100
96
  /**
101
97
  * @primitive b.mail.spamScore.create