@blamejs/core 0.7.4 → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  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.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  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 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  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/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
package/lib/api-key.js CHANGED
@@ -1,663 +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
- if (opts.metadata !== undefined && opts.metadata !== null) {
168
- if (typeof opts.metadata !== "object" || Array.isArray(opts.metadata)) {
169
- throw _err("BAD_METADATA", "apiKey.issue: metadata must be a plain object or null");
170
- }
171
- }
172
- if (opts.expiresAt !== undefined && opts.expiresAt !== null) {
173
- if (typeof opts.expiresAt !== "number" || !isFinite(opts.expiresAt) || opts.expiresAt < 0) {
174
- throw _err("BAD_OPT", "apiKey.issue: expiresAt must be a non-negative finite number (unix ms) or null");
175
- }
176
- }
177
- }
178
-
179
- // ---- Token format ----
180
-
181
- // Format: <prefix>_<namespace>_<idHex>_<secretHex>
182
- // Each part is alphanumeric so split-by-underscore is unambiguous as long
183
- // as prefix/namespace are validated to contain no underscores. We verify
184
- // that during create.
185
- function parseFormat(token) {
186
- if (typeof token !== "string" || token.length === 0) return null;
187
- var parts = token.split("_");
188
- if (parts.length !== 4) return null;
189
- var prefix = parts[0], ns = parts[1], idHex = parts[2], secretHex = parts[3];
190
- if (!prefix || !ns || !idHex || !secretHex) return null;
191
- if (!/^[0-9a-f]+$/i.test(idHex) || !/^[0-9a-f]+$/i.test(secretHex)) return null;
192
- return { prefix: prefix, namespace: ns, idHex: idHex, secretHex: secretHex };
193
- }
194
-
195
- function _composeKey(prefix, namespace, idHex, secretHex) {
196
- return prefix + "_" + namespace + "_" + idHex + "_" + secretHex;
197
- }
198
-
199
- function _composedId(namespace, idHex) {
200
- return namespace + ":" + idHex;
201
- }
202
-
203
- // ---- Sealed-row helpers ----
204
-
205
- function _sealForInsert(row) {
206
- var sealed = cryptoField.sealRow(TABLE, row);
207
- for (var i = 0; i < COLS.length; i++) {
208
- if (!(COLS[i] in sealed)) sealed[COLS[i]] = null;
209
- }
210
- return sealed;
211
- }
212
-
213
- // ---- Registry factory ----
214
-
215
- function create(opts) {
216
- opts = opts || {};
217
- validateOpts(opts, [
218
- "namespace", "prefix", "idBytes", "secretBytes",
219
- "trackLastUsedAt", "auditFailures", "auditSuccess",
220
- "purgeAfterMs", "hashAlgo", "audit", "clock",
221
- ], "apiKey");
222
- _validateCreateOpts(opts);
223
- var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
224
- var prefix = cfg.prefix;
225
- var namespace = opts.namespace;
226
- var idBytes = cfg.idBytes;
227
- var secretBytes = cfg.secretBytes;
228
- var trackLastUsedAt = cfg.trackLastUsedAt;
229
- var auditFailures = cfg.auditFailures;
230
- var auditSuccess = cfg.auditSuccess;
231
- var purgeAfterMs = cfg.purgeAfterMs;
232
- var hashAlgo = cfg.hashAlgo;
233
- var audit = opts.audit || null;
234
- var clock = opts.clock || function () { return Date.now(); };
235
-
236
- var _emit = validateOpts.makeAuditEmitter(audit);
237
-
238
- // Build the audit actor by extracting the 5 W's from the supplied
239
- // request (WHO/WHERE/HOW), then layering caller-supplied context
240
- // and an explicit userId on top so the most specific value wins.
241
- // The audit chain treats null fields as "unknown", so partial
242
- // context is always safe.
243
- function _actor(callerOpts, userId) {
244
- return requestHelpers.resolveActorWithOverride(
245
- callerOpts,
246
- userId ? { userId: userId } : null
247
- );
248
- }
249
-
250
- function _selectAll() {
251
- return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
252
- "secondarySecretHash, secondaryExpiresAt, " +
253
- "scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
254
- }
255
-
256
- function _scrubRecord(row) {
257
- if (!row) return null;
258
- var unsealed = cryptoField.unsealRow(TABLE, row);
259
- var scopes = null;
260
- if (unsealed.scopes) {
261
- try { scopes = safeJson.parse(unsealed.scopes); } catch (_e) { scopes = null; }
262
- }
263
- var metadata = null;
264
- if (unsealed.metadata) {
265
- try { metadata = safeJson.parse(unsealed.metadata); } catch (_e) { metadata = null; }
266
- }
267
- var idParts = String(unsealed.id).split(":");
268
- var idHexOnly = idParts.length === 2 ? idParts[1] : unsealed.id;
269
- return {
270
- id: idHexOnly,
271
- namespace: unsealed.namespace,
272
- ownerId: unsealed.ownerId,
273
- scopes: scopes || [],
274
- metadata: metadata || null,
275
- createdAt: Number(unsealed.createdAt),
276
- expiresAt: unsealed.expiresAt == null ? null : Number(unsealed.expiresAt),
277
- revokedAt: unsealed.revokedAt == null ? null : Number(unsealed.revokedAt),
278
- lastUsedAt: unsealed.lastUsedAt == null ? null : Number(unsealed.lastUsedAt),
279
- // secondaryExpiresAt is operator-visible signal that a graceful
280
- // rotation is in flight; secondarySecretHash itself is NEVER
281
- // exposed.
282
- secondaryExpiresAt: unsealed.secondaryExpiresAt == null ? null : Number(unsealed.secondaryExpiresAt),
283
- prefix: unsealed.prefix,
284
- };
285
- }
286
-
287
- async function issue(issueOpts) {
288
- cluster.requireLeader();
289
- _validateIssueOpts(issueOpts);
290
- var idHex = crypto.generateToken(idBytes);
291
- var secretHex = crypto.generateToken(secretBytes);
292
- var compositeId = _composedId(namespace, idHex);
293
- var nowMs = clock();
294
- var scopes = issueOpts.scopes || [];
295
- var metadata = issueOpts.metadata || null;
296
- var expiresAt = (issueOpts.expiresAt === undefined) ? null : issueOpts.expiresAt;
297
-
298
- var secretEnvelope = await credentialHash.hash(secretHex, { algo: hashAlgo });
299
- var sealed = _sealForInsert({
300
- id: compositeId,
301
- namespace: namespace,
302
- ownerId: issueOpts.ownerId,
303
- secretHash: secretEnvelope,
304
- secondarySecretHash: null,
305
- secondaryExpiresAt: null,
306
- scopes: JSON.stringify(scopes),
307
- metadata: metadata ? JSON.stringify(metadata) : null,
308
- createdAt: nowMs,
309
- expiresAt: expiresAt,
310
- revokedAt: null,
311
- lastUsedAt: null,
312
- prefix: prefix,
313
- });
314
- var values = COLS.map(function (c) { return sealed[c]; });
315
- var placeholders = COLS.map(function () { return "?"; }).join(", ");
316
- var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
317
-
318
- await clusterStorage.execute(
319
- "INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
320
- values
321
- );
322
-
323
- _emit("apikey.issue", {
324
- actor: _actor(issueOpts, issueOpts.ownerId),
325
- resource: { kind: "apikey", id: compositeId },
326
- metadata: { namespace: namespace, scopes: scopes, expiresAt: expiresAt },
327
- });
328
- _emitEvent("apikey.issue", 1, { namespace: namespace });
329
-
330
- return {
331
- id: idHex,
332
- secret: secretHex,
333
- key: _composeKey(prefix, namespace, idHex, secretHex),
334
- scopes: scopes,
335
- metadata: metadata,
336
- createdAt: nowMs,
337
- expiresAt: expiresAt,
338
- };
339
- }
340
-
341
- async function verify(token, verifyOpts) {
342
- var parsed = parseFormat(token);
343
- if (!parsed) return null;
344
- if (parsed.prefix !== prefix || parsed.namespace !== namespace) return null;
345
-
346
- var compositeId = _composedId(namespace, parsed.idHex);
347
- var row = await clusterStorage.executeOne(
348
- _selectAll() + " WHERE id = ?",
349
- [compositeId]
350
- );
351
- if (!row) {
352
- if (auditFailures) {
353
- _emit("apikey.verify", {
354
- actor: _actor(verifyOpts),
355
- resource: { kind: "apikey", id: compositeId },
356
- outcome: "failure",
357
- reason: "not-found",
358
- });
359
- }
360
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "not-found" });
361
- return null;
362
- }
363
-
364
- var nowMs = clock();
365
- var rowOwnerId = null;
366
- try {
367
- var unsealedOwner = cryptoField.unsealRow(TABLE, row);
368
- rowOwnerId = unsealedOwner.ownerId;
369
- } catch (_e) { rowOwnerId = null; }
370
-
371
- if (row.revokedAt != null) {
372
- if (auditFailures) {
373
- _emit("apikey.verify", {
374
- actor: _actor(verifyOpts, rowOwnerId),
375
- resource: { kind: "apikey", id: compositeId },
376
- outcome: "failure", reason: "revoked",
377
- });
378
- }
379
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "revoked" });
380
- return null;
381
- }
382
- if (row.expiresAt != null && Number(row.expiresAt) < nowMs) {
383
- if (auditFailures) {
384
- _emit("apikey.verify", {
385
- actor: _actor(verifyOpts, rowOwnerId),
386
- resource: { kind: "apikey", id: compositeId },
387
- outcome: "failure", reason: "expired",
388
- });
389
- }
390
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "expired" });
391
- return null;
392
- }
393
-
394
- // Hash dispatch goes through credentialHash so the persisted byte
395
- // controls the verification algorithm. Both primary and secondary
396
- // (graceful-rotation) slots are envelope-encoded.
397
- var primaryMatch = await credentialHash.verify(parsed.secretHex, row.secretHash);
398
- var secondaryMatch = false;
399
- var secondaryActive = row.secondarySecretHash != null &&
400
- row.secondaryExpiresAt != null &&
401
- Number(row.secondaryExpiresAt) >= nowMs;
402
- if (!primaryMatch && secondaryActive) {
403
- secondaryMatch = await credentialHash.verify(parsed.secretHex, row.secondarySecretHash);
404
- }
405
- if (!primaryMatch && !secondaryMatch) {
406
- if (auditFailures) {
407
- _emit("apikey.verify", {
408
- actor: _actor(verifyOpts, rowOwnerId),
409
- resource: { kind: "apikey", id: compositeId },
410
- outcome: "failure", reason: "bad-secret",
411
- });
412
- }
413
- _emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "bad-secret" });
414
- return null;
415
- }
416
-
417
- if (trackLastUsedAt && cluster.isLeader()) {
418
- try {
419
- await clusterStorage.execute(
420
- "UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
421
- [nowMs, compositeId]
422
- );
423
- } catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
424
- }
425
-
426
- if (auditSuccess) {
427
- _emit("apikey.verify", {
428
- actor: _actor(verifyOpts, rowOwnerId),
429
- resource: { kind: "apikey", id: compositeId },
430
- outcome: "success",
431
- metadata: { secondary: secondaryMatch },
432
- });
433
- }
434
- _emitEvent("apikey.verify", 1,
435
- { namespace: namespace, outcome: "success", secondary: secondaryMatch });
436
- var record = _scrubRecord(row);
437
- record.usedSecondary = secondaryMatch; // operator can detect grace-period usage
438
- return record;
439
- }
440
-
441
- async function revoke(idHex, revokeOpts) {
442
- cluster.requireLeader();
443
- if (typeof idHex !== "string" || idHex.length === 0) return false;
444
- var compositeId = _composedId(namespace, idHex);
445
- var nowMs = clock();
446
- var result = await clusterStorage.execute(
447
- "UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
448
- [nowMs, compositeId]
449
- );
450
- var changed = (result.rowCount || 0) > 0;
451
- if (changed) {
452
- _emit("apikey.revoke", {
453
- actor: _actor(revokeOpts),
454
- resource: { kind: "apikey", id: compositeId },
455
- });
456
- _emitEvent("apikey.revoke", 1, { namespace: namespace });
457
- }
458
- return changed;
459
- }
460
-
461
- async function rotate(idHex, rotateOpts) {
462
- cluster.requireLeader();
463
- if (typeof idHex !== "string" || idHex.length === 0) {
464
- throw _err("BAD_OPT", "apiKey.rotate: id must be a non-empty string");
465
- }
466
- rotateOpts = rotateOpts || {};
467
- // Graceful rotation: the previous hash stays valid in the
468
- // secondarySecretHash slot until secondaryExpiresAt. Operators
469
- // pass either { graceful: true } (default DEFAULT_ROTATE_GRACE_MS)
470
- // or { gracePeriodMs: <ms> } for an explicit window. Without
471
- // either, rotation is immediate (old secret invalidated) — this
472
- // preserves the original semantics for callers that explicitly
473
- // want a hard cutover.
474
- var gracePeriodMs = 0;
475
- if (typeof rotateOpts.gracePeriodMs === "number") {
476
- if (!isFinite(rotateOpts.gracePeriodMs) || rotateOpts.gracePeriodMs < 0) {
477
- throw _err("BAD_OPT", "apiKey.rotate: gracePeriodMs must be a non-negative finite number");
478
- }
479
- gracePeriodMs = rotateOpts.gracePeriodMs;
480
- } else if (rotateOpts.graceful === true) {
481
- gracePeriodMs = DEFAULT_ROTATE_GRACE_MS;
482
- } else if (rotateOpts.graceful !== undefined && rotateOpts.graceful !== false) {
483
- throw _err("BAD_OPT", "apiKey.rotate: graceful must be a boolean");
484
- }
485
-
486
- var compositeId = _composedId(namespace, idHex);
487
- var existing = await clusterStorage.executeOne(
488
- _selectAll() + " WHERE id = ?",
489
- [compositeId]
490
- );
491
- if (!existing) {
492
- throw _err("NOT_FOUND", "apiKey.rotate: id '" + idHex + "' not found in namespace '" + namespace + "'");
493
- }
494
- if (existing.revokedAt != null) {
495
- throw _err("REVOKED", "apiKey.rotate: id '" + idHex + "' is revoked");
496
- }
497
- var newSecretHex = crypto.generateToken(secretBytes);
498
- var newHash = await credentialHash.hash(newSecretHex, { algo: hashAlgo });
499
- var nowMs = clock();
500
-
501
- if (gracePeriodMs > 0) {
502
- // Move current hash secondary slot, install new hash as primary.
503
- await clusterStorage.execute(
504
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
505
- "secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
506
- [newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
507
- );
508
- } else {
509
- // Hard cutover old secret stops working immediately. Clears
510
- // any prior secondary slot too.
511
- await clusterStorage.execute(
512
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
513
- "secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
514
- [newHash, compositeId]
515
- );
516
- }
517
-
518
- _emit("apikey.rotate", {
519
- actor: _actor(rotateOpts),
520
- resource: { kind: "apikey", id: compositeId },
521
- metadata: { gracePeriodMs: gracePeriodMs },
522
- });
523
- _emitEvent("apikey.rotate", 1, { namespace: namespace, graceful: gracePeriodMs > 0 });
524
- return {
525
- key: _composeKey(prefix, namespace, idHex, newSecretHex),
526
- secret: newSecretHex,
527
- secretHash: newHash,
528
- gracePeriodMs: gracePeriodMs,
529
- secondaryExpiresAt: gracePeriodMs > 0 ? (nowMs + gracePeriodMs) : null,
530
- };
531
- }
532
-
533
- async function listForOwner(ownerId, listOpts) {
534
- if (typeof ownerId !== "string" || ownerId.length === 0) {
535
- throw _err("BAD_OPT", "apiKey.listForOwner: ownerId must be a non-empty string");
536
- }
537
- listOpts = listOpts || {};
538
- var includeRevoked = !!listOpts.includeRevoked;
539
- var includeExpired = !!listOpts.includeExpired;
540
- var lookup = cryptoField.lookupHash(TABLE, "ownerId", ownerId);
541
- if (!lookup) {
542
- throw _err("MISCONFIGURED",
543
- "_blamejs_api_keys schema is missing the ownerIdHash derived hash — framework misconfigured");
544
- }
545
- var sql = _selectAll() + " WHERE namespace = ? AND ownerIdHash = ?";
546
- var params = [namespace, lookup.value];
547
- if (!includeRevoked) sql += " AND revokedAt IS NULL";
548
- if (!includeExpired) {
549
- sql += " AND (expiresAt IS NULL OR expiresAt >= ?)";
550
- params.push(clock());
551
- }
552
- sql += " ORDER BY createdAt DESC";
553
- var rows = await clusterStorage.execute(sql, params);
554
- var list = (rows.rows || []).map(_scrubRecord);
555
- _emitEvent("apikey.list", 1, { namespace: namespace, count: list.length });
556
- // Read-access audit: "who listed whose keys at time T" — gated by
557
- // auditSuccess so operators with admin tooling that polls heavily
558
- // can opt out. ownerId is the audit subject; the listed IDs are
559
- // included in metadata so a compliance auditor can reconstruct
560
- // exactly which records were observed.
561
- if (auditSuccess) {
562
- _emit("apikey.list", {
563
- actor: _actor(listOpts),
564
- resource: { kind: "apikey-namespace", id: namespace },
565
- metadata: {
566
- ownerId: ownerId,
567
- count: list.length,
568
- observedIds: list.map(function (r) { return r.id; }),
569
- includeRevoked: includeRevoked,
570
- includeExpired: includeExpired,
571
- },
572
- });
573
- }
574
- return list;
575
- }
576
-
577
- async function getById(idHex, getOpts) {
578
- if (typeof idHex !== "string" || idHex.length === 0) return null;
579
- var compositeId = _composedId(namespace, idHex);
580
- var row = await clusterStorage.executeOne(
581
- _selectAll() + " WHERE id = ?",
582
- [compositeId]
583
- );
584
- var record = _scrubRecord(row);
585
- _emitEvent("apikey.get", 1,
586
- { namespace: namespace, found: record !== null });
587
- if (auditSuccess) {
588
- _emit("apikey.get", {
589
- actor: _actor(getOpts),
590
- resource: { kind: "apikey", id: compositeId },
591
- metadata: { found: record !== null },
592
- });
593
- }
594
- return record;
595
- }
596
-
597
- async function purgeExpired(purgeOpts) {
598
- cluster.requireLeader();
599
- var threshold = clock() - purgeAfterMs;
600
- // SELECT-then-DELETE so we can audit the specific IDs being purged.
601
- // Compliance auditors expect "key X was purged at time T" — a count-
602
- // only audit is too coarse for forensic reconstruction. Cost is one
603
- // extra round-trip per purge call which runs on a schedule (not
604
- // request-rate), so the cost is irrelevant.
605
- var idRows = await clusterStorage.execute(
606
- "SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
607
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
608
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
609
- [namespace, threshold, threshold]
610
- );
611
- var purgedCompositeIds = (idRows.rows || []).map(function (r) { return r.id; });
612
-
613
- if (purgedCompositeIds.length === 0) {
614
- _emitEvent("apikey.purge", 1, { namespace: namespace, count: 0 });
615
- return 0;
616
- }
617
-
618
- var result = await clusterStorage.execute(
619
- "DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
620
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
621
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
622
- [namespace, threshold, threshold]
623
- );
624
- var count = result.rowCount || purgedCompositeIds.length;
625
-
626
- _emit("apikey.purge", {
627
- actor: _actor(purgeOpts),
628
- resource: { kind: "apikey-namespace", id: namespace },
629
- metadata: {
630
- count: count,
631
- // Strip the namespace prefix so the audit payload contains
632
- // bare idHex values consistent with what callers receive
633
- // from issue/verify/getById.
634
- purgedIds: purgedCompositeIds.map(function (cid) {
635
- var parts = cid.split(":");
636
- return parts.length === 2 ? parts[1] : cid;
637
- }),
638
- thresholdMs: threshold,
639
- },
640
- });
641
- _emitEvent("apikey.purge", 1, { namespace: namespace, count: count });
642
- return count;
643
- }
644
-
645
- return {
646
- issue: issue,
647
- verify: verify,
648
- revoke: revoke,
649
- rotate: rotate,
650
- listForOwner: listForOwner,
651
- getById: getById,
652
- purgeExpired: purgeExpired,
653
- namespace: namespace,
654
- prefix: prefix,
655
- };
656
- }
657
-
658
- module.exports = {
659
- create: create,
660
- parseFormat: parseFormat,
661
- ApiKeyError: ApiKeyError,
662
- DEFAULTS: DEFAULTS,
663
- };
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
+ };