@blamejs/core 0.7.107 → 0.8.4

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 (100) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/NOTICE +17 -1
  3. package/README.md +4 -3
  4. package/index.js +15 -0
  5. package/lib/asyncapi-bindings.js +160 -0
  6. package/lib/asyncapi-traits.js +143 -0
  7. package/lib/asyncapi.js +531 -0
  8. package/lib/audit-sign.js +1 -1
  9. package/lib/audit.js +68 -2
  10. package/lib/auth/acr-vocabulary.js +265 -0
  11. package/lib/auth/auth-time-tracker.js +111 -0
  12. package/lib/auth/elevation-grant.js +306 -0
  13. package/lib/auth/jwt.js +13 -0
  14. package/lib/auth/lockout.js +16 -3
  15. package/lib/auth/oauth.js +15 -1
  16. package/lib/auth/password.js +22 -2
  17. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  18. package/lib/auth/sd-jwt-vc.js +7 -2
  19. package/lib/auth/step-up-policy.js +335 -0
  20. package/lib/auth/step-up.js +445 -0
  21. package/lib/break-glass.js +53 -14
  22. package/lib/cache-redis.js +1 -1
  23. package/lib/cache.js +6 -1
  24. package/lib/cli.js +3 -3
  25. package/lib/cluster.js +24 -1
  26. package/lib/compliance-ai-act-logging.js +190 -0
  27. package/lib/compliance-ai-act-prohibited.js +205 -0
  28. package/lib/compliance-ai-act-risk.js +189 -0
  29. package/lib/compliance-ai-act-transparency.js +200 -0
  30. package/lib/compliance-ai-act.js +558 -0
  31. package/lib/compliance.js +12 -2
  32. package/lib/config-drift.js +2 -2
  33. package/lib/crypto-field.js +21 -1
  34. package/lib/crypto.js +114 -1
  35. package/lib/db.js +35 -4
  36. package/lib/dev.js +30 -3
  37. package/lib/dual-control.js +19 -1
  38. package/lib/external-db.js +10 -0
  39. package/lib/file-upload.js +30 -3
  40. package/lib/flag-cache.js +136 -0
  41. package/lib/flag-evaluation-context.js +135 -0
  42. package/lib/flag-providers.js +279 -0
  43. package/lib/flag-targeting.js +210 -0
  44. package/lib/flag.js +284 -0
  45. package/lib/guard-all.js +33 -16
  46. package/lib/guard-csv.js +16 -2
  47. package/lib/guard-html.js +35 -0
  48. package/lib/guard-svg.js +20 -0
  49. package/lib/http-client.js +57 -11
  50. package/lib/inbox.js +391 -0
  51. package/lib/log-stream-syslog.js +8 -0
  52. package/lib/log-stream.js +1 -1
  53. package/lib/mail-arc-sign.js +372 -0
  54. package/lib/mail-auth.js +2 -0
  55. package/lib/mail.js +40 -0
  56. package/lib/middleware/ai-act-disclosure.js +166 -0
  57. package/lib/middleware/asyncapi-serve.js +136 -0
  58. package/lib/middleware/attach-user.js +25 -2
  59. package/lib/middleware/bearer-auth.js +71 -6
  60. package/lib/middleware/body-parser.js +13 -0
  61. package/lib/middleware/cors.js +10 -0
  62. package/lib/middleware/csrf-protect.js +34 -3
  63. package/lib/middleware/dpop.js +3 -3
  64. package/lib/middleware/flag-context.js +76 -0
  65. package/lib/middleware/host-allowlist.js +1 -1
  66. package/lib/middleware/index.js +15 -0
  67. package/lib/middleware/openapi-serve.js +143 -0
  68. package/lib/middleware/require-aal.js +2 -2
  69. package/lib/middleware/require-step-up.js +186 -0
  70. package/lib/middleware/trace-propagate.js +1 -1
  71. package/lib/mtls-ca.js +23 -29
  72. package/lib/mtls-engine-default.js +21 -1
  73. package/lib/network-tls.js +21 -6
  74. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  75. package/lib/observability-otlp-exporter.js +35 -2
  76. package/lib/openapi-paths-builder.js +248 -0
  77. package/lib/openapi-schema-walk.js +192 -0
  78. package/lib/openapi-security.js +169 -0
  79. package/lib/openapi-yaml.js +154 -0
  80. package/lib/openapi.js +443 -0
  81. package/lib/outbox.js +3 -3
  82. package/lib/permissions.js +10 -1
  83. package/lib/pqc-agent.js +22 -1
  84. package/lib/pqc-software.js +195 -0
  85. package/lib/pubsub.js +8 -4
  86. package/lib/redact.js +26 -1
  87. package/lib/retention.js +26 -0
  88. package/lib/router.js +1 -0
  89. package/lib/scheduler.js +57 -1
  90. package/lib/session.js +3 -3
  91. package/lib/ssrf-guard.js +19 -4
  92. package/lib/static.js +12 -0
  93. package/lib/totp.js +16 -0
  94. package/lib/vault/index.js +3 -0
  95. package/lib/vault-aad.js +259 -0
  96. package/lib/vendor/MANIFEST.json +29 -0
  97. package/lib/vendor/noble-post-quantum.cjs +18 -0
  98. package/lib/ws-client.js +978 -0
  99. package/package.json +1 -1
  100. package/sbom.cyclonedx.json +6 -6
