@blamejs/core 0.7.18 → 0.7.19

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 (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
package/lib/api-key.js CHANGED
@@ -1,660 +1,660 @@
1
- "use strict";
2
- /**
3
- * b.apiKey — operator-facing API-key issuance, verification, revocation,
4
- * and rotation.
5
- *
6
- * var keys = b.apiKey.create({
7
- * namespace: "live",
8
- * audit: b.audit, // optional
9
- * trackLastUsedAt: false, // default
10
- * });
11
- *
12
- * var issued = await keys.issue({
13
- * ownerId: "user-42",
14
- * scopes: ["read:users", "write:posts"],
15
- * metadata: { name: "Mobile app v3" },
16
- * expiresAt: Date.now() + b.constants.TIME.days(90),
17
- * });
18
- * // issued.key — "bk_live_<idHex>_<secretHex>" (returned ONCE)
19
- * // issued.id — "<idHex>"
20
- *
21
- * var record = await keys.verify(req.headers["x-api-key"]);
22
- * // → { id, ownerId, scopes, metadata, ... } or null
23
- *
24
- * await keys.revoke(id);
25
- * var rotated = await keys.rotate(id); // new secret; old stops working
26
- * var owned = await keys.listForOwner("user-42");
27
- *
28
- * Token format (Stripe-style, prefix-recognizable):
29
- *
30
- * <prefix>_<namespace>_<idHex>_<secretHex>
31
- *
32
- * Example: `bk_live_5b9e7c8a4f2d1e3a_8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d`
33
- *
34
- * - prefix operator-supplied; default "bk". Visual marker.
35
- * - namespace operator-supplied; lets multiple key registries coexist
36
- * (e.g. "live"/"test", "v1"/"v2") without collision.
37
- * - idHex opaque random hex; PRIMARY KEY component (DB lookup).
38
- * - secretHex opaque random hex; never re-derivable. Stored as
39
- * SHA3-512 hash, constant-time-compared on verify.
40
- *
41
- * Storage: framework table `_blamejs_api_keys` (sealed columns:
42
- * ownerId/scopes/metadata; ownerIdHash for indexed listForOwner).
43
- * Same dual-storage pattern as sessions — local SQLite in single-node
44
- * mode, external-db in cluster mode, dispatched via cluster-storage.
45
- *
46
- * Validation policy:
47
- *
48
- * - apiKey.create opts → throw at config time
49
- * - registry.issue opts → throw ApiKeyError at call site
50
- * - registry.rotate(id) on missing/revoked → throw ApiKeyError at call site
51
- * - registry.verify(token) on any failure → return null (tolerant read)
52
- * - registry.revoke(id) on missing → return false (tolerant read)
53
- * - registry.getById(id) on missing → return null (tolerant read)
54
- */
55
-
56
- var crypto = require("./crypto");
57
- var credentialHash = require("./credential-hash");
58
- var safeJson = require("./safe-json");
59
- var lazyRequire = require("./lazy-require");
60
- var clusterStorage = require("./cluster-storage");
61
- var cluster = require("./cluster");
62
- var cryptoField = require("./crypto-field");
63
- var requestHelpers = require("./request-helpers");
64
- var validateOpts = require("./validate-opts");
65
- var C = require("./constants");
66
- var numericChecks = require("./numeric-checks");
67
- var { ApiKeyError } = require("./framework-error");
68
-
69
- var observability = lazyRequire(function () { return require("./observability"); });
70
-
71
- function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
72
-
73
- var _err = ApiKeyError.factory;
74
-
75
- var TABLE = "_blamejs_api_keys";
76
- // Pre-quoted form for SQL interpolation. Defense-in-depth: even though
77
- // our constant is bare-identifier-shaped, every interpolation site uses
78
- // the wrapped form so a future rename to a reserved-word or
79
- // whitespace-bearing name would still resolve correctly.
80
- var Q_TABLE = '"' + TABLE + '"';
81
-
82
- // Column order used for INSERT — kept as a constant so the placeholders
83
- // list and the values list stay in sync. Must match _blamejs_api_keys'
84
- // schema in db.js (single-node) and framework-schema.js (cluster mode).
85
- var COLS = [
86
- "id", "namespace", "ownerId", "ownerIdHash", "secretHash",
87
- "secondarySecretHash", "secondaryExpiresAt",
88
- "scopes", "metadata", "createdAt", "expiresAt", "revokedAt",
89
- "lastUsedAt", "prefix",
90
- ];
91
-
92
- // Default rotate grace period when caller passes { graceful: true }
93
- // without an explicit gracePeriodMs. 7 days is enough to migrate the
94
- // vast majority of clients without paging anyone, short enough that
95
- // a forgotten old secret stops working before it becomes a long-tail
96
- // liability.
97
- var DEFAULT_ROTATE_GRACE_MS = C.TIME.days(7);
98
-
99
- // Visibility defaults are ON. When an operator wires `audit: b.audit`,
100
- // they're declaring "I want to know what happens with these credentials"
101
- // — that includes verify success (every access to a credential), reads
102
- // (listForOwner/getById), and all state changes. The compliance trail
103
- // (HIPAA §164.312(b), PCI-DSS 10.2.1, GDPR Art. 32) needs WHO + WHAT +
104
- // WHEN on every access, not just on writes.
105
- //
106
- // Operators with extreme verify-rate volume opt OUT explicitly via
107
- // `auditSuccess: false`. Failures stay on regardless.
108
- var DEFAULTS = Object.freeze({
109
- prefix: "bk",
110
- idBytes: C.BYTES.bytes(8), // 16 hex chars
111
- secretBytes: C.BYTES.bytes(16), // 32 hex chars
112
- trackLastUsedAt: true, // visibility on dormant / leaked keys
113
- auditFailures: true, // failure events are actionable signals
114
- auditSuccess: true, // compliance trail — opt out at extreme volume
115
- purgeAfterMs: C.TIME.days(90),
116
- // Credential hash algorithm for new issues. Falls through to
117
- // credentialHash defaults; SHAKE256 is the active per-framework
118
- // because api-key secrets are 128-bit random (memory-hard property
119
- // buys nothing at that entropy) and SHAKE256 is an XOF — the
120
- // envelope payload length itself drives the digest size, so a
121
- // future operator can request 96-byte digests without an algorithm
122
- // rotation. Operators with low-entropy or paranoia-mode storage
123
- // pin "argon2id" per registry. The envelope ensures historical
124
- // credentials always remain verifiable.
125
- hashAlgo: "shake256",
126
- });
127
-
128
- // ---- Config-time validation helpers (throw on bad input) ----
129
-
130
- var _isPositiveInt = numericChecks.isPositiveInt;
131
-
132
- function _validateIdentifier(name, value) {
133
- if (typeof value !== "string" || value.length === 0) {
134
- throw _err("BAD_OPT", name + " must be a non-empty string, got " + typeof value);
135
- }
136
- if (/[_\s]/.test(value)) {
137
- throw _err("BAD_OPT", name + " must not contain underscores or whitespace (collides with format separator), got " +
138
- JSON.stringify(value));
139
- }
140
- }
141
-
142
- function _validateCreateOpts(opts) {
143
- validateOpts.requireObject(opts, "apiKey.create", ApiKeyError);
144
- _validateIdentifier("apiKey.create: namespace", opts.namespace);
145
- if (opts.prefix !== undefined) _validateIdentifier("apiKey.create: prefix", opts.prefix);
146
- validateOpts.optionalPositiveInt(opts.idBytes, "apiKey.create: idBytes", ApiKeyError);
147
- validateOpts.optionalPositiveInt(opts.secretBytes, "apiKey.create: secretBytes", ApiKeyError);
148
- validateOpts.optionalBoolean(opts.trackLastUsedAt, "apiKey.create: trackLastUsedAt", ApiKeyError);
149
- validateOpts.optionalBoolean(opts.auditFailures, "apiKey.create: auditFailures", ApiKeyError);
150
- validateOpts.optionalBoolean(opts.auditSuccess, "apiKey.create: auditSuccess", ApiKeyError);
151
- validateOpts.optionalFiniteNonNegative(opts.purgeAfterMs, "apiKey.create: purgeAfterMs", ApiKeyError);
152
- if (opts.hashAlgo !== undefined) {
153
- if (typeof opts.hashAlgo !== "string" ||
154
- (opts.hashAlgo !== "shake256" && opts.hashAlgo !== "argon2id")) {
155
- throw _err("BAD_OPT", "apiKey.create: hashAlgo must be 'shake256' or 'argon2id', got " +
156
- JSON.stringify(opts.hashAlgo));
157
- }
158
- }
159
- validateOpts.auditShape(opts.audit, "apiKey.create", ApiKeyError);
160
- validateOpts.optionalFunction(opts.clock, "apiKey.create: clock", ApiKeyError);
161
- }
162
-
163
- function _validateIssueOpts(opts) {
164
- validateOpts.requireObject(opts, "apiKey.issue", ApiKeyError);
165
- validateOpts.requireNonEmptyString(opts.ownerId, "apiKey.issue: ownerId", ApiKeyError, "MISSING_OWNER");
166
- validateOpts.optionalNonEmptyStringArray(opts.scopes, "apiKey.issue: scopes", ApiKeyError, "BAD_SCOPES");
167
- validateOpts.optionalPlainObject(opts.metadata, "apiKey.issue: metadata",
168
- ApiKeyError, "BAD_METADATA");
169
- if (opts.expiresAt !== undefined && opts.expiresAt !== null) {
170
- if (typeof opts.expiresAt !== "number" || !isFinite(opts.expiresAt) || opts.expiresAt < 0) {
171
- throw _err("BAD_OPT", "apiKey.issue: expiresAt must be a non-negative finite number (unix ms) or null");
172
- }
173
- }
174
- }
175
-
176
- // ---- Token format ----
177
-
178
- // Format: <prefix>_<namespace>_<idHex>_<secretHex>
179
- // Each part is alphanumeric so split-by-underscore is unambiguous as long
180
- // as prefix/namespace are validated to contain no underscores. We verify
181
- // that during create.
182
- function parseFormat(token) {
183
- if (typeof token !== "string" || token.length === 0) return null;
184
- var parts = token.split("_");
185
- if (parts.length !== 4) return null;
186
- var prefix = parts[0], ns = parts[1], idHex = parts[2], secretHex = parts[3];
187
- if (!prefix || !ns || !idHex || !secretHex) return null;
188
- if (!/^[0-9a-f]+$/i.test(idHex) || !/^[0-9a-f]+$/i.test(secretHex)) return null;
189
- return { prefix: prefix, namespace: ns, idHex: idHex, secretHex: secretHex };
190
- }
191
-
192
- function _composeKey(prefix, namespace, idHex, secretHex) {
193
- return prefix + "_" + namespace + "_" + idHex + "_" + secretHex;
194
- }
195
-
196
- function _composedId(namespace, idHex) {
197
- return namespace + ":" + idHex;
198
- }
199
-
200
- // ---- Sealed-row helpers ----
201
-
202
- function _sealForInsert(row) {
203
- var sealed = cryptoField.sealRow(TABLE, row);
204
- for (var i = 0; i < COLS.length; i++) {
205
- if (!(COLS[i] in sealed)) sealed[COLS[i]] = null;
206
- }
207
- return sealed;
208
- }
209
-
210
- // ---- Registry factory ----
211
-
212
- function create(opts) {
213
- opts = opts || {};
214
- validateOpts(opts, [
215
- "namespace", "prefix", "idBytes", "secretBytes",
216
- "trackLastUsedAt", "auditFailures", "auditSuccess",
217
- "purgeAfterMs", "hashAlgo", "audit", "clock",
218
- ], "apiKey");
219
- _validateCreateOpts(opts);
220
- var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
221
- var prefix = cfg.prefix;
222
- var namespace = opts.namespace;
223
- var idBytes = cfg.idBytes;
224
- var secretBytes = cfg.secretBytes;
225
- var trackLastUsedAt = cfg.trackLastUsedAt;
226
- var auditFailures = cfg.auditFailures;
227
- var auditSuccess = cfg.auditSuccess;
228
- var purgeAfterMs = cfg.purgeAfterMs;
229
- var hashAlgo = cfg.hashAlgo;
230
- var audit = opts.audit || null;
231
- var clock = opts.clock || function () { return Date.now(); };
232
-
233
- var _emit = validateOpts.makeAuditEmitter(audit);
234
-
235
- // Build the audit actor by extracting the 5 W's from the supplied
236
- // request (WHO/WHERE/HOW), then layering caller-supplied context
237
- // and an explicit userId on top so the most specific value wins.
238
- // The audit chain treats null fields as "unknown", so partial
239
- // context is always safe.
240
- function _actor(callerOpts, userId) {
241
- return requestHelpers.resolveActorWithOverride(
242
- callerOpts,
243
- userId ? { userId: userId } : null
244
- );
245
- }
246
-
247
- function _selectAll() {
248
- return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
249
- "secondarySecretHash, secondaryExpiresAt, " +
250
- "scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
251
- }
252
-
253
- function _scrubRecord(row) {
254
- if (!row) return null;
255
- var unsealed = cryptoField.unsealRow(TABLE, row);
256
- var scopes = null;
257
- if (unsealed.scopes) {
258
- try { scopes = safeJson.parse(unsealed.scopes); } catch (_e) { scopes = null; }
259
- }
260
- var metadata = null;
261
- if (unsealed.metadata) {
262
- try { metadata = safeJson.parse(unsealed.metadata); } catch (_e) { metadata = null; }
263
- }
264
- var idParts = String(unsealed.id).split(":");
265
- var idHexOnly = idParts.length === 2 ? idParts[1] : unsealed.id;
266
- return {
267
- id: idHexOnly,
268
- namespace: unsealed.namespace,
269
- ownerId: unsealed.ownerId,
270
- scopes: scopes || [],
271
- metadata: metadata || null,
272
- createdAt: Number(unsealed.createdAt),
273
- expiresAt: unsealed.expiresAt == null ? null : Number(unsealed.expiresAt),
274
- revokedAt: unsealed.revokedAt == null ? null : Number(unsealed.revokedAt),
275
- lastUsedAt: unsealed.lastUsedAt == null ? null : Number(unsealed.lastUsedAt),
276
- // secondaryExpiresAt is operator-visible signal that a graceful
277
- // rotation is in flight; secondarySecretHash itself is NEVER
278
- // exposed.
279
- secondaryExpiresAt: unsealed.secondaryExpiresAt == null ? null : Number(unsealed.secondaryExpiresAt),
280
- prefix: unsealed.prefix,
281
- };
282
- }
283
-
284
- async function issue(issueOpts) {
285
- cluster.requireLeader();
286
- _validateIssueOpts(issueOpts);
287
- var idHex = crypto.generateToken(idBytes);
288
- var secretHex = crypto.generateToken(secretBytes);
289
- var compositeId = _composedId(namespace, idHex);
290
- var nowMs = clock();
291
- var scopes = issueOpts.scopes || [];
292
- var metadata = issueOpts.metadata || null;
293
- var expiresAt = (issueOpts.expiresAt === undefined) ? null : issueOpts.expiresAt;
294
-
295
- var secretEnvelope = await credentialHash.hash(secretHex, { algo: hashAlgo });
296
- var sealed = _sealForInsert({
297
- id: compositeId,
298
- namespace: namespace,
299
- ownerId: issueOpts.ownerId,
300
- secretHash: secretEnvelope,
301
- secondarySecretHash: null,
302
- secondaryExpiresAt: null,
303
- scopes: JSON.stringify(scopes),
304
- metadata: metadata ? JSON.stringify(metadata) : null,
305
- createdAt: nowMs,
306
- expiresAt: expiresAt,
307
- revokedAt: null,
308
- lastUsedAt: null,
309
- prefix: prefix,
310
- });
311
- var values = COLS.map(function (c) { return sealed[c]; });
312
- var placeholders = COLS.map(function () { return "?"; }).join(", ");
313
- var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
314
-
315
- await clusterStorage.execute(
316
- "INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
317
- values
318
- );
319
-
320
- _emit("apikey.issue", {
321
- actor: _actor(issueOpts, issueOpts.ownerId),
322
- resource: { kind: "apikey", id: compositeId },
323
- metadata: { namespace: namespace, scopes: scopes, expiresAt: expiresAt },
324
- });
325
- _emitEvent("apikey.issue", 1, { namespace: namespace });
326
-
327
- return {
328
- id: idHex,
329
- secret: secretHex,
330
- key: _composeKey(prefix, namespace, idHex, secretHex),
331
- scopes: scopes,
332
- metadata: metadata,
333
- createdAt: nowMs,
334
- expiresAt: expiresAt,
335
- };
336
- }
337
-
338
- async function verify(token, verifyOpts) {
339
- var parsed = parseFormat(token);
340
- if (!parsed) return null;
341
- if (parsed.prefix !== prefix || parsed.namespace !== namespace) return null;
342
-
343
- var compositeId = _composedId(namespace, parsed.idHex);
344
- var row = await clusterStorage.executeOne(
345
- _selectAll() + " WHERE id = ?",
346
- [compositeId]
347
- );
348
- if (!row) {
349
- if (auditFailures) {
350
- _emit("apikey.verify", {
351
- actor: _actor(verifyOpts),
352
- resource: { kind: "apikey", id: compositeId },
353
- outcome: "failure",
354
- reason: "not-found",
355
- });
356
- }
357
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "not-found" });
358
- return null;
359
- }
360
-
361
- var nowMs = clock();
362
- var rowOwnerId = null;
363
- try {
364
- var unsealedOwner = cryptoField.unsealRow(TABLE, row);
365
- rowOwnerId = unsealedOwner.ownerId;
366
- } catch (_e) { rowOwnerId = null; }
367
-
368
- if (row.revokedAt != null) {
369
- if (auditFailures) {
370
- _emit("apikey.verify", {
371
- actor: _actor(verifyOpts, rowOwnerId),
372
- resource: { kind: "apikey", id: compositeId },
373
- outcome: "failure", reason: "revoked",
374
- });
375
- }
376
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "revoked" });
377
- return null;
378
- }
379
- if (row.expiresAt != null && Number(row.expiresAt) < nowMs) {
380
- if (auditFailures) {
381
- _emit("apikey.verify", {
382
- actor: _actor(verifyOpts, rowOwnerId),
383
- resource: { kind: "apikey", id: compositeId },
384
- outcome: "failure", reason: "expired",
385
- });
386
- }
387
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "expired" });
388
- return null;
389
- }
390
-
391
- // Hash dispatch goes through credentialHash so the persisted byte
392
- // controls the verification algorithm. Both primary and secondary
393
- // (graceful-rotation) slots are envelope-encoded.
394
- var primaryMatch = await credentialHash.verify(parsed.secretHex, row.secretHash);
395
- var secondaryMatch = false;
396
- var secondaryActive = row.secondarySecretHash != null &&
397
- row.secondaryExpiresAt != null &&
398
- Number(row.secondaryExpiresAt) >= nowMs;
399
- if (!primaryMatch && secondaryActive) {
400
- secondaryMatch = await credentialHash.verify(parsed.secretHex, row.secondarySecretHash);
401
- }
402
- if (!primaryMatch && !secondaryMatch) {
403
- if (auditFailures) {
404
- _emit("apikey.verify", {
405
- actor: _actor(verifyOpts, rowOwnerId),
406
- resource: { kind: "apikey", id: compositeId },
407
- outcome: "failure", reason: "bad-secret",
408
- });
409
- }
410
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "bad-secret" });
411
- return null;
412
- }
413
-
414
- if (trackLastUsedAt && cluster.isLeader()) {
415
- try {
416
- await clusterStorage.execute(
417
- "UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
418
- [nowMs, compositeId]
419
- );
420
- } catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
421
- }
422
-
423
- if (auditSuccess) {
424
- _emit("apikey.verify", {
425
- actor: _actor(verifyOpts, rowOwnerId),
426
- resource: { kind: "apikey", id: compositeId },
427
- outcome: "success",
428
- metadata: { secondary: secondaryMatch },
429
- });
430
- }
431
- _emitEvent("apikey.verify", 1,
432
- { namespace: namespace, outcome: "success", secondary: secondaryMatch });
433
- var record = _scrubRecord(row);
434
- record.usedSecondary = secondaryMatch; // operator can detect grace-period usage
435
- return record;
436
- }
437
-
438
- async function revoke(idHex, revokeOpts) {
439
- cluster.requireLeader();
440
- if (typeof idHex !== "string" || idHex.length === 0) return false;
441
- var compositeId = _composedId(namespace, idHex);
442
- var nowMs = clock();
443
- var result = await clusterStorage.execute(
444
- "UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
445
- [nowMs, compositeId]
446
- );
447
- var changed = (result.rowCount || 0) > 0;
448
- if (changed) {
449
- _emit("apikey.revoke", {
450
- actor: _actor(revokeOpts),
451
- resource: { kind: "apikey", id: compositeId },
452
- });
453
- _emitEvent("apikey.revoke", 1, { namespace: namespace });
454
- }
455
- return changed;
456
- }
457
-
458
- async function rotate(idHex, rotateOpts) {
459
- cluster.requireLeader();
460
- if (typeof idHex !== "string" || idHex.length === 0) {
461
- throw _err("BAD_OPT", "apiKey.rotate: id must be a non-empty string");
462
- }
463
- rotateOpts = rotateOpts || {};
464
- // Graceful rotation: the previous hash stays valid in the
465
- // secondarySecretHash slot until secondaryExpiresAt. Operators
466
- // pass either { graceful: true } (default DEFAULT_ROTATE_GRACE_MS)
467
- // or { gracePeriodMs: <ms> } for an explicit window. Without
468
- // either, rotation is immediate (old secret invalidated) — this
469
- // preserves the original semantics for callers that explicitly
470
- // want a hard cutover.
471
- var gracePeriodMs = 0;
472
- if (typeof rotateOpts.gracePeriodMs === "number") {
473
- if (!isFinite(rotateOpts.gracePeriodMs) || rotateOpts.gracePeriodMs < 0) {
474
- throw _err("BAD_OPT", "apiKey.rotate: gracePeriodMs must be a non-negative finite number");
475
- }
476
- gracePeriodMs = rotateOpts.gracePeriodMs;
477
- } else if (rotateOpts.graceful === true) {
478
- gracePeriodMs = DEFAULT_ROTATE_GRACE_MS;
479
- } else if (rotateOpts.graceful !== undefined && rotateOpts.graceful !== false) {
480
- throw _err("BAD_OPT", "apiKey.rotate: graceful must be a boolean");
481
- }
482
-
483
- var compositeId = _composedId(namespace, idHex);
484
- var existing = await clusterStorage.executeOne(
485
- _selectAll() + " WHERE id = ?",
486
- [compositeId]
487
- );
488
- if (!existing) {
489
- throw _err("NOT_FOUND", "apiKey.rotate: id '" + idHex + "' not found in namespace '" + namespace + "'");
490
- }
491
- if (existing.revokedAt != null) {
492
- throw _err("REVOKED", "apiKey.rotate: id '" + idHex + "' is revoked");
493
- }
494
- var newSecretHex = crypto.generateToken(secretBytes);
495
- var newHash = await credentialHash.hash(newSecretHex, { algo: hashAlgo });
496
- var nowMs = clock();
497
-
498
- if (gracePeriodMs > 0) {
499
- // Move current hash → secondary slot, install new hash as primary.
500
- await clusterStorage.execute(
501
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
502
- "secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
503
- [newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
504
- );
505
- } else {
506
- // Hard cutover — old secret stops working immediately. Clears
507
- // any prior secondary slot too.
508
- await clusterStorage.execute(
509
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
510
- "secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
511
- [newHash, compositeId]
512
- );
513
- }
514
-
515
- _emit("apikey.rotate", {
516
- actor: _actor(rotateOpts),
517
- resource: { kind: "apikey", id: compositeId },
518
- metadata: { gracePeriodMs: gracePeriodMs },
519
- });
520
- _emitEvent("apikey.rotate", 1, { namespace: namespace, graceful: gracePeriodMs > 0 });
521
- return {
522
- key: _composeKey(prefix, namespace, idHex, newSecretHex),
523
- secret: newSecretHex,
524
- secretHash: newHash,
525
- gracePeriodMs: gracePeriodMs,
526
- secondaryExpiresAt: gracePeriodMs > 0 ? (nowMs + gracePeriodMs) : null,
527
- };
528
- }
529
-
530
- async function listForOwner(ownerId, listOpts) {
531
- if (typeof ownerId !== "string" || ownerId.length === 0) {
532
- throw _err("BAD_OPT", "apiKey.listForOwner: ownerId must be a non-empty string");
533
- }
534
- listOpts = listOpts || {};
535
- var includeRevoked = !!listOpts.includeRevoked;
536
- var includeExpired = !!listOpts.includeExpired;
537
- var lookup = cryptoField.lookupHash(TABLE, "ownerId", ownerId);
538
- if (!lookup) {
539
- throw _err("MISCONFIGURED",
540
- "_blamejs_api_keys schema is missing the ownerIdHash derived hash — framework misconfigured");
541
- }
542
- var sql = _selectAll() + " WHERE namespace = ? AND ownerIdHash = ?";
543
- var params = [namespace, lookup.value];
544
- if (!includeRevoked) sql += " AND revokedAt IS NULL";
545
- if (!includeExpired) {
546
- sql += " AND (expiresAt IS NULL OR expiresAt >= ?)";
547
- params.push(clock());
548
- }
549
- sql += " ORDER BY createdAt DESC";
550
- var rows = await clusterStorage.execute(sql, params);
551
- var list = (rows.rows || []).map(_scrubRecord);
552
- _emitEvent("apikey.list", 1, { namespace: namespace, count: list.length });
553
- // Read-access audit: "who listed whose keys at time T" — gated by
554
- // auditSuccess so operators with admin tooling that polls heavily
555
- // can opt out. ownerId is the audit subject; the listed IDs are
556
- // included in metadata so a compliance auditor can reconstruct
557
- // exactly which records were observed.
558
- if (auditSuccess) {
559
- _emit("apikey.list", {
560
- actor: _actor(listOpts),
561
- resource: { kind: "apikey-namespace", id: namespace },
562
- metadata: {
563
- ownerId: ownerId,
564
- count: list.length,
565
- observedIds: list.map(function (r) { return r.id; }),
566
- includeRevoked: includeRevoked,
567
- includeExpired: includeExpired,
568
- },
569
- });
570
- }
571
- return list;
572
- }
573
-
574
- async function getById(idHex, getOpts) {
575
- if (typeof idHex !== "string" || idHex.length === 0) return null;
576
- var compositeId = _composedId(namespace, idHex);
577
- var row = await clusterStorage.executeOne(
578
- _selectAll() + " WHERE id = ?",
579
- [compositeId]
580
- );
581
- var record = _scrubRecord(row);
582
- _emitEvent("apikey.get", 1,
583
- { namespace: namespace, found: record !== null });
584
- if (auditSuccess) {
585
- _emit("apikey.get", {
586
- actor: _actor(getOpts),
587
- resource: { kind: "apikey", id: compositeId },
588
- metadata: { found: record !== null },
589
- });
590
- }
591
- return record;
592
- }
593
-
594
- async function purgeExpired(purgeOpts) {
595
- cluster.requireLeader();
596
- var threshold = clock() - purgeAfterMs;
597
- // SELECT-then-DELETE so we can audit the specific IDs being purged.
598
- // Compliance auditors expect "key X was purged at time T" — a count-
599
- // only audit is too coarse for forensic reconstruction. Cost is one
600
- // extra round-trip per purge call which runs on a schedule (not
601
- // request-rate), so the cost is irrelevant.
602
- var idRows = await clusterStorage.execute(
603
- "SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
604
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
605
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
606
- [namespace, threshold, threshold]
607
- );
608
- var purgedCompositeIds = (idRows.rows || []).map(function (r) { return r.id; });
609
-
610
- if (purgedCompositeIds.length === 0) {
611
- _emitEvent("apikey.purge", 1, { namespace: namespace, count: 0 });
612
- return 0;
613
- }
614
-
615
- var result = await clusterStorage.execute(
616
- "DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
617
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
618
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
619
- [namespace, threshold, threshold]
620
- );
621
- var count = result.rowCount || purgedCompositeIds.length;
622
-
623
- _emit("apikey.purge", {
624
- actor: _actor(purgeOpts),
625
- resource: { kind: "apikey-namespace", id: namespace },
626
- metadata: {
627
- count: count,
628
- // Strip the namespace prefix so the audit payload contains
629
- // bare idHex values consistent with what callers receive
630
- // from issue/verify/getById.
631
- purgedIds: purgedCompositeIds.map(function (cid) {
632
- var parts = cid.split(":");
633
- return parts.length === 2 ? parts[1] : cid;
634
- }),
635
- thresholdMs: threshold,
636
- },
637
- });
638
- _emitEvent("apikey.purge", 1, { namespace: namespace, count: count });
639
- return count;
640
- }
641
-
642
- return {
643
- issue: issue,
644
- verify: verify,
645
- revoke: revoke,
646
- rotate: rotate,
647
- listForOwner: listForOwner,
648
- getById: getById,
649
- purgeExpired: purgeExpired,
650
- namespace: namespace,
651
- prefix: prefix,
652
- };
653
- }
654
-
655
- module.exports = {
656
- create: create,
657
- parseFormat: parseFormat,
658
- ApiKeyError: ApiKeyError,
659
- DEFAULTS: DEFAULTS,
660
- };
1
+ "use strict";
2
+ /**
3
+ * b.apiKey — operator-facing API-key issuance, verification, revocation,
4
+ * and rotation.
5
+ *
6
+ * var keys = b.apiKey.create({
7
+ * namespace: "live",
8
+ * audit: b.audit, // optional
9
+ * trackLastUsedAt: false, // default
10
+ * });
11
+ *
12
+ * var issued = await keys.issue({
13
+ * ownerId: "user-42",
14
+ * scopes: ["read:users", "write:posts"],
15
+ * metadata: { name: "Mobile app v3" },
16
+ * expiresAt: Date.now() + b.constants.TIME.days(90),
17
+ * });
18
+ * // issued.key — "bk_live_<idHex>_<secretHex>" (returned ONCE)
19
+ * // issued.id — "<idHex>"
20
+ *
21
+ * var record = await keys.verify(req.headers["x-api-key"]);
22
+ * // → { id, ownerId, scopes, metadata, ... } or null
23
+ *
24
+ * await keys.revoke(id);
25
+ * var rotated = await keys.rotate(id); // new secret; old stops working
26
+ * var owned = await keys.listForOwner("user-42");
27
+ *
28
+ * Token format (Stripe-style, prefix-recognizable):
29
+ *
30
+ * <prefix>_<namespace>_<idHex>_<secretHex>
31
+ *
32
+ * Example: `bk_live_5b9e7c8a4f2d1e3a_8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d`
33
+ *
34
+ * - prefix operator-supplied; default "bk". Visual marker.
35
+ * - namespace operator-supplied; lets multiple key registries coexist
36
+ * (e.g. "live"/"test", "v1"/"v2") without collision.
37
+ * - idHex opaque random hex; PRIMARY KEY component (DB lookup).
38
+ * - secretHex opaque random hex; never re-derivable. Stored as
39
+ * SHA3-512 hash, constant-time-compared on verify.
40
+ *
41
+ * Storage: framework table `_blamejs_api_keys` (sealed columns:
42
+ * ownerId/scopes/metadata; ownerIdHash for indexed listForOwner).
43
+ * Same dual-storage pattern as sessions — local SQLite in single-node
44
+ * mode, external-db in cluster mode, dispatched via cluster-storage.
45
+ *
46
+ * Validation policy:
47
+ *
48
+ * - apiKey.create opts → throw at config time
49
+ * - registry.issue opts → throw ApiKeyError at call site
50
+ * - registry.rotate(id) on missing/revoked → throw ApiKeyError at call site
51
+ * - registry.verify(token) on any failure → return null (tolerant read)
52
+ * - registry.revoke(id) on missing → return false (tolerant read)
53
+ * - registry.getById(id) on missing → return null (tolerant read)
54
+ */
55
+
56
+ var crypto = require("./crypto");
57
+ var credentialHash = require("./credential-hash");
58
+ var safeJson = require("./safe-json");
59
+ var lazyRequire = require("./lazy-require");
60
+ var clusterStorage = require("./cluster-storage");
61
+ var cluster = require("./cluster");
62
+ var cryptoField = require("./crypto-field");
63
+ var requestHelpers = require("./request-helpers");
64
+ var validateOpts = require("./validate-opts");
65
+ var C = require("./constants");
66
+ var numericChecks = require("./numeric-checks");
67
+ var { ApiKeyError } = require("./framework-error");
68
+
69
+ var observability = lazyRequire(function () { return require("./observability"); });
70
+
71
+ function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
72
+
73
+ var _err = ApiKeyError.factory;
74
+
75
+ var TABLE = "_blamejs_api_keys";
76
+ // Pre-quoted form for SQL interpolation. Defense-in-depth: even though
77
+ // our constant is bare-identifier-shaped, every interpolation site uses
78
+ // the wrapped form so a future rename to a reserved-word or
79
+ // whitespace-bearing name would still resolve correctly.
80
+ var Q_TABLE = '"' + TABLE + '"';
81
+
82
+ // Column order used for INSERT — kept as a constant so the placeholders
83
+ // list and the values list stay in sync. Must match _blamejs_api_keys'
84
+ // schema in db.js (single-node) and framework-schema.js (cluster mode).
85
+ var COLS = [
86
+ "id", "namespace", "ownerId", "ownerIdHash", "secretHash",
87
+ "secondarySecretHash", "secondaryExpiresAt",
88
+ "scopes", "metadata", "createdAt", "expiresAt", "revokedAt",
89
+ "lastUsedAt", "prefix",
90
+ ];
91
+
92
+ // Default rotate grace period when caller passes { graceful: true }
93
+ // without an explicit gracePeriodMs. 7 days is enough to migrate the
94
+ // vast majority of clients without paging anyone, short enough that
95
+ // a forgotten old secret stops working before it becomes a long-tail
96
+ // liability.
97
+ var DEFAULT_ROTATE_GRACE_MS = C.TIME.days(7);
98
+
99
+ // Visibility defaults are ON. When an operator wires `audit: b.audit`,
100
+ // they're declaring "I want to know what happens with these credentials"
101
+ // — that includes verify success (every access to a credential), reads
102
+ // (listForOwner/getById), and all state changes. The compliance trail
103
+ // (HIPAA §164.312(b), PCI-DSS 10.2.1, GDPR Art. 32) needs WHO + WHAT +
104
+ // WHEN on every access, not just on writes.
105
+ //
106
+ // Operators with extreme verify-rate volume opt OUT explicitly via
107
+ // `auditSuccess: false`. Failures stay on regardless.
108
+ var DEFAULTS = Object.freeze({
109
+ prefix: "bk",
110
+ idBytes: C.BYTES.bytes(8), // 16 hex chars
111
+ secretBytes: C.BYTES.bytes(16), // 32 hex chars
112
+ trackLastUsedAt: true, // visibility on dormant / leaked keys
113
+ auditFailures: true, // failure events are actionable signals
114
+ auditSuccess: true, // compliance trail — opt out at extreme volume
115
+ purgeAfterMs: C.TIME.days(90),
116
+ // Credential hash algorithm for new issues. Falls through to
117
+ // credentialHash defaults; SHAKE256 is the active per-framework
118
+ // because api-key secrets are 128-bit random (memory-hard property
119
+ // buys nothing at that entropy) and SHAKE256 is an XOF — the
120
+ // envelope payload length itself drives the digest size, so a
121
+ // future operator can request 96-byte digests without an algorithm
122
+ // rotation. Operators with low-entropy or paranoia-mode storage
123
+ // pin "argon2id" per registry. The envelope ensures historical
124
+ // credentials always remain verifiable.
125
+ hashAlgo: "shake256",
126
+ });
127
+
128
+ // ---- Config-time validation helpers (throw on bad input) ----
129
+
130
+ var _isPositiveInt = numericChecks.isPositiveInt;
131
+
132
+ function _validateIdentifier(name, value) {
133
+ if (typeof value !== "string" || value.length === 0) {
134
+ throw _err("BAD_OPT", name + " must be a non-empty string, got " + typeof value);
135
+ }
136
+ if (/[_\s]/.test(value)) {
137
+ throw _err("BAD_OPT", name + " must not contain underscores or whitespace (collides with format separator), got " +
138
+ JSON.stringify(value));
139
+ }
140
+ }
141
+
142
+ function _validateCreateOpts(opts) {
143
+ validateOpts.requireObject(opts, "apiKey.create", ApiKeyError);
144
+ _validateIdentifier("apiKey.create: namespace", opts.namespace);
145
+ if (opts.prefix !== undefined) _validateIdentifier("apiKey.create: prefix", opts.prefix);
146
+ validateOpts.optionalPositiveInt(opts.idBytes, "apiKey.create: idBytes", ApiKeyError);
147
+ validateOpts.optionalPositiveInt(opts.secretBytes, "apiKey.create: secretBytes", ApiKeyError);
148
+ validateOpts.optionalBoolean(opts.trackLastUsedAt, "apiKey.create: trackLastUsedAt", ApiKeyError);
149
+ validateOpts.optionalBoolean(opts.auditFailures, "apiKey.create: auditFailures", ApiKeyError);
150
+ validateOpts.optionalBoolean(opts.auditSuccess, "apiKey.create: auditSuccess", ApiKeyError);
151
+ validateOpts.optionalFiniteNonNegative(opts.purgeAfterMs, "apiKey.create: purgeAfterMs", ApiKeyError);
152
+ if (opts.hashAlgo !== undefined) {
153
+ if (typeof opts.hashAlgo !== "string" ||
154
+ (opts.hashAlgo !== "shake256" && opts.hashAlgo !== "argon2id")) {
155
+ throw _err("BAD_OPT", "apiKey.create: hashAlgo must be 'shake256' or 'argon2id', got " +
156
+ JSON.stringify(opts.hashAlgo));
157
+ }
158
+ }
159
+ validateOpts.auditShape(opts.audit, "apiKey.create", ApiKeyError);
160
+ validateOpts.optionalFunction(opts.clock, "apiKey.create: clock", ApiKeyError);
161
+ }
162
+
163
+ function _validateIssueOpts(opts) {
164
+ validateOpts.requireObject(opts, "apiKey.issue", ApiKeyError);
165
+ validateOpts.requireNonEmptyString(opts.ownerId, "apiKey.issue: ownerId", ApiKeyError, "MISSING_OWNER");
166
+ validateOpts.optionalNonEmptyStringArray(opts.scopes, "apiKey.issue: scopes", ApiKeyError, "BAD_SCOPES");
167
+ validateOpts.optionalPlainObject(opts.metadata, "apiKey.issue: metadata",
168
+ ApiKeyError, "BAD_METADATA");
169
+ if (opts.expiresAt !== undefined && opts.expiresAt !== null) {
170
+ if (typeof opts.expiresAt !== "number" || !isFinite(opts.expiresAt) || opts.expiresAt < 0) {
171
+ throw _err("BAD_OPT", "apiKey.issue: expiresAt must be a non-negative finite number (unix ms) or null");
172
+ }
173
+ }
174
+ }
175
+
176
+ // ---- Token format ----
177
+
178
+ // Format: <prefix>_<namespace>_<idHex>_<secretHex>
179
+ // Each part is alphanumeric so split-by-underscore is unambiguous as long
180
+ // as prefix/namespace are validated to contain no underscores. We verify
181
+ // that during create.
182
+ function parseFormat(token) {
183
+ if (typeof token !== "string" || token.length === 0) return null;
184
+ var parts = token.split("_");
185
+ if (parts.length !== 4) return null;
186
+ var prefix = parts[0], ns = parts[1], idHex = parts[2], secretHex = parts[3];
187
+ if (!prefix || !ns || !idHex || !secretHex) return null;
188
+ if (!/^[0-9a-f]+$/i.test(idHex) || !/^[0-9a-f]+$/i.test(secretHex)) return null;
189
+ return { prefix: prefix, namespace: ns, idHex: idHex, secretHex: secretHex };
190
+ }
191
+
192
+ function _composeKey(prefix, namespace, idHex, secretHex) {
193
+ return prefix + "_" + namespace + "_" + idHex + "_" + secretHex;
194
+ }
195
+
196
+ function _composedId(namespace, idHex) {
197
+ return namespace + ":" + idHex;
198
+ }
199
+
200
+ // ---- Sealed-row helpers ----
201
+
202
+ function _sealForInsert(row) {
203
+ var sealed = cryptoField.sealRow(TABLE, row);
204
+ for (var i = 0; i < COLS.length; i++) {
205
+ if (!(COLS[i] in sealed)) sealed[COLS[i]] = null;
206
+ }
207
+ return sealed;
208
+ }
209
+
210
+ // ---- Registry factory ----
211
+
212
+ function create(opts) {
213
+ opts = opts || {};
214
+ validateOpts(opts, [
215
+ "namespace", "prefix", "idBytes", "secretBytes",
216
+ "trackLastUsedAt", "auditFailures", "auditSuccess",
217
+ "purgeAfterMs", "hashAlgo", "audit", "clock",
218
+ ], "apiKey");
219
+ _validateCreateOpts(opts);
220
+ var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
221
+ var prefix = cfg.prefix;
222
+ var namespace = opts.namespace;
223
+ var idBytes = cfg.idBytes;
224
+ var secretBytes = cfg.secretBytes;
225
+ var trackLastUsedAt = cfg.trackLastUsedAt;
226
+ var auditFailures = cfg.auditFailures;
227
+ var auditSuccess = cfg.auditSuccess;
228
+ var purgeAfterMs = cfg.purgeAfterMs;
229
+ var hashAlgo = cfg.hashAlgo;
230
+ var audit = opts.audit || null;
231
+ var clock = opts.clock || function () { return Date.now(); };
232
+
233
+ var _emit = validateOpts.makeAuditEmitter(audit);
234
+
235
+ // Build the audit actor by extracting the 5 W's from the supplied
236
+ // request (WHO/WHERE/HOW), then layering caller-supplied context
237
+ // and an explicit userId on top so the most specific value wins.
238
+ // The audit chain treats null fields as "unknown", so partial
239
+ // context is always safe.
240
+ function _actor(callerOpts, userId) {
241
+ return requestHelpers.resolveActorWithOverride(
242
+ callerOpts,
243
+ userId ? { userId: userId } : null
244
+ );
245
+ }
246
+
247
+ function _selectAll() {
248
+ return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
249
+ "secondarySecretHash, secondaryExpiresAt, " +
250
+ "scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
251
+ }
252
+
253
+ function _scrubRecord(row) {
254
+ if (!row) return null;
255
+ var unsealed = cryptoField.unsealRow(TABLE, row);
256
+ var scopes = null;
257
+ if (unsealed.scopes) {
258
+ try { scopes = safeJson.parse(unsealed.scopes); } catch (_e) { scopes = null; }
259
+ }
260
+ var metadata = null;
261
+ if (unsealed.metadata) {
262
+ try { metadata = safeJson.parse(unsealed.metadata); } catch (_e) { metadata = null; }
263
+ }
264
+ var idParts = String(unsealed.id).split(":");
265
+ var idHexOnly = idParts.length === 2 ? idParts[1] : unsealed.id;
266
+ return {
267
+ id: idHexOnly,
268
+ namespace: unsealed.namespace,
269
+ ownerId: unsealed.ownerId,
270
+ scopes: scopes || [],
271
+ metadata: metadata || null,
272
+ createdAt: Number(unsealed.createdAt),
273
+ expiresAt: unsealed.expiresAt == null ? null : Number(unsealed.expiresAt),
274
+ revokedAt: unsealed.revokedAt == null ? null : Number(unsealed.revokedAt),
275
+ lastUsedAt: unsealed.lastUsedAt == null ? null : Number(unsealed.lastUsedAt),
276
+ // secondaryExpiresAt is operator-visible signal that a graceful
277
+ // rotation is in flight; secondarySecretHash itself is NEVER
278
+ // exposed.
279
+ secondaryExpiresAt: unsealed.secondaryExpiresAt == null ? null : Number(unsealed.secondaryExpiresAt),
280
+ prefix: unsealed.prefix,
281
+ };
282
+ }
283
+
284
+ async function issue(issueOpts) {
285
+ cluster.requireLeader();
286
+ _validateIssueOpts(issueOpts);
287
+ var idHex = crypto.generateToken(idBytes);
288
+ var secretHex = crypto.generateToken(secretBytes);
289
+ var compositeId = _composedId(namespace, idHex);
290
+ var nowMs = clock();
291
+ var scopes = issueOpts.scopes || [];
292
+ var metadata = issueOpts.metadata || null;
293
+ var expiresAt = (issueOpts.expiresAt === undefined) ? null : issueOpts.expiresAt;
294
+
295
+ var secretEnvelope = await credentialHash.hash(secretHex, { algo: hashAlgo });
296
+ var sealed = _sealForInsert({
297
+ id: compositeId,
298
+ namespace: namespace,
299
+ ownerId: issueOpts.ownerId,
300
+ secretHash: secretEnvelope,
301
+ secondarySecretHash: null,
302
+ secondaryExpiresAt: null,
303
+ scopes: JSON.stringify(scopes),
304
+ metadata: metadata ? JSON.stringify(metadata) : null,
305
+ createdAt: nowMs,
306
+ expiresAt: expiresAt,
307
+ revokedAt: null,
308
+ lastUsedAt: null,
309
+ prefix: prefix,
310
+ });
311
+ var values = COLS.map(function (c) { return sealed[c]; });
312
+ var placeholders = COLS.map(function () { return "?"; }).join(", ");
313
+ var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
314
+
315
+ await clusterStorage.execute(
316
+ "INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
317
+ values
318
+ );
319
+
320
+ _emit("apikey.issue", {
321
+ actor: _actor(issueOpts, issueOpts.ownerId),
322
+ resource: { kind: "apikey", id: compositeId },
323
+ metadata: { namespace: namespace, scopes: scopes, expiresAt: expiresAt },
324
+ });
325
+ _emitEvent("apikey.issue", 1, { namespace: namespace });
326
+
327
+ return {
328
+ id: idHex,
329
+ secret: secretHex,
330
+ key: _composeKey(prefix, namespace, idHex, secretHex),
331
+ scopes: scopes,
332
+ metadata: metadata,
333
+ createdAt: nowMs,
334
+ expiresAt: expiresAt,
335
+ };
336
+ }
337
+
338
+ async function verify(token, verifyOpts) {
339
+ var parsed = parseFormat(token);
340
+ if (!parsed) return null;
341
+ if (parsed.prefix !== prefix || parsed.namespace !== namespace) return null;
342
+
343
+ var compositeId = _composedId(namespace, parsed.idHex);
344
+ var row = await clusterStorage.executeOne(
345
+ _selectAll() + " WHERE id = ?",
346
+ [compositeId]
347
+ );
348
+ if (!row) {
349
+ if (auditFailures) {
350
+ _emit("apikey.verify", {
351
+ actor: _actor(verifyOpts),
352
+ resource: { kind: "apikey", id: compositeId },
353
+ outcome: "failure",
354
+ reason: "not-found",
355
+ });
356
+ }
357
+ _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "not-found" });
358
+ return null;
359
+ }
360
+
361
+ var nowMs = clock();
362
+ var rowOwnerId = null;
363
+ try {
364
+ var unsealedOwner = cryptoField.unsealRow(TABLE, row);
365
+ rowOwnerId = unsealedOwner.ownerId;
366
+ } catch (_e) { rowOwnerId = null; }
367
+
368
+ if (row.revokedAt != null) {
369
+ if (auditFailures) {
370
+ _emit("apikey.verify", {
371
+ actor: _actor(verifyOpts, rowOwnerId),
372
+ resource: { kind: "apikey", id: compositeId },
373
+ outcome: "failure", reason: "revoked",
374
+ });
375
+ }
376
+ _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "revoked" });
377
+ return null;
378
+ }
379
+ if (row.expiresAt != null && Number(row.expiresAt) < nowMs) {
380
+ if (auditFailures) {
381
+ _emit("apikey.verify", {
382
+ actor: _actor(verifyOpts, rowOwnerId),
383
+ resource: { kind: "apikey", id: compositeId },
384
+ outcome: "failure", reason: "expired",
385
+ });
386
+ }
387
+ _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "expired" });
388
+ return null;
389
+ }
390
+
391
+ // Hash dispatch goes through credentialHash so the persisted byte
392
+ // controls the verification algorithm. Both primary and secondary
393
+ // (graceful-rotation) slots are envelope-encoded.
394
+ var primaryMatch = await credentialHash.verify(parsed.secretHex, row.secretHash);
395
+ var secondaryMatch = false;
396
+ var secondaryActive = row.secondarySecretHash != null &&
397
+ row.secondaryExpiresAt != null &&
398
+ Number(row.secondaryExpiresAt) >= nowMs;
399
+ if (!primaryMatch && secondaryActive) {
400
+ secondaryMatch = await credentialHash.verify(parsed.secretHex, row.secondarySecretHash);
401
+ }
402
+ if (!primaryMatch && !secondaryMatch) {
403
+ if (auditFailures) {
404
+ _emit("apikey.verify", {
405
+ actor: _actor(verifyOpts, rowOwnerId),
406
+ resource: { kind: "apikey", id: compositeId },
407
+ outcome: "failure", reason: "bad-secret",
408
+ });
409
+ }
410
+ _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "bad-secret" });
411
+ return null;
412
+ }
413
+
414
+ if (trackLastUsedAt && cluster.isLeader()) {
415
+ try {
416
+ await clusterStorage.execute(
417
+ "UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
418
+ [nowMs, compositeId]
419
+ );
420
+ } catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
421
+ }
422
+
423
+ if (auditSuccess) {
424
+ _emit("apikey.verify", {
425
+ actor: _actor(verifyOpts, rowOwnerId),
426
+ resource: { kind: "apikey", id: compositeId },
427
+ outcome: "success",
428
+ metadata: { secondary: secondaryMatch },
429
+ });
430
+ }
431
+ _emitEvent("apikey.verify", 1,
432
+ { namespace: namespace, outcome: "success", secondary: secondaryMatch });
433
+ var record = _scrubRecord(row);
434
+ record.usedSecondary = secondaryMatch; // operator can detect grace-period usage
435
+ return record;
436
+ }
437
+
438
+ async function revoke(idHex, revokeOpts) {
439
+ cluster.requireLeader();
440
+ if (typeof idHex !== "string" || idHex.length === 0) return false;
441
+ var compositeId = _composedId(namespace, idHex);
442
+ var nowMs = clock();
443
+ var result = await clusterStorage.execute(
444
+ "UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
445
+ [nowMs, compositeId]
446
+ );
447
+ var changed = (result.rowCount || 0) > 0;
448
+ if (changed) {
449
+ _emit("apikey.revoke", {
450
+ actor: _actor(revokeOpts),
451
+ resource: { kind: "apikey", id: compositeId },
452
+ });
453
+ _emitEvent("apikey.revoke", 1, { namespace: namespace });
454
+ }
455
+ return changed;
456
+ }
457
+
458
+ async function rotate(idHex, rotateOpts) {
459
+ cluster.requireLeader();
460
+ if (typeof idHex !== "string" || idHex.length === 0) {
461
+ throw _err("BAD_OPT", "apiKey.rotate: id must be a non-empty string");
462
+ }
463
+ rotateOpts = rotateOpts || {};
464
+ // Graceful rotation: the previous hash stays valid in the
465
+ // secondarySecretHash slot until secondaryExpiresAt. Operators
466
+ // pass either { graceful: true } (default DEFAULT_ROTATE_GRACE_MS)
467
+ // or { gracePeriodMs: <ms> } for an explicit window. Without
468
+ // either, rotation is immediate (old secret invalidated) — this
469
+ // preserves the original semantics for callers that explicitly
470
+ // want a hard cutover.
471
+ var gracePeriodMs = 0;
472
+ if (typeof rotateOpts.gracePeriodMs === "number") {
473
+ if (!isFinite(rotateOpts.gracePeriodMs) || rotateOpts.gracePeriodMs < 0) {
474
+ throw _err("BAD_OPT", "apiKey.rotate: gracePeriodMs must be a non-negative finite number");
475
+ }
476
+ gracePeriodMs = rotateOpts.gracePeriodMs;
477
+ } else if (rotateOpts.graceful === true) {
478
+ gracePeriodMs = DEFAULT_ROTATE_GRACE_MS;
479
+ } else if (rotateOpts.graceful !== undefined && rotateOpts.graceful !== false) {
480
+ throw _err("BAD_OPT", "apiKey.rotate: graceful must be a boolean");
481
+ }
482
+
483
+ var compositeId = _composedId(namespace, idHex);
484
+ var existing = await clusterStorage.executeOne(
485
+ _selectAll() + " WHERE id = ?",
486
+ [compositeId]
487
+ );
488
+ if (!existing) {
489
+ throw _err("NOT_FOUND", "apiKey.rotate: id '" + idHex + "' not found in namespace '" + namespace + "'");
490
+ }
491
+ if (existing.revokedAt != null) {
492
+ throw _err("REVOKED", "apiKey.rotate: id '" + idHex + "' is revoked");
493
+ }
494
+ var newSecretHex = crypto.generateToken(secretBytes);
495
+ var newHash = await credentialHash.hash(newSecretHex, { algo: hashAlgo });
496
+ var nowMs = clock();
497
+
498
+ if (gracePeriodMs > 0) {
499
+ // Move current hash → secondary slot, install new hash as primary.
500
+ await clusterStorage.execute(
501
+ "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
502
+ "secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
503
+ [newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
504
+ );
505
+ } else {
506
+ // Hard cutover — old secret stops working immediately. Clears
507
+ // any prior secondary slot too.
508
+ await clusterStorage.execute(
509
+ "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
510
+ "secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
511
+ [newHash, compositeId]
512
+ );
513
+ }
514
+
515
+ _emit("apikey.rotate", {
516
+ actor: _actor(rotateOpts),
517
+ resource: { kind: "apikey", id: compositeId },
518
+ metadata: { gracePeriodMs: gracePeriodMs },
519
+ });
520
+ _emitEvent("apikey.rotate", 1, { namespace: namespace, graceful: gracePeriodMs > 0 });
521
+ return {
522
+ key: _composeKey(prefix, namespace, idHex, newSecretHex),
523
+ secret: newSecretHex,
524
+ secretHash: newHash,
525
+ gracePeriodMs: gracePeriodMs,
526
+ secondaryExpiresAt: gracePeriodMs > 0 ? (nowMs + gracePeriodMs) : null,
527
+ };
528
+ }
529
+
530
+ async function listForOwner(ownerId, listOpts) {
531
+ if (typeof ownerId !== "string" || ownerId.length === 0) {
532
+ throw _err("BAD_OPT", "apiKey.listForOwner: ownerId must be a non-empty string");
533
+ }
534
+ listOpts = listOpts || {};
535
+ var includeRevoked = !!listOpts.includeRevoked;
536
+ var includeExpired = !!listOpts.includeExpired;
537
+ var lookup = cryptoField.lookupHash(TABLE, "ownerId", ownerId);
538
+ if (!lookup) {
539
+ throw _err("MISCONFIGURED",
540
+ "_blamejs_api_keys schema is missing the ownerIdHash derived hash — framework misconfigured");
541
+ }
542
+ var sql = _selectAll() + " WHERE namespace = ? AND ownerIdHash = ?";
543
+ var params = [namespace, lookup.value];
544
+ if (!includeRevoked) sql += " AND revokedAt IS NULL";
545
+ if (!includeExpired) {
546
+ sql += " AND (expiresAt IS NULL OR expiresAt >= ?)";
547
+ params.push(clock());
548
+ }
549
+ sql += " ORDER BY createdAt DESC";
550
+ var rows = await clusterStorage.execute(sql, params);
551
+ var list = (rows.rows || []).map(_scrubRecord);
552
+ _emitEvent("apikey.list", 1, { namespace: namespace, count: list.length });
553
+ // Read-access audit: "who listed whose keys at time T" — gated by
554
+ // auditSuccess so operators with admin tooling that polls heavily
555
+ // can opt out. ownerId is the audit subject; the listed IDs are
556
+ // included in metadata so a compliance auditor can reconstruct
557
+ // exactly which records were observed.
558
+ if (auditSuccess) {
559
+ _emit("apikey.list", {
560
+ actor: _actor(listOpts),
561
+ resource: { kind: "apikey-namespace", id: namespace },
562
+ metadata: {
563
+ ownerId: ownerId,
564
+ count: list.length,
565
+ observedIds: list.map(function (r) { return r.id; }),
566
+ includeRevoked: includeRevoked,
567
+ includeExpired: includeExpired,
568
+ },
569
+ });
570
+ }
571
+ return list;
572
+ }
573
+
574
+ async function getById(idHex, getOpts) {
575
+ if (typeof idHex !== "string" || idHex.length === 0) return null;
576
+ var compositeId = _composedId(namespace, idHex);
577
+ var row = await clusterStorage.executeOne(
578
+ _selectAll() + " WHERE id = ?",
579
+ [compositeId]
580
+ );
581
+ var record = _scrubRecord(row);
582
+ _emitEvent("apikey.get", 1,
583
+ { namespace: namespace, found: record !== null });
584
+ if (auditSuccess) {
585
+ _emit("apikey.get", {
586
+ actor: _actor(getOpts),
587
+ resource: { kind: "apikey", id: compositeId },
588
+ metadata: { found: record !== null },
589
+ });
590
+ }
591
+ return record;
592
+ }
593
+
594
+ async function purgeExpired(purgeOpts) {
595
+ cluster.requireLeader();
596
+ var threshold = clock() - purgeAfterMs;
597
+ // SELECT-then-DELETE so we can audit the specific IDs being purged.
598
+ // Compliance auditors expect "key X was purged at time T" — a count-
599
+ // only audit is too coarse for forensic reconstruction. Cost is one
600
+ // extra round-trip per purge call which runs on a schedule (not
601
+ // request-rate), so the cost is irrelevant.
602
+ var idRows = await clusterStorage.execute(
603
+ "SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
604
+ "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
605
+ " (expiresAt IS NOT NULL AND expiresAt < ?))",
606
+ [namespace, threshold, threshold]
607
+ );
608
+ var purgedCompositeIds = (idRows.rows || []).map(function (r) { return r.id; });
609
+
610
+ if (purgedCompositeIds.length === 0) {
611
+ _emitEvent("apikey.purge", 1, { namespace: namespace, count: 0 });
612
+ return 0;
613
+ }
614
+
615
+ var result = await clusterStorage.execute(
616
+ "DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
617
+ "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
618
+ " (expiresAt IS NOT NULL AND expiresAt < ?))",
619
+ [namespace, threshold, threshold]
620
+ );
621
+ var count = result.rowCount || purgedCompositeIds.length;
622
+
623
+ _emit("apikey.purge", {
624
+ actor: _actor(purgeOpts),
625
+ resource: { kind: "apikey-namespace", id: namespace },
626
+ metadata: {
627
+ count: count,
628
+ // Strip the namespace prefix so the audit payload contains
629
+ // bare idHex values consistent with what callers receive
630
+ // from issue/verify/getById.
631
+ purgedIds: purgedCompositeIds.map(function (cid) {
632
+ var parts = cid.split(":");
633
+ return parts.length === 2 ? parts[1] : cid;
634
+ }),
635
+ thresholdMs: threshold,
636
+ },
637
+ });
638
+ _emitEvent("apikey.purge", 1, { namespace: namespace, count: count });
639
+ return count;
640
+ }
641
+
642
+ return {
643
+ issue: issue,
644
+ verify: verify,
645
+ revoke: revoke,
646
+ rotate: rotate,
647
+ listForOwner: listForOwner,
648
+ getById: getById,
649
+ purgeExpired: purgeExpired,
650
+ namespace: namespace,
651
+ prefix: prefix,
652
+ };
653
+ }
654
+
655
+ module.exports = {
656
+ create: create,
657
+ parseFormat: parseFormat,
658
+ ApiKeyError: ApiKeyError,
659
+ DEFAULTS: DEFAULTS,
660
+ };