@blamejs/blamejs-shop 0.4.17 → 0.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +1 -1
- package/SECURITY.md +11 -0
- package/lib/admin.js +713 -23
- package/lib/asset-manifest.json +1 -1
- package/lib/index.js +1 -0
- package/lib/operator-accounts.js +543 -0
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
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/package.json
CHANGED