package/lib/crypto.js CHANGED
@@ -164,7 +164,26 @@ function verify(data, signature, publicKeyPem) {
164
164
  function encrypt(plaintext, publicKeys) {
165
165
  var mlkemPubPem = typeof publicKeys === "string" ? publicKeys : publicKeys.publicKey;
166
166
  var ecPubPem = typeof publicKeys === "string" ? null : publicKeys.ecPublicKey;
167
- if (!ecPubPem) return encryptMlkemOnly(plaintext, mlkemPubPem);
167
+ if (!ecPubPem) {
168
+ // Operator passed only an ML-KEM public key — silently dropping
169
+ // the P-384 hybrid leg means the operator's defense-in-depth
170
+ // posture (classical ECDH backstop on top of PQC KEM) is gone
171
+ // without any signal. Emit on next tick (crypto must not import
172
+ // audit synchronously — audit imports crypto for chain hashing).
173
+ // Operators who genuinely want KEM-only should call
174
+ // encryptMlkemOnly explicitly so this audit doesn't fire.
175
+ setImmediate(function () {
176
+ try {
177
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports crypto)
178
+ auditMod.safeEmit({
179
+ action: "system.crypto.hybrid_disabled",
180
+ outcome: "success",
181
+ metadata: { reason: "no-ec-public-key", note: "encrypt() received only mlkem; ecPublicKey absent — call encryptMlkemOnly explicitly to silence" },
182
+ });
183
+ } catch (_e) { /* drop-silent — best-effort */ }
184
+ });
185
+ return encryptMlkemOnly(plaintext, mlkemPubPem);
186
+ }
168
187
 
169
188
  var mlkemPub = nodeCrypto.createPublicKey(mlkemPubPem);
170
189
  var kem = nodeCrypto.encapsulate(mlkemPub);
@@ -361,6 +380,37 @@ function encryptMlkem768X25519(plaintext, recipient) {
361
380
  ]).toString("base64");
362
381
  }
363
382
 
383
+ // Symmetric named-pair to encryptMlkem768X25519. Operators wiring the
384
+ // IETF / Cloudflare / Chrome TLS-1.3 hybrid (codepoint 0x11EC) want
385
+ // the encrypt + decrypt halves under symmetric, discoverable names.
386
+ //
387
+ // The generic b.crypto.decrypt already dispatches by KEM ID and
388
+ // handles ML_KEM_768_X25519 envelopes correctly; this helper REJECTS
389
+ // any other KEM ID at the head, so an operator who calls
390
+ // decryptMlkem768X25519 with a ciphertext sealed under a different
391
+ // algorithm gets a clear error rather than the generic "unsupported
392
+ // KEM ID" path.
393
+ //
394
+ // recipient: { privateKey, x25519PrivateKey } — operator's keys
395
+ // ciphertext: base64 envelope from encryptMlkem768X25519
396
+ function decryptMlkem768X25519(ciphertext, recipient) {
397
+ if (!recipient || typeof recipient !== "object" ||
398
+ !recipient.privateKey || !recipient.x25519PrivateKey) {
399
+ throw new Error("decryptMlkem768X25519 requires { privateKey, x25519PrivateKey } " +
400
+ "(privateKey is the ML-KEM-768 PEM, x25519PrivateKey is the X25519 PEM)");
401
+ }
402
+ var packed = Buffer.from(ciphertext, "base64");
403
+ if (packed[0] !== C.ENVELOPE_MAGIC) {
404
+ throw new Error("decryptMlkem768X25519: invalid envelope (bad magic byte)");
405
+ }
406
+ if (packed[1] !== C.KEM_IDS.ML_KEM_768_X25519) {
407
+ throw new Error("decryptMlkem768X25519: envelope KEM ID is " + packed[1] +
408
+ ", expected " + C.KEM_IDS.ML_KEM_768_X25519 +
409
+ " (ML_KEM_768_X25519). Use b.crypto.decrypt for KEM-id dispatch.");
410
+ }
411
+ return decryptEnvelope(packed, recipient);
412
+ }
413
+
364
414
  // ---- Cert-peer envelope primitives ----
365
415
  //
366
416
  // The framework's default `encrypt` / `decrypt` source the recipient
