@blamejs/blamejs-shop 0.4.17 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.17",
2
+ "version": "0.4.19",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/index.js CHANGED
@@ -124,6 +124,7 @@ Object.assign(module.exports, {
124
124
  cartBulkOps: require("./cart-bulk-ops"),
125
125
  carrierRates: require("./carrier-rates"),
126
126
  operatorAuditLog: require("./operator-audit-log"),
127
+ operatorAccounts: require("./operator-accounts"),
127
128
  cmsBlocks: require("./cms-blocks"),
128
129
  giftCardLedger: require("./gift-card-ledger"),
129
130
  discountAnalytics: require("./discount-analytics"),
@@ -0,0 +1,543 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorAccounts
4
+ * @title Operator accounts — the staff person who signs in to the
5
+ * admin console, holding their OWN credential
6
+ *
7
+ * @intro
8
+ * Today a single shared `ADMIN_API_KEY` guards the whole admin
9
+ * console. This primitive adds per-operator identity: several humans
10
+ * can run the shop with distinct logins and distinct roles, and the
11
+ * audit trail names WHO did each thing. `ADMIN_API_KEY` keeps working
12
+ * as the bootstrap / break-glass credential mapped to the owner role,
13
+ * so an upgrade never locks the operator out — and with zero rows in
14
+ * `operator_accounts` the console behaves byte-for-byte as it did
15
+ * before.
16
+ *
17
+ * An operator account holds:
18
+ *
19
+ * - `email` (display) + `email_hash` (login lookup key). The hash
20
+ * is `namespaceHash("operator-email", canonical-email)` so a
21
+ * database dump never carries a plaintext login index.
22
+ * - `password_hash` — an Argon2id PHC string from the vendored
23
+ * `b.auth.password.hash`. The plaintext never reaches storage;
24
+ * `b.auth.password.verify` does the constant-time check.
25
+ * - `api_key_hash` (optional) — the namespaceHash of a per-operator
26
+ * bearer token (32-byte CSPRNG draw, base64url). The plaintext is
27
+ * returned ONCE at mint time and never stored. The verify path
28
+ * runs `b.crypto.timingSafeEqual` so a wrong key and an unknown
29
+ * operator are indistinguishable on the wire.
30
+ * - `role` — one of `owner` | `manager` | `viewer` (the v1
31
+ * built-in role set; see `lib/operator-roles.js` for the
32
+ * operator-authored custom-role surface that layers on top).
33
+ * - `status` — `active` | `disabled`. A disabled operator's
34
+ * password + API key stop authenticating immediately.
35
+ *
36
+ * Surface:
37
+ *
38
+ * - createAccount({ email, display_name, password, role,
39
+ * created_by, mint_api_key? })
40
+ * Hash the password (Argon2id), optionally mint a per-operator
41
+ * API key, insert the row. Returns the public account shape;
42
+ * when `mint_api_key` is set the freshly-minted plaintext key
43
+ * rides back as `api_key` (the ONLY time it is ever visible).
44
+ *
45
+ * - verifyPassword({ email, password })
46
+ * Resolve the account by email hash, refuse non-active rows,
47
+ * and run `b.auth.password.verify`. Returns the public account on a
48
+ * match, `null` on every refuse path (unknown email, disabled,
49
+ * wrong password) — no oracle distinguishing the three.
50
+ *
51
+ * - verifyApiKey(plaintext)
52
+ * Resolve the account by the bearer token's namespaceHash, run
53
+ * a timing-safe compare against the stored hash, refuse
54
+ * non-active rows. Returns the public account on a match,
55
+ * `null` otherwise. Burns the same compare work on a miss as a
56
+ * hit so the latency profile matches.
57
+ *
58
+ * - getById(id) / getByEmail(email)
59
+ * Read the public account shape (never the hashes).
60
+ *
61
+ * - listAccounts({ status?, limit? })
62
+ * Enumerate accounts, newest-first.
63
+ *
64
+ * - setStatus({ id, status, actor_id })
65
+ * Flip `active` <-> `disabled`. Disabling is the v1 way to
66
+ * revoke an operator's access (the row + its audit grain
67
+ * survive). Idempotent.
68
+ *
69
+ * - setRole({ id, role, actor_id })
70
+ * Change an operator's built-in role.
71
+ *
72
+ * - rotateApiKey({ id, actor_id })
73
+ * Mint a fresh per-operator bearer token, replacing any prior
74
+ * one. Returns the new plaintext once.
75
+ *
76
+ * Composition:
77
+ * - `b.auth.password.hash` / `b.auth.password.verify` — Argon2id, OWASP-2026
78
+ * floor params. The single password primitive; never hand-rolled.
79
+ * - `b.crypto.generateBytes` / `b.crypto.toBase64Url` — API-key
80
+ * plaintext (32-byte CSPRNG draw).
81
+ * - `b.crypto.namespaceHash` — email-hash + api-key-hash keying.
82
+ * - `b.crypto.timingSafeEqual` — constant-time API-key compare.
83
+ * - `b.guardEmail` — strict email validation + canonicalization.
84
+ * - `b.uuid.v7` — account id (sorts by creation time).
85
+ * - `operatorAuditLog` (opt) — chained-hash audit of every account
86
+ * mutation when wired.
87
+ *
88
+ * Storage: `migrations-d1/0213_operator_accounts.sql`.
89
+ *
90
+ * @primitive operatorAccounts
91
+ * @related operatorRoles, operatorSessions, operatorAuditLog,
92
+ * b.password, b.crypto, b.guardEmail, b.uuid
93
+ */
94
+
95
+ var EMAIL_NAMESPACE = "operator-email";
96
+ var API_KEY_NAMESPACE = "operator-api-key";
97
+ var API_KEY_BYTES = 32;
98
+
99
+ var MAX_EMAIL_LEN = 320;
100
+ var MAX_DISPLAY_NAME_LEN = 128;
101
+ var MIN_PASSWORD_LEN = 12;
102
+ var MAX_PASSWORD_BYTES = 4096; // Argon2id plaintext ceiling
103
+ var MAX_LIST_LIMIT = 200;
104
+
105
+ var ROLES = Object.freeze(["owner", "manager", "viewer"]);
106
+ var STATUS = Object.freeze(["active", "disabled"]);
107
+
108
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
109
+
110
+ var b = require("./vendor/blamejs");
111
+
112
+ // ---- validators ---------------------------------------------------------
113
+
114
+ function _uuid(s, label) {
115
+ try { return b.guardUuid.sanitize(s, { profile: "strict" }); }
116
+ catch (e) { throw new TypeError("operatorAccounts: " + label + " — " + (e && e.message || "invalid UUID")); }
117
+ }
118
+
119
+ function _actorId(s, label) {
120
+ // The bootstrap / break-glass actor is the sentinel "owner" string
121
+ // (ADMIN_API_KEY-authed, not a UUID); operator actors are UUIDs.
122
+ // Accept either shape so the bootstrap path can record itself.
123
+ if (typeof s !== "string" || !s.length || s.length > 128) {
124
+ throw new TypeError("operatorAccounts: " + (label || "actor_id") + " must be a non-empty string (<= 128 chars)");
125
+ }
126
+ if (CONTROL_BYTE_RE.test(s)) {
127
+ throw new TypeError("operatorAccounts: " + (label || "actor_id") + " must not contain control bytes");
128
+ }
129
+ return s;
130
+ }
131
+
132
+ function _normalizeEmail(input) {
133
+ if (typeof input !== "string" || !input.length || input.length > MAX_EMAIL_LEN) {
134
+ throw new TypeError("operatorAccounts: email must be a non-empty string (<= " + MAX_EMAIL_LEN + " chars)");
135
+ }
136
+ var guardEmail = b.guardEmail;
137
+ var report;
138
+ try {
139
+ report = guardEmail.validate(input, { profile: "strict" });
140
+ } catch (e) {
141
+ throw new TypeError("operatorAccounts: email — " + (e && e.message || "invalid email"));
142
+ }
143
+ if (!report || report.ok === false) {
144
+ var first = (report && report.issues && report.issues[0]) || {};
145
+ throw new TypeError("operatorAccounts: email — " + (first.snippet || first.ruleId || "refused at strict profile"));
146
+ }
147
+ var canonical;
148
+ try {
149
+ canonical = guardEmail.sanitize(input, { profile: "strict" });
150
+ } catch (e) {
151
+ throw new TypeError("operatorAccounts: email — " + (e && e.message || "refused"));
152
+ }
153
+ var at = canonical.lastIndexOf("@");
154
+ if (at !== -1) {
155
+ canonical = canonical.slice(0, at) + "@" + canonical.slice(at + 1).toLowerCase();
156
+ }
157
+ return canonical;
158
+ }
159
+
160
+ function _displayName(s) {
161
+ if (typeof s !== "string" || !s.length || s.length > MAX_DISPLAY_NAME_LEN) {
162
+ throw new TypeError("operatorAccounts: display_name must be a non-empty string (<= " + MAX_DISPLAY_NAME_LEN + " chars)");
163
+ }
164
+ if (CONTROL_BYTE_RE.test(s)) {
165
+ throw new TypeError("operatorAccounts: display_name must not contain control bytes");
166
+ }
167
+ return s;
168
+ }
169
+
170
+ function _password(s) {
171
+ if (typeof s !== "string" || s.length < MIN_PASSWORD_LEN) {
172
+ throw new TypeError("operatorAccounts: password must be a string of at least " + MIN_PASSWORD_LEN + " characters");
173
+ }
174
+ if (Buffer.byteLength(s, "utf8") > MAX_PASSWORD_BYTES) {
175
+ throw new TypeError("operatorAccounts: password exceeds " + MAX_PASSWORD_BYTES + " bytes");
176
+ }
177
+ return s;
178
+ }
179
+
180
+ function _role(s) {
181
+ if (typeof s !== "string" || ROLES.indexOf(s) === -1) {
182
+ throw new TypeError("operatorAccounts: role must be one of " + ROLES.join(", "));
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _statusValue(s) {
188
+ if (typeof s !== "string" || STATUS.indexOf(s) === -1) {
189
+ throw new TypeError("operatorAccounts: status must be one of " + STATUS.join(", "));
190
+ }
191
+ return s;
192
+ }
193
+
194
+ function _bool(v, label) {
195
+ if (v == null) return false;
196
+ if (v === true || v === false) return v;
197
+ throw new TypeError("operatorAccounts: " + label + " must be a boolean");
198
+ }
199
+
200
+ function _limit(n) {
201
+ if (n == null) return 100;
202
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
203
+ throw new TypeError("operatorAccounts: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
204
+ }
205
+ return n;
206
+ }
207
+
208
+ // ---- row hydration ------------------------------------------------------
209
+ //
210
+ // The public shape NEVER carries password_hash / api_key_hash / the raw
211
+ // email_hash — only the fields a console screen or the auth resolver
212
+ // needs.
213
+
214
+ function _hydrate(r) {
215
+ if (!r) return null;
216
+ return {
217
+ id: r.id,
218
+ email: r.email,
219
+ display_name: r.display_name,
220
+ role: r.role,
221
+ status: r.status,
222
+ has_api_key: r.api_key_hash != null,
223
+ created_by: r.created_by,
224
+ created_at: Number(r.created_at),
225
+ updated_at: Number(r.updated_at),
226
+ disabled_at: r.disabled_at == null ? null : Number(r.disabled_at),
227
+ disabled_by: r.disabled_by == null ? null : r.disabled_by,
228
+ };
229
+ }
230
+
231
+ // ---- factory ------------------------------------------------------------
232
+
233
+ function create(opts) {
234
+ opts = opts || {};
235
+ var query = opts.query;
236
+ if (!query) {
237
+ query = function (sql, params) { return b.externalDb.query(sql, params); };
238
+ }
239
+ // Optional chained-audit peer. Duck-typed `.record(...)` so the tests
240
+ // can stub it without importing the full primitive.
241
+ var operatorAuditLog = opts.operatorAuditLog || null;
242
+
243
+ // Per-factory monotonic clock — two account mutations in the same
244
+ // wall-clock millisecond still carry strictly-increasing timestamps so
245
+ // the audit timeline sorts deterministically.
246
+ var _lastTs = 0;
247
+ function _now() {
248
+ var wall = Date.now();
249
+ if (wall > _lastTs) _lastTs = wall;
250
+ else _lastTs += 1;
251
+ return _lastTs;
252
+ }
253
+
254
+ // A real Argon2id PHC hash burned against a presented password when no
255
+ // account matches the email, so the unknown-email path pays the same
256
+ // memory-hard cost as a known-email-wrong-password path — closing the
257
+ // timing oracle on account existence. Computed once, lazily, on the
258
+ // first unknown-email verify and memoized thereafter.
259
+ var _dummyHashPromise = null;
260
+ function _dummyHash() {
261
+ if (!_dummyHashPromise) {
262
+ _dummyHashPromise = b.auth.password.hash("operator-accounts-timing-equalizer");
263
+ }
264
+ return _dummyHashPromise;
265
+ }
266
+
267
+ function _hashEmail(email) {
268
+ return b.crypto.namespaceHash(EMAIL_NAMESPACE, email);
269
+ }
270
+ function _hashApiKey(plaintext) {
271
+ return b.crypto.namespaceHash(API_KEY_NAMESPACE, plaintext);
272
+ }
273
+ function _mintApiKey() {
274
+ var plaintext = b.crypto.toBase64Url(b.crypto.generateBytes(API_KEY_BYTES));
275
+ return { plaintext: plaintext, hash: _hashApiKey(plaintext) };
276
+ }
277
+
278
+ async function _audit(action, actorId, accountId, before, after) {
279
+ if (!operatorAuditLog || typeof operatorAuditLog.record !== "function") return;
280
+ await operatorAuditLog.record({
281
+ actor_type: "operator",
282
+ actor_id: actorId,
283
+ action: "operator_account." + action,
284
+ resource_kind: "operator_account",
285
+ resource_id: accountId,
286
+ before: before,
287
+ after: after,
288
+ });
289
+ }
290
+
291
+ async function _rowById(id) {
292
+ return (await query(
293
+ "SELECT * FROM operator_accounts WHERE id = ?1 LIMIT 1",
294
+ [id],
295
+ )).rows[0] || null;
296
+ }
297
+
298
+ // ---- createAccount -------------------------------------------------
299
+
300
+ async function createAccount(input) {
301
+ if (!input || typeof input !== "object") {
302
+ throw new TypeError("operatorAccounts.createAccount: input object required");
303
+ }
304
+ var email = _normalizeEmail(input.email);
305
+ var displayName = _displayName(input.display_name);
306
+ var password = _password(input.password);
307
+ var role = _role(input.role);
308
+ var createdBy = _actorId(input.created_by, "created_by");
309
+ var mintApiKey = _bool(input.mint_api_key, "mint_api_key");
310
+
311
+ var emailHash = _hashEmail(email);
312
+ var existing = (await query(
313
+ "SELECT id FROM operator_accounts WHERE email_hash = ?1 LIMIT 1",
314
+ [emailHash],
315
+ )).rows[0];
316
+ if (existing) {
317
+ throw new TypeError("operatorAccounts.createAccount: an operator with that email already exists");
318
+ }
319
+
320
+ var passwordHash = await b.auth.password.hash(password);
321
+ var apiKey = null;
322
+ var apiKeyHash = null;
323
+ if (mintApiKey) {
324
+ var minted = _mintApiKey();
325
+ apiKey = minted.plaintext;
326
+ apiKeyHash = minted.hash;
327
+ }
328
+
329
+ var id = b.uuid.v7();
330
+ var ts = _now();
331
+ await query(
332
+ "INSERT INTO operator_accounts " +
333
+ "(id, email, email_hash, display_name, password_hash, api_key_hash, " +
334
+ " role, status, created_by, created_at, updated_at, disabled_at, disabled_by) " +
335
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, ?9, NULL, NULL)",
336
+ [id, email, emailHash, displayName, passwordHash, apiKeyHash, role, createdBy, ts],
337
+ );
338
+
339
+ await _audit("create", createdBy, id, null, { role: role, status: "active", has_api_key: !!apiKeyHash });
340
+
341
+ var account = _hydrate(await _rowById(id));
342
+ if (apiKey) account.api_key = apiKey;
343
+ return account;
344
+ }
345
+
346
+ // ---- verifyPassword ------------------------------------------------
347
+
348
+ async function verifyPassword(input) {
349
+ if (!input || typeof input !== "object") return null;
350
+ var email;
351
+ try { email = _normalizeEmail(input.email); }
352
+ catch (_e) { return null; }
353
+ if (typeof input.password !== "string" || !input.password.length) return null;
354
+
355
+ var row = (await query(
356
+ "SELECT * FROM operator_accounts WHERE email_hash = ?1 LIMIT 1",
357
+ [_hashEmail(email)],
358
+ )).rows[0];
359
+ if (!row) {
360
+ // Burn a real Argon2id verify against a memoized dummy hash so an
361
+ // unknown email pays the same memory-hard cost as a known one — no
362
+ // timing oracle on account existence. The result is discarded.
363
+ try { await b.auth.password.verify(await _dummyHash(), input.password); }
364
+ catch (_e) { /* drop — verify tolerates malformed input */ }
365
+ return null;
366
+ }
367
+ var ok = false;
368
+ try { ok = await b.auth.password.verify(row.password_hash, input.password); }
369
+ catch (_e) { ok = false; }
370
+ if (!ok) return null;
371
+ if (row.status !== "active") return null;
372
+ return _hydrate(row);
373
+ }
374
+
375
+ // ---- verifyApiKey --------------------------------------------------
376
+
377
+ async function verifyApiKey(plaintext) {
378
+ if (typeof plaintext !== "string" || !plaintext.length) return null;
379
+ var hash = _hashApiKey(plaintext);
380
+ var row = (await query(
381
+ "SELECT * FROM operator_accounts WHERE api_key_hash = ?1 LIMIT 1",
382
+ [hash],
383
+ )).rows[0];
384
+ if (!row || row.api_key_hash == null) {
385
+ // Burn the same compare work as the hit path so the latency
386
+ // profile matches between miss and hit.
387
+ b.crypto.timingSafeEqual(hash, hash);
388
+ return null;
389
+ }
390
+ var matched = b.crypto.timingSafeEqual(row.api_key_hash, hash);
391
+ if (!matched) return null;
392
+ if (row.status !== "active") return null;
393
+ return _hydrate(row);
394
+ }
395
+
396
+ // ---- getById / getByEmail ------------------------------------------
397
+
398
+ async function getById(id) {
399
+ var clean = _uuid(id, "id");
400
+ return _hydrate(await _rowById(clean));
401
+ }
402
+
403
+ async function getByEmail(email) {
404
+ var canonical = _normalizeEmail(email);
405
+ var row = (await query(
406
+ "SELECT * FROM operator_accounts WHERE email_hash = ?1 LIMIT 1",
407
+ [_hashEmail(canonical)],
408
+ )).rows[0];
409
+ return _hydrate(row);
410
+ }
411
+
412
+ // ---- listAccounts --------------------------------------------------
413
+
414
+ async function listAccounts(listOpts) {
415
+ listOpts = listOpts || {};
416
+ if (typeof listOpts !== "object") {
417
+ throw new TypeError("operatorAccounts.listAccounts: opts must be an object");
418
+ }
419
+ var status = listOpts.status == null ? null : _statusValue(listOpts.status);
420
+ var limit = _limit(listOpts.limit);
421
+ var sql, params;
422
+ if (status != null) {
423
+ sql = "SELECT * FROM operator_accounts WHERE status = ?1 ORDER BY created_at DESC, id DESC LIMIT ?2";
424
+ params = [status, limit];
425
+ } else {
426
+ sql = "SELECT * FROM operator_accounts ORDER BY created_at DESC, id DESC LIMIT ?1";
427
+ params = [limit];
428
+ }
429
+ var rows = (await query(sql, params)).rows;
430
+ var out = [];
431
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrate(rows[i]));
432
+ return out;
433
+ }
434
+
435
+ // ---- setStatus -----------------------------------------------------
436
+
437
+ async function setStatus(input) {
438
+ if (!input || typeof input !== "object") {
439
+ throw new TypeError("operatorAccounts.setStatus: input object required");
440
+ }
441
+ var id = _uuid(input.id, "id");
442
+ var status = _statusValue(input.status);
443
+ var actorId = _actorId(input.actor_id, "actor_id");
444
+
445
+ var current = await _rowById(id);
446
+ if (!current) {
447
+ throw new TypeError("operatorAccounts.setStatus: operator not found");
448
+ }
449
+ if (current.status === status) {
450
+ return _hydrate(current); // idempotent
451
+ }
452
+ var ts = _now();
453
+ if (status === "disabled") {
454
+ await query(
455
+ "UPDATE operator_accounts SET status = 'disabled', disabled_at = ?1, disabled_by = ?2, updated_at = ?1 WHERE id = ?3",
456
+ [ts, actorId, id],
457
+ );
458
+ } else {
459
+ await query(
460
+ "UPDATE operator_accounts SET status = 'active', disabled_at = NULL, disabled_by = NULL, updated_at = ?1 WHERE id = ?2",
461
+ [ts, id],
462
+ );
463
+ }
464
+ await _audit("set_status", actorId, id, { status: current.status }, { status: status });
465
+ return _hydrate(await _rowById(id));
466
+ }
467
+
468
+ // ---- setRole -------------------------------------------------------
469
+
470
+ async function setRole(input) {
471
+ if (!input || typeof input !== "object") {
472
+ throw new TypeError("operatorAccounts.setRole: input object required");
473
+ }
474
+ var id = _uuid(input.id, "id");
475
+ var role = _role(input.role);
476
+ var actorId = _actorId(input.actor_id, "actor_id");
477
+
478
+ var current = await _rowById(id);
479
+ if (!current) {
480
+ throw new TypeError("operatorAccounts.setRole: operator not found");
481
+ }
482
+ if (current.role === role) {
483
+ return _hydrate(current);
484
+ }
485
+ var ts = _now();
486
+ await query(
487
+ "UPDATE operator_accounts SET role = ?1, updated_at = ?2 WHERE id = ?3",
488
+ [role, ts, id],
489
+ );
490
+ await _audit("set_role", actorId, id, { role: current.role }, { role: role });
491
+ return _hydrate(await _rowById(id));
492
+ }
493
+
494
+ // ---- rotateApiKey --------------------------------------------------
495
+
496
+ async function rotateApiKey(input) {
497
+ if (!input || typeof input !== "object") {
498
+ throw new TypeError("operatorAccounts.rotateApiKey: input object required");
499
+ }
500
+ var id = _uuid(input.id, "id");
501
+ var actorId = _actorId(input.actor_id, "actor_id");
502
+
503
+ var current = await _rowById(id);
504
+ if (!current) {
505
+ throw new TypeError("operatorAccounts.rotateApiKey: operator not found");
506
+ }
507
+ var minted = _mintApiKey();
508
+ var ts = _now();
509
+ await query(
510
+ "UPDATE operator_accounts SET api_key_hash = ?1, updated_at = ?2 WHERE id = ?3",
511
+ [minted.hash, ts, id],
512
+ );
513
+ await _audit("rotate_api_key", actorId, id,
514
+ { has_api_key: current.api_key_hash != null }, { has_api_key: true });
515
+ var account = _hydrate(await _rowById(id));
516
+ account.api_key = minted.plaintext;
517
+ return account;
518
+ }
519
+
520
+ return {
521
+ ROLES: ROLES,
522
+ STATUS: STATUS,
523
+ createAccount: createAccount,
524
+ verifyPassword: verifyPassword,
525
+ verifyApiKey: verifyApiKey,
526
+ getById: getById,
527
+ getByEmail: getByEmail,
528
+ listAccounts: listAccounts,
529
+ setStatus: setStatus,
530
+ setRole: setRole,
531
+ rotateApiKey: rotateApiKey,
532
+ };
533
+ }
534
+
535
+ module.exports = {
536
+ create: create,
537
+ ROLES: ROLES,
538
+ STATUS: STATUS,
539
+ MIN_PASSWORD_LEN: MIN_PASSWORD_LEN,
540
+ MAX_DISPLAY_NAME_LEN: MAX_DISPLAY_NAME_LEN,
541
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
542
+ API_KEY_NAMESPACE: API_KEY_NAMESPACE,
543
+ };
package/lib/webhooks.js CHANGED
@@ -214,27 +214,59 @@ function create(opts) {
214
214
  return await _attempt(deliveryId, endpointRow, eventType, payloadJson);
215
215
  }
216
216
 
217
+ // The marker a refusal row carries in `last_error`. Rows bearing it are
218
+ // EXCLUDED from the rate-limit count — a refusal is the gate's own
219
+ // output, not a real delivery, so counting it would let the gate
220
+ // perpetuate its own throttle: once tripped, the refusal rows alone keep
221
+ // the window "full" and every subsequent send is refused forever even
222
+ // after the real deliveries age out.
223
+ var RATE_LIMITED_MARKER = "rate-limited";
224
+
217
225
  async function _checkRateLimit(endpointRow) {
218
226
  var limit = endpointRow.rate_limit_per_minute;
219
227
  if (typeof limit !== "number" || !isFinite(limit) || limit <= 0) return true;
220
228
  var windowStart = nowFn() - RATE_WINDOW_MS;
221
229
  var r = await query(
222
- "SELECT count(*) AS n FROM webhook_deliveries WHERE endpoint_id = ?1 AND created_at > ?2",
223
- [endpointRow.id, windowStart],
230
+ "SELECT count(*) AS n FROM webhook_deliveries " +
231
+ "WHERE endpoint_id = ?1 AND created_at > ?2 " +
232
+ "AND (last_error IS NULL OR last_error != ?3)",
233
+ [endpointRow.id, windowStart, RATE_LIMITED_MARKER],
224
234
  );
225
235
  var n = (r.rows[0] && (r.rows[0].n != null ? r.rows[0].n : r.rows[0]["count(*)"])) || 0;
226
236
  return n < limit;
227
237
  }
228
238
 
239
+ // Surface a single refusal row per endpoint per window so the operator
240
+ // sees the throttle in the admin feed — but DON'T grow the table by one
241
+ // row per suppressed attempt. A flood of suppressed events against a
242
+ // wedged receiver would otherwise write unbounded refusal rows. When a
243
+ // recent refusal row already exists for this endpoint, refresh its
244
+ // last_attempted_at (collapse) instead of inserting another.
229
245
  async function _persistRateLimited(endpointRow, eventType, payloadJson) {
230
- var deliveryId = b.uuid.v7();
231
246
  var ts = nowFn();
247
+ var windowStart = ts - RATE_WINDOW_MS;
248
+ var existing = await query(
249
+ "SELECT id FROM webhook_deliveries " +
250
+ "WHERE endpoint_id = ?1 AND last_error = ?2 AND created_at > ?3 " +
251
+ "ORDER BY created_at DESC LIMIT 1",
252
+ [endpointRow.id, RATE_LIMITED_MARKER, windowStart],
253
+ );
254
+ if (existing.rows[0]) {
255
+ var existingId = existing.rows[0].id;
256
+ await query(
257
+ "UPDATE webhook_deliveries SET last_attempted_at = ?1, event_type = ?2, payload_json = ?3 " +
258
+ "WHERE id = ?4",
259
+ [ts, eventType, payloadJson, existingId],
260
+ );
261
+ return await _getDelivery(existingId);
262
+ }
263
+ var deliveryId = b.uuid.v7();
232
264
  await query(
233
265
  "INSERT INTO webhook_deliveries " +
234
266
  "(id, endpoint_id, event_type, payload_json, attempts, last_status, last_error, " +
235
267
  " last_attempted_at, created_at) " +
236
268
  "VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?6, ?7)",
237
- [deliveryId, endpointRow.id, eventType, payloadJson, "rate-limited", ts, ts],
269
+ [deliveryId, endpointRow.id, eventType, payloadJson, RATE_LIMITED_MARKER, ts, ts],
238
270
  );
239
271
  return await _getDelivery(deliveryId);
240
272
  }
@@ -311,6 +343,13 @@ function create(opts) {
311
343
  // per-endpoint feed still surfaces the failure. The DLQ row
312
344
  // carries the full payload so replayFromDlq can re-queue
313
345
  // without consulting the original delivery.
346
+ //
347
+ // Idempotent per delivery: the INSERT...SELECT writes only when no
348
+ // dead-letter row already exists for this delivery_id. Without it, a
349
+ // manual retry of an already-exhausted delivery dropped a duplicate
350
+ // DLQ row on every click. The migration 0215 UNIQUE(delivery_id) is
351
+ // the schema backstop; this guard avoids relying on a swallowed
352
+ // constraint error.
314
353
  var dlqId = b.uuid.v7();
315
354
  var firstAttemptedAt;
316
355
  var r = await query(
@@ -322,7 +361,8 @@ function create(opts) {
322
361
  "INSERT INTO webhook_dlq " +
323
362
  "(id, endpoint_id, delivery_id, event_type, payload_json, attempts, " +
324
363
  " last_status, last_error, first_attempted_at, last_attempted_at, dropped_at) " +
325
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
364
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 " +
365
+ "WHERE NOT EXISTS (SELECT 1 FROM webhook_dlq WHERE delivery_id = ?3)",
326
366
  [dlqId, endpointRow.id, deliveryId, eventType, payloadJson, attempts,
327
367
  lastStatus, lastError, firstAttemptedAt, ts, ts],
328
368
  );
@@ -443,6 +483,16 @@ function create(opts) {
443
483
  _uuid(deliveryId, "delivery id");
444
484
  var d = await _getDelivery(deliveryId);
445
485
  if (!d) return null;
486
+ // An exhausted delivery (attempts at/over the max) has already
487
+ // moved to the DLQ — re-attempting it would push attempts past the
488
+ // max and drop a DUPLICATE DLQ row on every click. Answer a no-op
489
+ // (return the row unchanged) so the operator UI shows the existing
490
+ // state; re-queuing an exhausted delivery is `replayFromDlq`, not
491
+ // `retry`. A successfully-delivered row is likewise a no-op.
492
+ var attempts = Number(d.attempts || 0);
493
+ if (attempts >= MAX_ATTEMPTS || d.delivered_at != null) {
494
+ return d;
495
+ }
446
496
  var endpoint = await _getEndpoint(d.endpoint_id);
447
497
  if (!endpoint) return null;
448
498
  return await _attempt(deliveryId, endpoint, d.event_type, d.payload_json);