@blamejs/blamejs-shop 0.0.59 → 0.0.60

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.
@@ -0,0 +1,789 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.apiKeys
4
+ * @title API keys primitive — operator-issued bearer tokens for
5
+ * third-party access to admin endpoints
6
+ *
7
+ * @intro
8
+ * Every key is a bearer credential. Whoever knows the plaintext
9
+ * token can authenticate as the key's owner — so the shop never
10
+ * stores it. On `issueKey` we draw 32 random bytes via
11
+ * `b.crypto.generateBytes(32)`, render them as URL-safe base64
12
+ * without padding (43 characters), store the
13
+ * `b.crypto.namespaceHash("api-key-token", plaintext)` digest, and
14
+ * return the plaintext exactly once. Subsequent reads only ever see
15
+ * the hash. `verifyToken(plaintext)` hashes the supplied value with
16
+ * the same namespace, matches against `token_hash` OR
17
+ * `token_hash_previous` (24h rotation grace), and routes the hex
18
+ * compare through `b.crypto.timingSafeEqual` so the equality check
19
+ * leaves no micro-timing oracle.
20
+ *
21
+ * Each key carries:
22
+ * - `owner_type` — one of `operator | app | affiliate | tenant`.
23
+ * - `owner_id` — optional owner-row id (nullable for global
24
+ * operator keys).
25
+ * - `scopes` — JSON array of opaque scope strings the route
26
+ * layer enforces; the primitive validates shape
27
+ * only (string, alphabet, length).
28
+ * - `rate_limit_per_minute` — per-key request budget over the
29
+ * last 60s; the route layer enforces
30
+ * by reading `api_key_usage`.
31
+ * - `expires_at` — optional ms-epoch hard expiry; once reached,
32
+ * verifyToken refuses and `cleanupExpired` may
33
+ * sweep the row's status to `expired`.
34
+ *
35
+ * FSM:
36
+ * active — issued and usable.
37
+ * rotated — superseded by a newer hash via `rotate`; the row's
38
+ * previous hash stays usable until rotated_at + 24h.
39
+ * revoked — terminal; verifyToken refuses immediately.
40
+ * expired — terminal; either reached `expires_at` or was swept
41
+ * by `cleanupExpired`.
42
+ *
43
+ * Composes:
44
+ * - `b.crypto.generateBytes` — 32-byte uniform draw for token
45
+ * plaintext.
46
+ * - `b.crypto.namespaceHash` — SHA3-512 of the plaintext under
47
+ * the `api-key-token` namespace.
48
+ * - `b.crypto.timingSafeEqual` — constant-time hex compare on
49
+ * verify.
50
+ * - `b.guardUuid` — UUID-shape validation for ids.
51
+ * - `b.uuid.v7` — row ids.
52
+ *
53
+ * Surface:
54
+ * issueKey({ owner_type, owner_id?, name, scopes,
55
+ * rate_limit_per_minute?, expires_at? })
56
+ * verifyToken(plaintext)
57
+ * revoke(key_id, reason)
58
+ * rotate(key_id)
59
+ * listForOwner({ owner_type, owner_id })
60
+ * getKey(key_id)
61
+ * update(key_id, patch)
62
+ * recordUse({ key_id, endpoint, occurred_at? })
63
+ * usageForKey({ key_id, from, to })
64
+ * cleanupExpired({ now? })
65
+ *
66
+ * Storage:
67
+ * - `api_keys` + `api_key_usage` (migration `0064_api_keys.sql`).
68
+ *
69
+ * @primitive apiKeys
70
+ * @related b.crypto, b.guardUuid, b.uuid
71
+ */
72
+
73
+ var TOKEN_NAMESPACE = "api-key-token";
74
+ var TOKEN_BYTE_LEN = 32;
75
+ // 32 bytes -> 43 chars of base64url (no padding).
76
+ var TOKEN_PLAINTEXT_LEN = 43;
77
+ var TOKEN_PLAINTEXT_RE = /^[A-Za-z0-9_-]{43}$/;
78
+
79
+ var ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
80
+
81
+ var OWNER_TYPES = ["operator", "app", "affiliate", "tenant"];
82
+ var STATUSES = ["active", "rotated", "revoked", "expired"];
83
+
84
+ var MAX_NAME_LEN = 200;
85
+ var MAX_REASON_LEN = 280;
86
+ var MAX_ENDPOINT_LEN = 512;
87
+ var MAX_SCOPE_LEN = 128;
88
+ var MAX_SCOPES_PER_KEY = 64;
89
+ var MIN_RATE_LIMIT = 0;
90
+ // 1e6 requests/minute is ~16.6k/sec — operator wiring something
91
+ // larger is mis-configured. The CHECK in storage allows >= 0; we
92
+ // guard the upper end at the entry point so the primitive doesn't
93
+ // have to think about overflow downstream.
94
+ var MAX_RATE_LIMIT = 1000000;
95
+ var DEFAULT_RATE_LIMIT = 60;
96
+
97
+ // Scope strings are operator-supplied opaque tokens — the route layer
98
+ // enforces them. Alphabet is conservative so a typo'd scope is loud:
99
+ // lowercase + digits + `:` + `.` + `_` + `-`. No spaces, no unicode.
100
+ var SCOPE_RE = /^[a-z0-9][a-z0-9:._-]{0,127}$/;
101
+
102
+ // Control bytes + zero-width / direction-override family. The name +
103
+ // reason + endpoint render in operator dashboards; embedded control
104
+ // / direction-override bytes are a slipping-class for header
105
+ // injection + visual-spoofing attacks downstream.
106
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
107
+ var ZERO_WIDTH_RE = new RegExp(
108
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
109
+ );
110
+
111
+ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
112
+ "name", "scopes", "rate_limit_per_minute",
113
+ ]);
114
+
115
+ // Lazy framework handle — matches the pattern used by every other
116
+ // shop primitive; avoids the require cycle that would arise from
117
+ // importing `./index` at module-eval time.
118
+ var bShop;
119
+ function _b() {
120
+ if (!bShop) bShop = require("./index");
121
+ return bShop.framework;
122
+ }
123
+
124
+ // ---- validators ---------------------------------------------------------
125
+
126
+ function _uuid(s, label) {
127
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
128
+ catch (e) { throw new TypeError("apiKeys: " + label + " — " + (e && e.message || "invalid UUID")); }
129
+ }
130
+
131
+ function _ownerType(s) {
132
+ if (typeof s !== "string" || OWNER_TYPES.indexOf(s) === -1) {
133
+ throw new TypeError("apiKeys: owner_type must be one of " + OWNER_TYPES.join(", "));
134
+ }
135
+ return s;
136
+ }
137
+
138
+ function _name(s) {
139
+ if (typeof s !== "string") {
140
+ throw new TypeError("apiKeys: name must be a string");
141
+ }
142
+ var trimmed = s.trim();
143
+ if (!trimmed.length) {
144
+ throw new TypeError("apiKeys: name must be non-empty after trim");
145
+ }
146
+ if (s.length > MAX_NAME_LEN) {
147
+ throw new TypeError("apiKeys: name must be <= " + MAX_NAME_LEN + " characters");
148
+ }
149
+ if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
150
+ throw new TypeError("apiKeys: name contains control / zero-width bytes");
151
+ }
152
+ return s;
153
+ }
154
+
155
+ function _scopes(arr) {
156
+ if (!Array.isArray(arr)) {
157
+ throw new TypeError("apiKeys: scopes must be an array of strings");
158
+ }
159
+ if (!arr.length) {
160
+ throw new TypeError("apiKeys: scopes must contain at least one entry");
161
+ }
162
+ if (arr.length > MAX_SCOPES_PER_KEY) {
163
+ throw new TypeError("apiKeys: scopes must contain <= " + MAX_SCOPES_PER_KEY + " entries");
164
+ }
165
+ // Set-validate: dedupe + canonicalize. Two callers passing
166
+ // ["admin:read", "admin:read"] should land on the same stored row
167
+ // shape as ["admin:read"]. The primitive sorts so the on-disk JSON
168
+ // is order-independent — operators diffing two keys see a stable
169
+ // shape.
170
+ var seen = Object.create(null);
171
+ var out = [];
172
+ for (var i = 0; i < arr.length; i += 1) {
173
+ var s = arr[i];
174
+ if (typeof s !== "string") {
175
+ throw new TypeError("apiKeys: scopes[" + i + "] must be a string");
176
+ }
177
+ if (s.length > MAX_SCOPE_LEN) {
178
+ throw new TypeError("apiKeys: scopes[" + i + "] must be <= " + MAX_SCOPE_LEN + " characters");
179
+ }
180
+ if (!SCOPE_RE.test(s)) {
181
+ throw new TypeError("apiKeys: scopes[" + i + "] contains characters outside the scope alphabet");
182
+ }
183
+ if (seen[s]) continue;
184
+ seen[s] = true;
185
+ out.push(s);
186
+ }
187
+ out.sort();
188
+ return out;
189
+ }
190
+
191
+ function _rateLimit(n) {
192
+ if (n == null) return DEFAULT_RATE_LIMIT;
193
+ if (!Number.isInteger(n) || n < MIN_RATE_LIMIT || n > MAX_RATE_LIMIT) {
194
+ throw new TypeError(
195
+ "apiKeys: rate_limit_per_minute must be an integer " +
196
+ MIN_RATE_LIMIT + ".." + MAX_RATE_LIMIT
197
+ );
198
+ }
199
+ return n;
200
+ }
201
+
202
+ function _expiresAt(ts) {
203
+ if (ts == null) return null;
204
+ if (!Number.isInteger(ts) || ts <= 0) {
205
+ throw new TypeError("apiKeys: expires_at must be a positive integer (ms epoch) or null");
206
+ }
207
+ return ts;
208
+ }
209
+
210
+ function _reason(r) {
211
+ if (typeof r !== "string") {
212
+ throw new TypeError("apiKeys: reason must be a string");
213
+ }
214
+ var trimmed = r.trim();
215
+ if (!trimmed.length) {
216
+ throw new TypeError("apiKeys: reason must be non-empty after trim");
217
+ }
218
+ if (r.length > MAX_REASON_LEN) {
219
+ throw new TypeError("apiKeys: reason must be <= " + MAX_REASON_LEN + " characters");
220
+ }
221
+ if (CONTROL_BYTE_STRICT_RE.test(r) || ZERO_WIDTH_RE.test(r)) {
222
+ throw new TypeError("apiKeys: reason contains control / zero-width bytes");
223
+ }
224
+ return r;
225
+ }
226
+
227
+ function _endpoint(s) {
228
+ if (typeof s !== "string") {
229
+ throw new TypeError("apiKeys: endpoint must be a string");
230
+ }
231
+ var trimmed = s.trim();
232
+ if (!trimmed.length) {
233
+ throw new TypeError("apiKeys: endpoint must be non-empty after trim");
234
+ }
235
+ if (s.length > MAX_ENDPOINT_LEN) {
236
+ throw new TypeError("apiKeys: endpoint must be <= " + MAX_ENDPOINT_LEN + " characters");
237
+ }
238
+ if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
239
+ throw new TypeError("apiKeys: endpoint contains control / zero-width bytes");
240
+ }
241
+ return s;
242
+ }
243
+
244
+ function _msEpoch(n, label) {
245
+ if (!Number.isInteger(n) || n < 0) {
246
+ throw new TypeError("apiKeys: " + label + " must be a non-negative integer (ms epoch)");
247
+ }
248
+ return n;
249
+ }
250
+
251
+ function _now() { return Date.now(); }
252
+
253
+ // ---- token generation + hashing -----------------------------------------
254
+
255
+ // 32 bytes -> 43 chars base64url (no padding). We render manually so
256
+ // the primitive doesn't depend on a Buffer-side flag rename across
257
+ // Node minors.
258
+ function _generateToken() {
259
+ var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
260
+ return buf.toString("base64")
261
+ .replace(/\+/g, "-")
262
+ .replace(/\//g, "_")
263
+ .replace(/=+$/, "");
264
+ }
265
+
266
+ function _canonicalToken(input) {
267
+ if (typeof input !== "string" || !input.length) {
268
+ throw new TypeError("apiKeys: token must be a non-empty string");
269
+ }
270
+ if (!TOKEN_PLAINTEXT_RE.test(input)) {
271
+ throw new TypeError("apiKeys: token must be 43 base64url characters");
272
+ }
273
+ return input;
274
+ }
275
+
276
+ function _hashToken(canonical) {
277
+ return _b().crypto.namespaceHash(TOKEN_NAMESPACE, canonical);
278
+ }
279
+
280
+ // ---- factory ------------------------------------------------------------
281
+
282
+ function create(opts) {
283
+ opts = opts || {};
284
+ var query = opts.query;
285
+ if (!query) {
286
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
287
+ }
288
+
289
+ async function _getRaw(id) {
290
+ var r = await query("SELECT * FROM api_keys WHERE id = ?1", [id]);
291
+ return r.rows[0] || null;
292
+ }
293
+
294
+ // Project the storage row into the operator-facing shape. The
295
+ // plaintext token NEVER appears here — it's returned exactly once
296
+ // at issue / rotate. `scopes` deserializes the JSON column so
297
+ // callers don't have to.
298
+ function _project(row) {
299
+ if (!row) return null;
300
+ var out = {
301
+ id: row.id,
302
+ owner_type: row.owner_type,
303
+ owner_id: row.owner_id,
304
+ name: row.name,
305
+ scopes: _parseScopesJSON(row.scopes_json),
306
+ token_hash: row.token_hash,
307
+ token_hash_previous: row.token_hash_previous,
308
+ rotated_at: row.rotated_at != null ? Number(row.rotated_at) : null,
309
+ status: row.status,
310
+ revoked_at: row.revoked_at != null ? Number(row.revoked_at) : null,
311
+ revoke_reason: row.revoke_reason,
312
+ rate_limit_per_minute: Number(row.rate_limit_per_minute),
313
+ last_used_at: row.last_used_at != null ? Number(row.last_used_at) : null,
314
+ expires_at: row.expires_at != null ? Number(row.expires_at) : null,
315
+ created_at: Number(row.created_at),
316
+ active: row.status === "active",
317
+ };
318
+ return out;
319
+ }
320
+
321
+ function _parseScopesJSON(raw) {
322
+ if (raw == null) return [];
323
+ try {
324
+ var parsed = JSON.parse(raw);
325
+ return Array.isArray(parsed) ? parsed : [];
326
+ } catch (_e) {
327
+ // Drop-silent on parse error — the issue path always writes a
328
+ // canonical array, so a parse failure means the row was hand-
329
+ // edited outside the primitive. Surfacing as `[]` keeps the
330
+ // route layer's allowlist closed-by-default.
331
+ return [];
332
+ }
333
+ }
334
+
335
+ // Insert the issued row. Token plaintext is generated up-front;
336
+ // collision on the hash UNIQUE constraint is astronomically
337
+ // unlikely (SHA3-512 over 2^256 input space) but we retry on the
338
+ // off chance an operator hand-inserts a row in the same boot.
339
+ async function _insertKey(row, plaintext) {
340
+ var attempts = 0;
341
+ var lastErr;
342
+ while (attempts < 5) {
343
+ attempts += 1;
344
+ try {
345
+ await query(
346
+ "INSERT INTO api_keys " +
347
+ "(id, owner_type, owner_id, name, scopes_json, token_hash, " +
348
+ " token_hash_previous, rotated_at, status, revoked_at, " +
349
+ " revoke_reason, rate_limit_per_minute, last_used_at, " +
350
+ " expires_at, created_at) " +
351
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, 'active', NULL, " +
352
+ " NULL, ?7, NULL, ?8, ?9)",
353
+ [
354
+ row.id, row.owner_type, row.owner_id, row.name,
355
+ row.scopes_json, row.token_hash, row.rate_limit_per_minute,
356
+ row.expires_at, row.created_at,
357
+ ],
358
+ );
359
+ lastErr = null;
360
+ break;
361
+ } catch (e) {
362
+ lastErr = e;
363
+ if (!e || !e.message || e.message.indexOf("UNIQUE") === -1) throw e;
364
+ // Regenerate on collision.
365
+ plaintext = _generateToken();
366
+ row.token_hash = _hashToken(plaintext);
367
+ }
368
+ }
369
+ if (lastErr) throw lastErr;
370
+ return plaintext;
371
+ }
372
+
373
+ return {
374
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
375
+ TOKEN_BYTE_LEN: TOKEN_BYTE_LEN,
376
+ TOKEN_PLAINTEXT_LEN: TOKEN_PLAINTEXT_LEN,
377
+ ROTATION_GRACE_MS: ROTATION_GRACE_MS,
378
+ OWNER_TYPES: OWNER_TYPES.slice(),
379
+ STATUSES: STATUSES.slice(),
380
+ MAX_NAME_LEN: MAX_NAME_LEN,
381
+ MAX_REASON_LEN: MAX_REASON_LEN,
382
+ MAX_ENDPOINT_LEN: MAX_ENDPOINT_LEN,
383
+ MAX_SCOPE_LEN: MAX_SCOPE_LEN,
384
+ MAX_SCOPES_PER_KEY: MAX_SCOPES_PER_KEY,
385
+ MIN_RATE_LIMIT: MIN_RATE_LIMIT,
386
+ MAX_RATE_LIMIT: MAX_RATE_LIMIT,
387
+ DEFAULT_RATE_LIMIT: DEFAULT_RATE_LIMIT,
388
+
389
+ issueKey: async function (input) {
390
+ if (!input || typeof input !== "object") {
391
+ throw new TypeError("apiKeys.issueKey: input object required");
392
+ }
393
+ var ownerType = _ownerType(input.owner_type);
394
+ var ownerId = null;
395
+ if (input.owner_id != null) {
396
+ ownerId = _uuid(input.owner_id, "owner_id");
397
+ }
398
+ var name = _name(input.name);
399
+ var scopes = _scopes(input.scopes);
400
+ var rateLimit = _rateLimit(input.rate_limit_per_minute);
401
+ var expiresAt = _expiresAt(input.expires_at);
402
+
403
+ var id = _b().uuid.v7();
404
+ var plaintext = _generateToken();
405
+ var hash = _hashToken(plaintext);
406
+ var ts = _now();
407
+
408
+ var row = {
409
+ id: id,
410
+ owner_type: ownerType,
411
+ owner_id: ownerId,
412
+ name: name,
413
+ scopes_json: JSON.stringify(scopes),
414
+ token_hash: hash,
415
+ rate_limit_per_minute: rateLimit,
416
+ expires_at: expiresAt,
417
+ created_at: ts,
418
+ };
419
+ var finalPlaintext = await _insertKey(row, plaintext);
420
+
421
+ // `plaintext_token` is returned exactly ONCE. The caller
422
+ // delivers it. Subsequent reads against this row only see the
423
+ // hash. We also surface a `token_hint` (last 6 plaintext
424
+ // characters) so an operator triaging a support request can
425
+ // identify which row the holder is asking about — 6 chars of
426
+ // base64url is 2^36 ≈ 70-billion entries, far too large to
427
+ // enable brute-force recovery of the remaining 37 chars.
428
+ return {
429
+ key_id: id,
430
+ plaintext_token: finalPlaintext,
431
+ token_hint: finalPlaintext.slice(finalPlaintext.length - 6),
432
+ owner_type: ownerType,
433
+ owner_id: ownerId,
434
+ name: name,
435
+ scopes: scopes.slice(),
436
+ rate_limit_per_minute: rateLimit,
437
+ expires_at: expiresAt,
438
+ created_at: ts,
439
+ };
440
+ },
441
+
442
+ // Resolve a plaintext token to the underlying key's authorization
443
+ // context. Returns null on no-match, revoked, expired, or rotated-
444
+ // beyond-grace. The hash compare itself is constant-time (we route
445
+ // the hex strings through `b.crypto.timingSafeEqual` even after
446
+ // the SQL = match) so an attacker who can time the query can't
447
+ // distinguish "no row" from "wrong hash". A `rotated` row's
448
+ // previous hash is accepted until `rotated_at + 24h`; this lets
449
+ // a deployed caller re-fetch its new token without a hard outage.
450
+ verifyToken: async function (plaintext, optsArg) {
451
+ var canonical = _canonicalToken(plaintext);
452
+ var hash = _hashToken(canonical);
453
+ var now;
454
+ if (optsArg && optsArg.now != null) {
455
+ now = _msEpoch(optsArg.now, "now");
456
+ } else {
457
+ now = _now();
458
+ }
459
+
460
+ // Match against either the live hash or the previous-on-rotate
461
+ // hash. The SQL = on the hex strings is the lookup; the
462
+ // timingSafeEqual on the matched row's hex is belt-and-braces
463
+ // for any future schema change that introduces collection scans.
464
+ var r = await query(
465
+ "SELECT * FROM api_keys " +
466
+ "WHERE token_hash = ?1 OR token_hash_previous = ?1",
467
+ [hash],
468
+ );
469
+ if (!r.rows.length) return null;
470
+ var row = r.rows[0];
471
+
472
+ // Constant-time equality on whichever column matched.
473
+ var matchedLive = _b().crypto.timingSafeEqual(row.token_hash, hash);
474
+ var matchedPrevious = row.token_hash_previous != null
475
+ && _b().crypto.timingSafeEqual(row.token_hash_previous, hash);
476
+ if (!matchedLive && !matchedPrevious) return null;
477
+
478
+ // expires_at takes precedence over everything else. A key whose
479
+ // status hasn't been swept to `expired` yet but whose
480
+ // expires_at has elapsed is refused at verify time.
481
+ if (row.expires_at != null && Number(row.expires_at) <= now) return null;
482
+
483
+ var status = row.status;
484
+ if (status === "revoked" || status === "expired") return null;
485
+
486
+ if (status === "rotated") {
487
+ // The live hash is no longer accepted from a rotated row —
488
+ // only the previous (grace) hash. The new caller should be
489
+ // using the freshly-issued token, which lives on a different
490
+ // row.
491
+ if (!matchedPrevious) return null;
492
+ var rotatedAt = row.rotated_at != null ? Number(row.rotated_at) : 0;
493
+ if (now - rotatedAt > ROTATION_GRACE_MS) return null;
494
+ } else if (status === "active") {
495
+ // An active row matches only on the live hash. (The previous
496
+ // column should be NULL on an active row, but defend against
497
+ // a hand-edit by refusing any previous-only match here.)
498
+ if (!matchedLive) return null;
499
+ } else {
500
+ return null;
501
+ }
502
+
503
+ return {
504
+ key_id: row.id,
505
+ owner_type: row.owner_type,
506
+ owner_id: row.owner_id,
507
+ scopes: _parseScopesJSON(row.scopes_json),
508
+ rate_limit_per_minute: Number(row.rate_limit_per_minute),
509
+ active: true,
510
+ };
511
+ },
512
+
513
+ revoke: async function (keyId, reason) {
514
+ var id = _uuid(keyId, "key_id");
515
+ var why = _reason(reason);
516
+ var current = await _getRaw(id);
517
+ if (!current) {
518
+ var miss = new Error("apiKeys.revoke: key not found");
519
+ miss.code = "API_KEY_NOT_FOUND";
520
+ throw miss;
521
+ }
522
+ if (current.status === "revoked") {
523
+ // Idempotent — re-revoke returns the existing terminal row.
524
+ return _project(current);
525
+ }
526
+ var ts = _now();
527
+ await query(
528
+ "UPDATE api_keys SET status = 'revoked', revoked_at = ?1, " +
529
+ "revoke_reason = ?2, token_hash_previous = NULL, rotated_at = NULL " +
530
+ "WHERE id = ?3",
531
+ [ts, why, id],
532
+ );
533
+ return _project(await _getRaw(id));
534
+ },
535
+
536
+ // Rotate the live hash. The old hash slides into
537
+ // `token_hash_previous` with a 24h overlap; verifyToken will
538
+ // continue to accept it (subject to the row's status check)
539
+ // until `rotated_at + ROTATION_GRACE_MS`. The row's status
540
+ // becomes `rotated` to advertise the grace window — the new
541
+ // plaintext is returned on a NEW row (so an operator can list
542
+ // the historical rotation chain). Returns
543
+ // `{ key_id: <new>, plaintext_token, ... , previous_key_id }`.
544
+ rotate: async function (keyId) {
545
+ var id = _uuid(keyId, "key_id");
546
+ var current = await _getRaw(id);
547
+ if (!current) {
548
+ var miss = new Error("apiKeys.rotate: key not found");
549
+ miss.code = "API_KEY_NOT_FOUND";
550
+ throw miss;
551
+ }
552
+ if (current.status !== "active") {
553
+ var refused = new Error(
554
+ "apiKeys.rotate: refused — key is " + current.status
555
+ );
556
+ refused.code = "API_KEY_NOT_ROTATABLE";
557
+ throw refused;
558
+ }
559
+
560
+ var ts = _now();
561
+ // Mark the existing row as rotated. The old hash moves into
562
+ // `token_hash_previous`; `token_hash` is cleared so the new
563
+ // row can claim the UNIQUE column without colliding. We use a
564
+ // placeholder hash (the row id under a rotate-marker namespace)
565
+ // so the UNIQUE constraint stays honoured.
566
+ var placeholderHash = _b().crypto.namespaceHash(
567
+ "api-key-rotated-placeholder", current.id + ":" + ts
568
+ );
569
+ await query(
570
+ "UPDATE api_keys SET status = 'rotated', rotated_at = ?1, " +
571
+ "token_hash_previous = ?2, token_hash = ?3 WHERE id = ?4",
572
+ [ts, current.token_hash, placeholderHash, id],
573
+ );
574
+
575
+ // Issue the replacement row. Scopes / rate-limit / expiry /
576
+ // owner all carry forward verbatim — rotation is plaintext-only.
577
+ var newId = _b().uuid.v7();
578
+ var plaintext = _generateToken();
579
+ var newHash = _hashToken(plaintext);
580
+ var row = {
581
+ id: newId,
582
+ owner_type: current.owner_type,
583
+ owner_id: current.owner_id,
584
+ name: current.name,
585
+ scopes_json: current.scopes_json,
586
+ token_hash: newHash,
587
+ rate_limit_per_minute: Number(current.rate_limit_per_minute),
588
+ expires_at: current.expires_at != null ? Number(current.expires_at) : null,
589
+ created_at: ts,
590
+ };
591
+ var finalPlaintext = await _insertKey(row, plaintext);
592
+
593
+ return {
594
+ key_id: newId,
595
+ previous_key_id: id,
596
+ plaintext_token: finalPlaintext,
597
+ token_hint: finalPlaintext.slice(finalPlaintext.length - 6),
598
+ owner_type: current.owner_type,
599
+ owner_id: current.owner_id,
600
+ name: current.name,
601
+ scopes: _parseScopesJSON(current.scopes_json),
602
+ rate_limit_per_minute: Number(current.rate_limit_per_minute),
603
+ expires_at: current.expires_at != null ? Number(current.expires_at) : null,
604
+ created_at: ts,
605
+ rotation_grace_ms: ROTATION_GRACE_MS,
606
+ };
607
+ },
608
+
609
+ listForOwner: async function (input) {
610
+ if (!input || typeof input !== "object") {
611
+ throw new TypeError("apiKeys.listForOwner: input object required");
612
+ }
613
+ var ownerType = _ownerType(input.owner_type);
614
+ var ownerId = null;
615
+ if (input.owner_id != null) {
616
+ ownerId = _uuid(input.owner_id, "owner_id");
617
+ }
618
+ var sql, params;
619
+ if (ownerId == null) {
620
+ sql = "SELECT * FROM api_keys WHERE owner_type = ?1 AND owner_id IS NULL " +
621
+ "ORDER BY created_at DESC, id DESC";
622
+ params = [ownerType];
623
+ } else {
624
+ sql = "SELECT * FROM api_keys WHERE owner_type = ?1 AND owner_id = ?2 " +
625
+ "ORDER BY created_at DESC, id DESC";
626
+ params = [ownerType, ownerId];
627
+ }
628
+ var r = await query(sql, params);
629
+ return r.rows.map(_project);
630
+ },
631
+
632
+ getKey: async function (keyId) {
633
+ var id = _uuid(keyId, "key_id");
634
+ return _project(await _getRaw(id));
635
+ },
636
+
637
+ // Patch-style update — only `name`, `scopes`, and
638
+ // `rate_limit_per_minute` can be set. Token hash / status /
639
+ // owner_type / owner_id are immutable post-issue (changing the
640
+ // owner would orphan the audit trail; changing the status
641
+ // happens through revoke / rotate / cleanupExpired).
642
+ update: async function (keyId, patch) {
643
+ var id = _uuid(keyId, "key_id");
644
+ if (!patch || typeof patch !== "object") {
645
+ throw new TypeError("apiKeys.update: patch object required");
646
+ }
647
+ var keys = Object.keys(patch);
648
+ if (!keys.length) {
649
+ throw new TypeError("apiKeys.update: patch must contain at least one column");
650
+ }
651
+ for (var i = 0; i < keys.length; i += 1) {
652
+ if (ALLOWED_UPDATE_COLUMNS.indexOf(keys[i]) === -1) {
653
+ throw new TypeError("apiKeys.update: column '" + keys[i] + "' not updatable");
654
+ }
655
+ }
656
+
657
+ var current = await _getRaw(id);
658
+ if (!current) return null;
659
+ if (current.status !== "active") {
660
+ var refused = new Error(
661
+ "apiKeys.update: refused — key is " + current.status
662
+ );
663
+ refused.code = "API_KEY_NOT_UPDATABLE";
664
+ throw refused;
665
+ }
666
+
667
+ var sets = [];
668
+ var params = [];
669
+ var idx = 1;
670
+ function _set(col, val) {
671
+ sets.push(col + " = ?" + idx);
672
+ params.push(val);
673
+ idx += 1;
674
+ }
675
+ if (patch.name != null) _set("name", _name(patch.name));
676
+ if (patch.scopes != null) _set("scopes_json", JSON.stringify(_scopes(patch.scopes)));
677
+ if (patch.rate_limit_per_minute != null) _set("rate_limit_per_minute", _rateLimit(patch.rate_limit_per_minute));
678
+
679
+ params.push(id);
680
+ var sql = "UPDATE api_keys SET " + sets.join(", ") + " WHERE id = ?" + idx;
681
+ await query(sql, params);
682
+ return _project(await _getRaw(id));
683
+ },
684
+
685
+ // Append-only usage row + bump `last_used_at` on the parent. The
686
+ // route layer is responsible for calling this on every admitted
687
+ // request; downstream rate-limit accounting reads the usage log
688
+ // across the last 60s.
689
+ recordUse: async function (input) {
690
+ if (!input || typeof input !== "object") {
691
+ throw new TypeError("apiKeys.recordUse: input object required");
692
+ }
693
+ var keyId = _uuid(input.key_id, "key_id");
694
+ var endpoint = _endpoint(input.endpoint);
695
+ var ts;
696
+ if (input.occurred_at != null) {
697
+ ts = _msEpoch(input.occurred_at, "occurred_at");
698
+ } else {
699
+ ts = _now();
700
+ }
701
+
702
+ var current = await _getRaw(keyId);
703
+ if (!current) {
704
+ var miss = new Error("apiKeys.recordUse: key not found");
705
+ miss.code = "API_KEY_NOT_FOUND";
706
+ throw miss;
707
+ }
708
+
709
+ var rowId = _b().uuid.v7();
710
+ await query(
711
+ "INSERT INTO api_key_usage (id, key_id, endpoint, occurred_at) " +
712
+ "VALUES (?1, ?2, ?3, ?4)",
713
+ [rowId, keyId, endpoint, ts],
714
+ );
715
+ await query(
716
+ "UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2",
717
+ [ts, keyId],
718
+ );
719
+ return { id: rowId, key_id: keyId, endpoint: endpoint, occurred_at: ts };
720
+ },
721
+
722
+ usageForKey: async function (input) {
723
+ if (!input || typeof input !== "object") {
724
+ throw new TypeError("apiKeys.usageForKey: input object required");
725
+ }
726
+ var keyId = _uuid(input.key_id, "key_id");
727
+ var from = _msEpoch(input.from, "from");
728
+ var to = _msEpoch(input.to, "to");
729
+ if (from > to) {
730
+ throw new TypeError("apiKeys.usageForKey: from must be <= to");
731
+ }
732
+ var r = await query(
733
+ "SELECT id, key_id, endpoint, occurred_at FROM api_key_usage " +
734
+ "WHERE key_id = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3 " +
735
+ "ORDER BY occurred_at DESC, id DESC",
736
+ [keyId, from, to],
737
+ );
738
+ return r.rows.map(function (row) {
739
+ return {
740
+ id: row.id,
741
+ key_id: row.key_id,
742
+ endpoint: row.endpoint,
743
+ occurred_at: Number(row.occurred_at),
744
+ };
745
+ });
746
+ },
747
+
748
+ // Sweep any active / rotated row whose `expires_at` has elapsed
749
+ // into the `expired` terminal status. Returns the count of rows
750
+ // swept. Idempotent — calling twice in a row produces 0 on the
751
+ // second call. Operators run this on a schedule (every 5
752
+ // minutes is plenty; expiry doesn't need to be instant because
753
+ // `verifyToken` already refuses elapsed-but-not-yet-swept rows).
754
+ cleanupExpired: async function (input) {
755
+ input = input || {};
756
+ var now;
757
+ if (input.now != null) {
758
+ now = _msEpoch(input.now, "now");
759
+ } else {
760
+ now = _now();
761
+ }
762
+ var r = await query(
763
+ "UPDATE api_keys SET status = 'expired' " +
764
+ "WHERE expires_at IS NOT NULL AND expires_at <= ?1 " +
765
+ "AND status IN ('active', 'rotated')",
766
+ [now],
767
+ );
768
+ return { swept: r.rowCount != null ? Number(r.rowCount) : 0, now: now };
769
+ },
770
+ };
771
+ }
772
+
773
+ module.exports = {
774
+ create: create,
775
+ TOKEN_NAMESPACE: TOKEN_NAMESPACE,
776
+ TOKEN_BYTE_LEN: TOKEN_BYTE_LEN,
777
+ TOKEN_PLAINTEXT_LEN: TOKEN_PLAINTEXT_LEN,
778
+ ROTATION_GRACE_MS: ROTATION_GRACE_MS,
779
+ OWNER_TYPES: OWNER_TYPES.slice(),
780
+ STATUSES: STATUSES.slice(),
781
+ MAX_NAME_LEN: MAX_NAME_LEN,
782
+ MAX_REASON_LEN: MAX_REASON_LEN,
783
+ MAX_ENDPOINT_LEN: MAX_ENDPOINT_LEN,
784
+ MAX_SCOPE_LEN: MAX_SCOPE_LEN,
785
+ MAX_SCOPES_PER_KEY: MAX_SCOPES_PER_KEY,
786
+ MIN_RATE_LIMIT: MIN_RATE_LIMIT,
787
+ MAX_RATE_LIMIT: MAX_RATE_LIMIT,
788
+ DEFAULT_RATE_LIMIT: DEFAULT_RATE_LIMIT,
789
+ };