@@ -487,6 +537,65 @@ function decryptEnvelopeAsCertPeer(envelope, opts) {
487
537
  // Operator-audit accessor — exposes every supported KEM hybrid for
488
538
  // compliance audit visibility ("which envelopes does this deploy
489
539
  // accept on decrypt?").
540
+ // ---- Certificate fingerprint helpers ----
541
+ //
542
+ // Operators pinning peer-cert fingerprints (mtls bootstrap, webhook
543
+ // verification, certificate transparency cross-checks) want a stable
544
+ // SHA3-512 hash of the DER bytes plus a colon-separated hex form that
545
+ // matches what most operator tooling renders for X.509 fingerprints.
546
+ // hashCertFingerprint accepts either a Buffer (DER) or a PEM string;
547
+ // if PEM, the BEGIN/END envelope is stripped and the base64 body is
548
+ // decoded before hashing. The hash is the framework's standard SHA3-
549
+ // 512 (not SHA-256 — operators using OpenSSL's `-sha256` defaults can
550
+ // keep their own SHA-256 hashes, this primitive is the framework-
551
+ // canonical form). Returns { hex, colon } so callers can compare
552
+ // against either rendering.
553
+ function _pemToDer(pemOrDer) {
554
+ if (Buffer.isBuffer(pemOrDer)) return pemOrDer;
555
+ if (typeof pemOrDer !== "string") {
556
+ throw new TypeError("crypto.hashCertFingerprint: input must be a Buffer (DER) or a PEM-encoded string");
557
+ }
558
+ var match = pemOrDer.match(/-----BEGIN [A-Z0-9 ]+-----([\s\S]+?)-----END [A-Z0-9 ]+-----/);
559
+ if (!match) {
560
+ throw new TypeError("crypto.hashCertFingerprint: PEM input lacks BEGIN/END markers");
561
+ }
562
+ return Buffer.from(match[1].replace(/\s+/g, ""), "base64");
563
+ }
564
+ function hashCertFingerprint(pemOrDer) {
565
+ var der = _pemToDer(pemOrDer);
566
+ var digest = hash(der, "sha3-512");
567
+ var hex = digest.toString("hex");
568
+ // Colon-separated, uppercase — matches openssl x509 -fingerprint
569
+ // output style (which is SHA-1 by default, but the rendering shape
570
+ // operators expect is the same).
571
+ var colon = hex.toUpperCase().match(/.{2}/g).join(":");
572
+ return { hex: hex, colon: colon };
573
+ }
574
+ // Compares a peer's PEM/DER cert against an allowlist of pinned
575
+ // fingerprints. Allowlist entries may be the colon form, the lower-
576
+ // case hex form, or both — every comparison runs through
577
+ // timingSafeEqual to avoid leaking which entry matched.
578
+ function isCertRevoked(pemOrDer, denyList) {
579
+ if (!Array.isArray(denyList)) {
580
+ throw new TypeError("crypto.isCertRevoked: denyList must be an array of fingerprint strings");
581
+ }
582
+ var fp = hashCertFingerprint(pemOrDer);
583
+ var fpHex = Buffer.from(fp.hex, "hex");
584
+ var fpColon = Buffer.from(fp.colon);
585
+ for (var i = 0; i < denyList.length; i++) {
586
+ var entry = denyList[i];
587
+ if (typeof entry !== "string" || entry.length === 0) continue;
588
+ var normalized = entry.indexOf(":") !== -1 ? entry.toUpperCase() : entry.toLowerCase();
589
+ var normalizedBuf = entry.indexOf(":") !== -1 ? Buffer.from(normalized) : Buffer.from(normalized, "hex");
590
+ var compareBuf = entry.indexOf(":") !== -1 ? fpColon : fpHex;
591
+ if (normalizedBuf.length === compareBuf.length &&
592
+ nodeCrypto.timingSafeEqual(normalizedBuf, compareBuf)) {
593
+ return true;
594
+ }
595
+ }
596
+ return false;
597
+ }
598
+
490
599
  var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
491
600
  { id: "ml-kem-1024", envelopeId: C.KEM_IDS.ML_KEM_1024, description: "ML-KEM-1024 KEM-only (legacy single-component)" },
492
601
  { id: "ml-kem-1024-p384", envelopeId: C.KEM_IDS.ML_KEM_1024_P384, description: "ML-KEM-1024 + ECDH P-384 hybrid (framework default)" },
@@ -501,6 +610,9 @@ module.exports = {
501
610
  kdf: kdf,
502
611
  // Comparison
503
612
  timingSafeEqual: timingSafeEqual,
613
+ // Cert fingerprint helpers
614
+ hashCertFingerprint: hashCertFingerprint,
615
+ isCertRevoked: isCertRevoked,
504
616
  // Random
505
617
  generateBytes: generateBytes,
506
618
  generateToken: generateToken,
@@ -515,6 +627,7 @@ module.exports = {
515
627
  encrypt: encrypt,
516
628
  decrypt: decrypt,
517
629
  encryptMlkem768X25519: encryptMlkem768X25519,
630
+ decryptMlkem768X25519: decryptMlkem768X25519,
518
631
  encryptEnvelopeAsCertPeer: encryptEnvelopeAsCertPeer,
519
632
  decryptEnvelopeAsCertPeer: decryptEnvelopeAsCertPeer,
520
633
  SUPPORTED_KEM_ALGORITHMS: SUPPORTED_KEM_ALGORITHMS,
package/lib/db.js CHANGED
@@ -585,7 +585,21 @@ function decryptToTmp() {
585
585
  }
586
586
  var packed = fs.readFileSync(encPath);
587
587
  if (packed.length < 26) return; // too short to be a valid envelope
588
- atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
588
+ // AAD binds the envelope to this deployment's data dir so two
589
+ // installs sharing the same operator passphrase can't swap each
590
+ // other's db.enc files. Backwards-compat: if the AAD-bound decrypt
591
+ // fails, retry without AAD for envelopes written by pre-AAD
592
+ // versions (one-release transition window).
593
+ var aad = _dbEncAad(dataDir);
594
+ try {
595
+ atomicFile.writeSync(dbPath, decryptPacked(packed, encKey, aad));
596
+ } catch (_e) {
597
+ atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
598
+ }
599
+ }
600
+
601
+ function _dbEncAad(dir) {
602
+ return Buffer.from("blamejs.db-enc.v1\0" + (dir || ""), "utf8");
589
603
  }
590
604
 
591
605
  function encryptToDisk() {
@@ -593,7 +607,7 @@ function encryptToDisk() {
593
607
  // Force WAL checkpoint so the .db file holds all committed transactions.
594
608
  try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
595
609
  if (!fs.existsSync(dbPath)) return;
596
- atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey));
610
+ atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey, _dbEncAad(dataDir)));
597
611
  }
