@blamejs/core 0.4.1

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