598
612
 
599
613
  // Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
@@ -691,6 +705,23 @@ async function init(opts) {
691
705
  // dominates with audit-chain emissions and cascade fan-out.
692
706
  runSql(database, "PRAGMA secure_delete=ON");
693
707
 
708
+ // Boot-time integrity check — refuse to boot on B-tree corruption.
709
+ // SQLite normally surfaces corruption only when a query stumbles on
710
+ // a bad page; that's a "first failure during request handling"
711
+ // surface, not a clean fail-closed boot. integrity_check is fast on
712
+ // the freshly-decrypted-into-tmpfs file (<1 second on a typical
713
+ // multi-MB DB) and the result is "ok" or a list of issues.
714
+ if (opts.skipBootIntegrityCheck !== true) {
715
+ var ic = database.prepare("PRAGMA integrity_check").all();
716
+ var icIssues = ic.map(function (r) { return r && r.integrity_check; })
717
+ .filter(function (s) { return s && s !== "ok"; });
718
+ if (icIssues.length > 0) {
719
+ throw new DbError("db/integrity-check-failed",
720
+ "PRAGMA integrity_check at boot reported " + icIssues.length +
721
+ " issue(s): " + icIssues.slice(0, 3).join("; "));
722
+ }
723
+ }
724
+
694
725
  // PRAGMA integrity_check — refuse boot on B-tree corruption (per
695
726
  // audit-batch finding). SQLite returns "ok" for a healthy database;
696
727
  // any other result means corruption. Catching it at boot beats
@@ -1264,7 +1295,7 @@ module.exports = {
1264
1295
  catch (_e) { /* drop-silent */ }
1265
1296
  if (auditOn) {
1266
1297
  try { audit.safeEmit({
1267
- action: "system.db.integrity_ok", outcome: "ok", metadata: {},
1298
+ action: "system.db.integrity_ok", outcome: "success", metadata: {},
1268
1299
  }); } catch (_e) { /* drop-silent */ }
1269
1300
  }
1270
1301
  return;
@@ -1274,7 +1305,7 @@ module.exports = {
1274
1305
  catch (_e) { /* drop-silent */ }
1275
1306
  if (auditOn) {
1276
1307
  try { audit.safeEmit({
1277
- action: "system.db.integrity_corrupt", outcome: "fail",
1308
+ action: "system.db.integrity_corrupt", outcome: "failure",
1278
1309
  metadata: { issueCount: issues.length },
1279
1310
  }); } catch (_e) { /* drop-silent */ }
1280
1311
  }
package/lib/dev.js CHANGED
@@ -48,13 +48,24 @@
48
48
  */
49
49
 
50
50
  var path = require("path");
51
- var childProcess = require("child_process");
52
51
  var fs = require("fs");
52
+ var lazyRequire = require("./lazy-require");
53
53
  var logModule = require("./log");
54
54
  var nb = require("./numeric-bounds");
55
+ var safeEnv = require("./parsers/safe-env");
55
56
  var validateOpts = require("./validate-opts");
56
57
  var { FrameworkError } = require("./framework-error");
57
58
 
59
+ // child_process is required ONLY when dev.create() is actually
60
+ // called — not at module load. The dev primitive spawns subprocesses
61
+ // (nodemon-style restart loop) by design; isolating the dependency
62
+ // behind lazy-load means a production process that never calls
63
+ // b.dev.create() never loads child_process, and supply-chain scanners
64
+ // inspecting a deployed bundle don't see it as a top-level dep of an
65
+ // otherwise hermetic framework. Production deployments additionally
66
+ // refuse to construct dev.create() — see _refuseInProduction below.
67
+ var childProcess = lazyRequire(function () { return require("child_process"); });
68
+
58
69
  class DevError extends FrameworkError {
59
70
  constructor(code, message) {
60
71
  super(message, code);
@@ -123,9 +134,25 @@ function create(opts) {
123
134
  var env = opts.env || process.env;
124
135
  var cwd = opts.cwd || process.cwd();
125
136
 
126
- // Test seams
137
+ // dev.create() is intended for development-mode use only —
138
+ // restarting subprocesses on file change is a feature operators
139
+ // run on their laptop, never in production. Refusing here means a
140
+ // mis-configured production deployment that accidentally wires the
141
+ // dev primitive crashes loudly at boot rather than spawning shells
142
+ // on every save. Operators with a legitimate cross-cutting need
143
+ // (e.g. a CI runner that uses dev() to drive end-to-end tests)
144
+ // explicitly opt in via opts.allowProduction with an audited reason.
145
+ if (safeEnv.readVar("NODE_ENV") === "production" && !opts.allowProduction) {
146
+ throw new DevError("dev/refused-in-production",
147
+ "b.dev.create: dev mode refuses to load when NODE_ENV=production. " +
148
+ "Set opts.allowProduction:true with an audited reason if a non-dev " +
149
+ "context legitimately needs subprocess spawn-on-watch behaviour.");
150
+ }
151
+
152
+ // Test seams. childProcess is lazily-required; calling spawn here
153
+ // pulls in node's child_process module on first dev.create() use.
127
154
  var spawnFn = opts._spawn || function (cmd, sargs, sopts) {
128
- return childProcess.spawn(cmd, sargs, sopts);
155
+ return childProcess().spawn(cmd, sargs, sopts);
129
156
  };
130
157
  var watchFn = opts._watch || function (dir, wopts, listener) {
131
158
  return fs.watch(dir, wopts, listener);
@@ -186,7 +186,25 @@ function create(opts) {
186
186
  if (!approverRoles) return true;
187
187
  if (!actor || !Array.isArray(actor.roles)) return false;
188
188
  for (var i = 0; i < approverRoles.length; i++) {
189
- if (actor.roles.indexOf(approverRoles[i]) !== -1) return true;
189
+ var required = approverRoles[i];
190
+ // Wildcard match — actor's "security:*" satisfies a required
191
+ // "security:officer" (matching the b.permissions.match
192
+ // semantics elsewhere in the framework). Without this, an
193
+ // operator with a wildcard-shaped role can't approve dual-
194
+ // control flows even when b.permissions would consider the
195
+ // role assignment satisfied.
196
+ for (var j = 0; j < actor.roles.length; j++) {
197
+ var actorRole = actor.roles[j];
198
+ if (actorRole === required) return true;
199
+ if (typeof actorRole === "string" &&
200
+ actorRole.length > 0 &&
201
+ actorRole.charAt(actorRole.length - 1) === "*") {
202
+ var prefix = actorRole.slice(0, -1);
203
+ if (typeof required === "string" && required.indexOf(prefix) === 0) {
204
+ return true;
205
+ }
206
+ }
207
+ }
190
208
  }
191
209
  return false;
192
210
  }
@@ -570,6 +570,16 @@ function _buildSessionGucsStatements(sessionGucs) {
570
570
  // Postgres SET accepts on/off/true/false — render true/false.
571
571
  literal = value ? "true" : "false";
572
572
  } else if (typeof value === "string") {
573
+ // Cap the value length so an operator-controlled tenant_id of
574
+ // 100 KB doesn't hit Postgres' SET LOCAL parser with payload
575
+ // that bloats query logs and consumes max_stack_depth. The cap
576
+ // is generous for legitimate tenant identifiers but rejects
577
+ // amplification.
578
+ if (value.length > C.BYTES.kib(4)) {
579
+ throw _err("INVALID_SESSION_GUCS",
580
+ "sessionGucs['" + name + "']: value exceeds 4 KiB cap (got " +
581
+ value.length + " chars)", true);
582
+ }
573
583
  literal = "'" + value.replace(/'/g, "''") + "'";
574
584
  } else if (value === null || value === undefined) {
575
585
  throw _err("INVALID_SESSION_GUCS",
@@ -763,7 +763,7 @@ function create(opts) {
763
763
  return { paths: paths, totalBytes: totalBytes, totalHashHex: totalHashHex };
764
764
  }
765
765
 
766
- function _checkAllowedFileType(firstChunkBody) {
766
+ function _checkAllowedFileType(firstChunkBody, claimedMime) {
767
767
  if (!allowedFileTypes || allowedFileTypes.length === 0) return;
768
768
  if (!fileType) return; // create() guards this; defensive
769
769
  var detected = fileType.detect(firstChunkBody);
@@ -787,6 +787,30 @@ function create(opts) {
787
787
  "fileUpload.finalize: detected MIME '" + detectedMime +
788
788
  "' not in allowedFileTypes (" + allowedFileTypes.join(", ") + ")");
789
789
  }
790
+ // Cross-check claimed MIME (Content-Type header) against detected
791
+ // magic bytes. When both exist and disagree, refuse — downstream
792
+ // renderers / storage layers that trust metadata.contentType
793
+ // (CDN cache routing, MIME-sniff fallbacks, attachment-disposition
794
+ // decisions) will mis-handle the file. The strict-MIME check is
795
+ // load-bearing for image-pipelines that pass to image processors:
796
+ // a "image/png" claim with PDF magic bytes lands in image-rendering
797
+ // code-paths that will exec PDF parsers with surprising semantics.
798
+ if (claimedMime && typeof claimedMime === "string" && claimedMime.indexOf("/") !== -1) {
799
+ var claimedNormalized = claimedMime.split(";")[0].trim().toLowerCase();
800
+ if (claimedNormalized && claimedNormalized !== detectedMime) {
801
+ // Wildcard / family acceptance: "image/jpeg" claim + "image/jpg"
802
+ // detect (synonyms) is OK; "image/png" claim + "image/jpeg"
803
+ // detect is NOT.
804
+ var claimedFamily = claimedNormalized.split("/")[0];
805
+ var detectedFamily = detectedMime.split("/")[0];
806
+ if (claimedFamily !== detectedFamily) {
807
+ throw _err("MIME_CLAIM_MISMATCH",
808
+ "fileUpload.finalize: claimed Content-Type '" + claimedNormalized +
809
+ "' disagrees with detected magic-byte MIME '" + detectedMime +
810
+ "'. Refusing to proceed with mis-typed file.");
811
+ }
812
+ }
813
+ }
790
814
  }
791
815
 
792
816
  function _streamFromChunkPaths(paths /* totalBytes */) {
@@ -842,8 +866,11 @@ function create(opts) {
842
866
  firstChunk = pieces[0];
843
867
  }
844
868
 
845
- // MIME allowlist gate (if configured).
846
- try { _checkAllowedFileType(firstChunk); }
869
+ // MIME allowlist gate (if configured). Pass the operator-supplied
870
+ // contentType from upload metadata so the cross-check can refuse
871
+ // claimed-vs-detected mismatches.
872
+ var claimedMime = (meta && meta.metadata && meta.metadata.contentType) || null;
873
+ try { _checkAllowedFileType(firstChunk, claimedMime); }
847
874
  catch (e) {
848
875
  _emitObs("fileUpload.mime_rejected", 1);
849
876
  _emitAudit("fileUpload.finalize", {
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * Flag-evaluation cache — per-targetingKey TTL'd cache wrapping a
4
+ * downstream provider so a high-traffic request path does not hit
5
+ * the provider on every flag read.
6
+ *
7
+ * var raw = b.flag.providers.localFile({ path: "./flags.json", watch: true });
8
+ * var cached = b.flag.cache(raw, { ttlMs: 60_000, maxEntries: 10000 });
9
+ *
10
+ * var flag = b.flag.create({ provider: cached });
11
+ *
12
+ * Cache key: `${targetingKey}::${flagKey}`. Entries TTL out after
13
+ * `ttlMs` (default 30 s). When the cache hits its `maxEntries` cap,
14
+ * oldest entries are evicted (insertion-order via a Map).
15
+ *
16
+ * Cache is bypassed for evaluation contexts without a `targetingKey`
17
+ * (flag value depends on every attribute, not a stable key).
18
+ *
19
+ * Operators with a hot-reload need pass `bustOn: "flag-reload"` and
20
+ * call `cached.bust()` from their reload handler — clears the entire
21
+ * map.
22
+ */
23
+
24
+ var validateOpts = require("./validate-opts");
25
+ var lazyRequire = require("./lazy-require");
26
+ var C = require("./constants");
27
+ var { defineClass } = require("./framework-error");
28
+ var FlagError = defineClass("FlagError", { alwaysPermanent: true });
29
+
30
+ var audit = lazyRequire(function () { return require("./audit"); });
31
+
32
+ function cache(downstream, opts) {
33
+ opts = opts || {};
34
+ validateOpts(opts, ["ttlMs", "maxEntries", "audit"], "flag.cache");
35
+ if (!downstream || typeof downstream.evaluate !== "function") {
36
+ throw new FlagError("flag/bad-cache",
37
+ "cache: downstream provider must implement .evaluate()");
38
+ }
39
+ // allow:numeric-opt-Infinity — defaults clamped + ttl-floor enforced below
40
+ var ttlMs = (typeof opts.ttlMs === "number" && opts.ttlMs > 0)
41
+ ? opts.ttlMs
42
+ : C.TIME.seconds(30);
43
+ if (ttlMs < C.TIME.seconds(1)) {
44
+ throw new FlagError("flag/bad-cache",
45
+ "cache: ttlMs must be >= 1000ms - got " + ttlMs);
46
+ }
47
+ // allow:numeric-opt-Infinity — maxEntries default + Math.floor coerce; throws on bad type at config time
48
+ var maxEntries = (typeof opts.maxEntries === "number" && opts.maxEntries > 0)
49
+ ? Math.floor(opts.maxEntries)
50
+ : 10000; // allow:raw-byte-literal — entry-count default
51
+ var auditOn = opts.audit === true; // off by default — too chatty
52
+ var entries = new Map();
53
+ var hits = 0;
54
+ var misses = 0;
55
+ var evictions = 0;
56
+
57
+ function _evictExpired(nowMs) {
58
+ var iter = entries.entries();
59
+ var step = iter.next();
60
+ while (!step.done) {
61
+ if (step.value[1].expiresAt <= nowMs) {
62
+ entries.delete(step.value[0]);
63
+ evictions += 1;
64
+ }
65
+ step = iter.next();
66
+ }
67
+ }
68
+
69
+ function _evictOldest() {
70
+ var first = entries.keys().next();
71
+ if (!first.done) {
72
+ entries.delete(first.value);
73
+ evictions += 1;
74
+ }
75
+ }
76
+
77
+ return {
78
+ kind: "cache:" + (downstream.kind || "unknown"),
79
+ list: typeof downstream.list === "function"
80
+ ? function () { return downstream.list(); }
81
+ : function () { return []; },
82
+ evaluate: function (flagKey, ctx) {
83
+ var tk = (ctx && typeof ctx.targetingKey === "string") ? ctx.targetingKey : null;
84
+ if (!tk) {
85
+ misses += 1;
86
+ return downstream.evaluate(flagKey, ctx);
87
+ }
88
+ var key = tk + "::" + flagKey;
89
+ var now = Date.now();
90
+ var entry = entries.get(key);
91
+ if (entry && entry.expiresAt > now) {
92
+ hits += 1;
93
+ return entry.value;
94
+ }
95
+ if (entry) entries.delete(key);
96
+ var freshResult = downstream.evaluate(flagKey, ctx);
97
+ misses += 1;
98
+ // Don't cache flag-not-found — operator might add it later.
99
+ if (freshResult && freshResult.reason !== "flag_not_found") {
100
+ if (entries.size >= maxEntries) _evictOldest();
101
+ entries.set(key, { value: freshResult, expiresAt: now + ttlMs });
102
+ }
103
+ // Periodic sweep — evict expired on every 100th miss.
104
+ if (misses % 100 === 0) _evictExpired(now);
105
+ return freshResult;
106
+ },
107
+ bust: function () {
108
+ var prevSize = entries.size;
109
+ entries.clear();
110
+ if (auditOn) {
111
+ try {
112
+ audit().safeEmit({
113
+ action: "flag.cache.bust",
114
+ outcome: "success",
115
+ actor: null,
116
+ metadata: { prevSize: prevSize },
117
+ });
118
+ } catch (_e) { /* drop-silent */ }
119
+ }
120
+ return prevSize;
121
+ },
122
+ stats: function () {
123
+ return {
124
+ size: entries.size,
125
+ hits: hits,
126
+ misses: misses,
127
+ evictions: evictions,
128
+ hitRatio: (hits + misses) === 0 ? 0 : hits / (hits + misses),
129
+ ttlMs: ttlMs,
130
+ maxEntries: maxEntries,
131
+ };
132
+ },
133
+ };
134
+ }
135
+
136
+ module.exports = { cache: cache, FlagError: FlagError };
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ /**
3
+ * Flag evaluation context — the operator-supplied object describing
4
+ * the subject of a flag evaluation: targeting key, user attributes,
5
+ * tenant id, environment, custom attributes.
6
+ *
7
+ * Per the OpenFeature specification:
8
+ * - `targetingKey` is the canonical identity for percentage-bucket
9
+ * stickiness (so a 50% rollout consistently picks the SAME 50%
10
+ * of users across re-evaluations).
11
+ * - All other attributes flow through targeting rules.
12
+ *
13
+ * The framework's helper produces a frozen, normalised context object.
14
+ * Operators compose contexts incrementally: start from `fromRequest`
15
+ * (extracts user / tenant / locale from req), augment with `merge`,
16
+ * then evaluate.
17
+ */
18
+
19
+ var nodeCrypto = require("crypto");
20
+ var validateOpts = require("./validate-opts");
21
+ var lazyRequire = require("./lazy-require");
22
+ var { defineClass } = require("./framework-error");
23
+ var FlagError = defineClass("FlagError", { alwaysPermanent: true });
24
+
25
+ var fwCrypto = lazyRequire(function () { return require("./crypto"); });
26
+
27
+ function _normalize(input, label) {
28
+ if (input == null) return {};
29
+ if (typeof input !== "object" || Array.isArray(input)) {
30
+ throw new FlagError("flag/bad-context",
31
+ (label || "context") + ": must be a plain object");
32
+ }
33
+ var out = {};
34
+ for (var key in input) {
35
+ if (!Object.prototype.hasOwnProperty.call(input, key)) continue;
36
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
37
+ continue; // poisoned-keys defense
38
+ }
39
+ out[key] = input[key];
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function create(input) {
45
+ var normalised = _normalize(input, "create");
46
+ if (normalised.targetingKey != null &&
47
+ typeof normalised.targetingKey !== "string") {
48
+ throw new FlagError("flag/bad-context",
49
+ "create: targetingKey must be a string");
50
+ }
51
+ return Object.freeze(normalised);
52
+ }
53
+
54
+ function merge(base, overlay) {
55
+ var b = _normalize(base, "merge.base");
56
+ var o = _normalize(overlay, "merge.overlay");
57
+ var out = {};
58
+ for (var k1 in b) {
59
+ if (Object.prototype.hasOwnProperty.call(b, k1)) out[k1] = b[k1];
60
+ }
61
+ for (var k2 in o) {
62
+ if (Object.prototype.hasOwnProperty.call(o, k2)) out[k2] = o[k2];
63
+ }
64
+ return Object.freeze(out);
65
+ }
66
+
67
+ function fromRequest(req, opts) {
68
+ opts = opts || {};
69
+ validateOpts(opts, ["userKey", "tenantKey", "extra"], "flag.context.fromRequest");
70
+ if (!req || typeof req !== "object") {
71
+ return create({});
72
+ }
73
+ var ctx = {};
74
+ if (req.user) {
75
+ if (typeof req.user.id === "string") ctx.userId = req.user.id;
76
+ if (typeof req.user.role === "string") ctx.role = req.user.role;
77
+ if (typeof req.user.email === "string") ctx.email = req.user.email;
78
+ if (req.user.tenantId != null) ctx.tenantId = req.user.tenantId;
79
+ }
80
+ var headers = req.headers || {};
81
+ if (typeof headers["accept-language"] === "string") {
82
+ ctx.locale = headers["accept-language"].split(",")[0].split(";")[0].trim();
83
+ }
84
+ if (typeof headers["user-agent"] === "string") {
85
+ ctx.userAgent = headers["user-agent"];
86
+ }
87
+ // Targeting key: prefer explicit userKey opt, then user.id, then a
88
+ // request-stable hash of (clientIp + userAgent) for anonymous flows.
89
+ var tk = null;
90
+ if (typeof opts.userKey === "string" && opts.userKey.length > 0) {
91
+ tk = opts.userKey;
92
+ } else if (req.user && typeof req.user.id === "string") {
93
+ tk = req.user.id;
94
+ } else {
95
+ var ip = (typeof headers["x-forwarded-for"] === "string" &&
96
+ headers["x-forwarded-for"].split(",")[0].trim()) ||
97
+ (req.connection && req.connection.remoteAddress) || "";
98
+ var ua = headers["user-agent"] || "";
99
+ tk = "anon:" + fwCrypto().sha3Hash(ip + ":" + ua).slice(0, 16); // allow:raw-byte-literal — base16 prefix len
100
+ }
101
+ ctx.targetingKey = tk;
102
+
103
+ if (opts.extra && typeof opts.extra === "object") {
104
+ for (var k in opts.extra) {
105
+ if (Object.prototype.hasOwnProperty.call(opts.extra, k)) {
106
+ if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
107
+ ctx[k] = opts.extra[k];
108
+ }
109
+ }
110
+ }
111
+ return create(ctx);
112
+ }
113
+
114
+ // Percentage-bucket helper — deterministic hash of (targetingKey +
115
+ // flagKey) into [0, 100) for percentage-based rollouts.
116
+ function bucketOf(targetingKey, flagKey) {
117
+ if (typeof targetingKey !== "string" || typeof flagKey !== "string" ||
118
+ targetingKey.length === 0 || flagKey.length === 0) {
119
+ return 0;
120
+ }
121
+ var digest = nodeCrypto.createHash("sha3-512")
122
+ .update(flagKey + ":" + targetingKey).digest();
123
+ // Use first 4 bytes as a uint32, then mod 10000 → 0.00-99.99 with
124
+ // sub-percent granularity.
125
+ var n = digest.readUInt32BE(0);
126
+ return (n % 10000) / 100; // allow:raw-byte-literal — bucket-precision divisor
127
+ }
128
+
129
+ module.exports = {
130
+ create: create,
131
+ merge: merge,
132
+ fromRequest: fromRequest,
133
+ bucketOf: bucketOf,
134
+ FlagError: FlagError,
135
+ };