@fonderie/admin 1.1.0 → 1.2.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.
- package/README.md +56 -0
- package/brain/outcomes.md +65 -0
- package/brain/signatures.md +3 -1
- package/dist/index.cjs +1135 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +1141 -80
- package/dist/index.js.map +1 -1
- package/dist/migrations/sql/003_admin_operators.sql +55 -0
- package/dist/ui/app.global.js +13 -8
- package/package.json +3 -1
package/dist/index.cjs
CHANGED
|
@@ -4,8 +4,8 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
4
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
6
|
var __export = (target, all) => {
|
|
7
|
-
for (var
|
|
8
|
-
__defProp(target,
|
|
7
|
+
for (var name2 in all)
|
|
8
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
9
9
|
};
|
|
10
10
|
var __copyProps = (to, from, except, desc) => {
|
|
11
11
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
@@ -55,8 +55,8 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
|
55
55
|
|
|
56
56
|
// src/module.ts
|
|
57
57
|
var import_node_fs2 = require("fs");
|
|
58
|
-
var
|
|
59
|
-
var
|
|
58
|
+
var import_core5 = require("@fonderie/core");
|
|
59
|
+
var import_middlewares2 = require("@fonderie/core/middlewares");
|
|
60
60
|
|
|
61
61
|
// src/doctor.ts
|
|
62
62
|
function collectChecks(app, own) {
|
|
@@ -131,7 +131,9 @@ function adminLog(store, route, module2) {
|
|
|
131
131
|
const res = await next();
|
|
132
132
|
const row = {
|
|
133
133
|
// A scoped token names itself; X-Actor refines it; the root token is 'admin-token'.
|
|
134
|
-
|
|
134
|
+
// An operator session names itself and cannot be overridden: X-Actor
|
|
135
|
+
// is a client-supplied label, fine for a token, not for a person.
|
|
136
|
+
actor: (typeof ctx.meta["adminOperator"] === "string" ? `operator:${ctx.meta["adminOperator"]}` : null) || (typeof ctx.meta["adminActor"] === "string" ? ctx.meta["adminActor"] : null) || ctx.request.headers.get("x-actor") || (typeof ctx.meta["adminTokenName"] === "string" ? `token:${ctx.meta["adminTokenName"]}` : DEFAULT_ACTOR),
|
|
135
137
|
method: ctx.request.method,
|
|
136
138
|
// Normalized, so '/x' and '/x/' are one row for one route — the same
|
|
137
139
|
// path the router matched and `route` below records the pattern of.
|
|
@@ -234,11 +236,11 @@ async function migrationsReport(store, sets) {
|
|
|
234
236
|
const everApplied = await hasMigrationHistory(store);
|
|
235
237
|
const modules = [];
|
|
236
238
|
let firstBehind = null;
|
|
237
|
-
for (const [
|
|
239
|
+
for (const [name2, dir] of sets) {
|
|
238
240
|
const pending = await new import_store.InternalMigrationRunner(store, dir).pending();
|
|
239
241
|
const files = classifyPending(dir, pending);
|
|
240
242
|
modules.push({
|
|
241
|
-
name,
|
|
243
|
+
name: name2,
|
|
242
244
|
pending: files,
|
|
243
245
|
// Only a module BEFORE this one blocks it. The first module with
|
|
244
246
|
// pending work blocks every module after it, and nothing blocks it.
|
|
@@ -247,12 +249,12 @@ async function migrationsReport(store, sets) {
|
|
|
247
249
|
// destroying anything. Same rule the CLI's --check applies.
|
|
248
250
|
appliable: files.length > 0 && firstBehind === null && (!everApplied || files.every((f) => f.impact === "additive"))
|
|
249
251
|
});
|
|
250
|
-
if (files.length > 0 && firstBehind === null) firstBehind =
|
|
252
|
+
if (files.length > 0 && firstBehind === null) firstBehind = name2;
|
|
251
253
|
}
|
|
252
254
|
return { everApplied, modules };
|
|
253
255
|
}
|
|
254
256
|
async function applyModuleMigrations(store, sets, module2, expect) {
|
|
255
|
-
const set = sets.find(([
|
|
257
|
+
const set = sets.find(([name2]) => name2 === module2);
|
|
256
258
|
if (!set) return { ok: false, reason: "NOT_FOUND" };
|
|
257
259
|
const before = await migrationsReport(store, sets);
|
|
258
260
|
const target = before.modules.find((m) => m.name === module2);
|
|
@@ -292,13 +294,13 @@ function buildManifest(app, admin) {
|
|
|
292
294
|
byModule.set(p.module, list);
|
|
293
295
|
}
|
|
294
296
|
const describing = new Set(app.adminDescriptions().map((d) => d.module));
|
|
295
|
-
const modules = report.modules.map(({ name, version }) => {
|
|
296
|
-
const problems = byModule.get(
|
|
297
|
+
const modules = report.modules.map(({ name: name2, version }) => {
|
|
298
|
+
const problems = byModule.get(name2) ?? [];
|
|
297
299
|
return {
|
|
298
|
-
name,
|
|
300
|
+
name: name2,
|
|
299
301
|
version: version ?? null,
|
|
300
302
|
readiness: { ok: !problems.some((p) => p.severity === "error"), problems },
|
|
301
|
-
describesAdmin: describing.has(
|
|
303
|
+
describesAdmin: describing.has(name2)
|
|
302
304
|
};
|
|
303
305
|
});
|
|
304
306
|
return {
|
|
@@ -320,10 +322,10 @@ function environmentReport(app, env) {
|
|
|
320
322
|
return {
|
|
321
323
|
generatedAt: report.generatedAt,
|
|
322
324
|
readiness: report.readiness,
|
|
323
|
-
modules: report.modules.map(({ name }) => ({ name, problems: byModule.get(
|
|
324
|
-
env: env.map((
|
|
325
|
-
name,
|
|
326
|
-
set: process.env[
|
|
325
|
+
modules: report.modules.map(({ name: name2 }) => ({ name: name2, problems: byModule.get(name2) ?? [] })),
|
|
326
|
+
env: env.map((name2) => ({
|
|
327
|
+
name: name2,
|
|
328
|
+
set: process.env[name2] !== void 0 && process.env[name2] !== ""
|
|
327
329
|
}))
|
|
328
330
|
};
|
|
329
331
|
}
|
|
@@ -346,7 +348,7 @@ function tokensReport(app, adminModule, issued) {
|
|
|
346
348
|
return {
|
|
347
349
|
generatedAt: report.generatedAt,
|
|
348
350
|
admin: { ok: !adminProblems.some((p) => p.severity === "error"), problems: adminProblems },
|
|
349
|
-
legacy: report.modules.map(({ name }) => ({ module:
|
|
351
|
+
legacy: report.modules.map(({ name: name2 }) => ({ module: name2, set: legacyOwners.has(name2) })).filter((e) => e.set),
|
|
350
352
|
issued
|
|
351
353
|
};
|
|
352
354
|
}
|
|
@@ -357,8 +359,1014 @@ function isLegacyAdminPath(method, path) {
|
|
|
357
359
|
}
|
|
358
360
|
|
|
359
361
|
// src/tokens.ts
|
|
362
|
+
var import_node_crypto2 = require("crypto");
|
|
363
|
+
var import_core4 = require("@fonderie/core");
|
|
364
|
+
|
|
365
|
+
// src/operators/http.ts
|
|
366
|
+
var import_core3 = require("@fonderie/core");
|
|
367
|
+
var import_middlewares = require("@fonderie/core/middlewares");
|
|
368
|
+
|
|
369
|
+
// src/operators/crypto.ts
|
|
360
370
|
var import_node_crypto = require("crypto");
|
|
361
371
|
var import_core2 = require("@fonderie/core");
|
|
372
|
+
var STEP = 30;
|
|
373
|
+
var DRIFT = 1;
|
|
374
|
+
var DIGITS = 6;
|
|
375
|
+
var B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
376
|
+
function base32Encode(buf) {
|
|
377
|
+
let bits = 0;
|
|
378
|
+
let value = 0;
|
|
379
|
+
let out = "";
|
|
380
|
+
for (const byte of buf) {
|
|
381
|
+
value = value << 8 | byte;
|
|
382
|
+
bits += 8;
|
|
383
|
+
while (bits >= 5) {
|
|
384
|
+
out += B32[value >>> bits - 5 & 31];
|
|
385
|
+
bits -= 5;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (bits > 0) out += B32[value << 5 - bits & 31];
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
function base32Decode(input) {
|
|
392
|
+
let bits = 0;
|
|
393
|
+
let value = 0;
|
|
394
|
+
const out = [];
|
|
395
|
+
for (const ch of input.toUpperCase().replace(/[=\s]/g, "")) {
|
|
396
|
+
const i = B32.indexOf(ch);
|
|
397
|
+
if (i === -1) continue;
|
|
398
|
+
value = value << 5 | i;
|
|
399
|
+
bits += 5;
|
|
400
|
+
if (bits >= 8) {
|
|
401
|
+
out.push(value >>> bits - 8 & 255);
|
|
402
|
+
bits -= 8;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return Buffer.from(out);
|
|
406
|
+
}
|
|
407
|
+
function hotp(secret, counter) {
|
|
408
|
+
const msg = Buffer.alloc(8);
|
|
409
|
+
msg.writeBigUInt64BE(BigInt(counter));
|
|
410
|
+
const mac = (0, import_node_crypto.createHmac)("sha1", base32Decode(secret)).update(msg).digest();
|
|
411
|
+
const o = (mac[19] ?? 0) & 15;
|
|
412
|
+
const bin = ((mac[o] ?? 0) & 127) << 24 | ((mac[o + 1] ?? 0) & 255) << 16 | ((mac[o + 2] ?? 0) & 255) << 8 | (mac[o + 3] ?? 0) & 255;
|
|
413
|
+
return String(bin % 10 ** DIGITS).padStart(DIGITS, "0");
|
|
414
|
+
}
|
|
415
|
+
var totpCounter = (now = Date.now()) => Math.floor(now / 1e3 / STEP);
|
|
416
|
+
function verifyTotp(secret, code, lastUsed, now = Date.now()) {
|
|
417
|
+
const clean = code.replace(/\s/g, "");
|
|
418
|
+
if (!/^\d{6}$/.test(clean)) return null;
|
|
419
|
+
const t = totpCounter(now);
|
|
420
|
+
for (let d = -DRIFT; d <= DRIFT; d++) {
|
|
421
|
+
const step = t + d;
|
|
422
|
+
if (lastUsed !== null && step <= lastUsed) continue;
|
|
423
|
+
if ((0, import_core2.constantTimeEqual)(hotp(secret, step), clean)) return step;
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
var newTotpSecret = () => base32Encode((0, import_node_crypto.randomBytes)(20));
|
|
428
|
+
function totpUri(issuer, account, secret) {
|
|
429
|
+
const q = new URLSearchParams({
|
|
430
|
+
secret,
|
|
431
|
+
issuer,
|
|
432
|
+
algorithm: "SHA1",
|
|
433
|
+
digits: String(DIGITS),
|
|
434
|
+
period: String(STEP)
|
|
435
|
+
});
|
|
436
|
+
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?${q}`;
|
|
437
|
+
}
|
|
438
|
+
function newBackupCodes(count = 10) {
|
|
439
|
+
return Array.from({ length: count }, () => {
|
|
440
|
+
const s = base32Encode((0, import_node_crypto.randomBytes)(7)).slice(0, 10);
|
|
441
|
+
return `${s.slice(0, 5)}-${s.slice(5)}`;
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
var normalizeBackupCode = (code) => code.toUpperCase().replace(/[^A-Z2-7]/g, "");
|
|
445
|
+
var sha256 = (s) => (0, import_node_crypto.createHash)("sha256").update(s).digest("hex");
|
|
446
|
+
var N = 2 ** 15;
|
|
447
|
+
var R = 8;
|
|
448
|
+
var P = 1;
|
|
449
|
+
var KEYLEN = 32;
|
|
450
|
+
var scrypt = (password2, salt, n, r, p) => new Promise(
|
|
451
|
+
(resolve, reject) => (0, import_node_crypto.scrypt)(
|
|
452
|
+
password2.normalize("NFKC"),
|
|
453
|
+
salt,
|
|
454
|
+
KEYLEN,
|
|
455
|
+
{ N: n, r, p, maxmem: 128 * n * r * 2 },
|
|
456
|
+
(err, key) => err ? reject(err) : resolve(key)
|
|
457
|
+
)
|
|
458
|
+
);
|
|
459
|
+
async function hashPassword(password2) {
|
|
460
|
+
const salt = (0, import_node_crypto.randomBytes)(16);
|
|
461
|
+
const key = await scrypt(password2, salt, N, R, P);
|
|
462
|
+
return `scrypt$${N}$${R}$${P}$${salt.toString("base64")}$${key.toString("base64")}`;
|
|
463
|
+
}
|
|
464
|
+
async function verifyPassword(password2, stored) {
|
|
465
|
+
const [kind, n, r, p, salt, hash] = stored.split("$");
|
|
466
|
+
if (kind !== "scrypt" || !n || !r || !p || !salt || !hash) return false;
|
|
467
|
+
const expected = Buffer.from(hash, "base64");
|
|
468
|
+
const key = await scrypt(password2, Buffer.from(salt, "base64"), Number(n), Number(r), Number(p));
|
|
469
|
+
return key.length === expected.length && (0, import_node_crypto.timingSafeEqual)(key, expected);
|
|
470
|
+
}
|
|
471
|
+
var dummy = null;
|
|
472
|
+
var dummyHash = () => {
|
|
473
|
+
dummy ??= hashPassword((0, import_node_crypto.randomBytes)(16).toString("hex"));
|
|
474
|
+
return dummy;
|
|
475
|
+
};
|
|
476
|
+
function passwordProblem(password2) {
|
|
477
|
+
if (typeof password2 !== "string" || password2.length < 12) return "Use at least 12 characters.";
|
|
478
|
+
if (password2.length > 256) return "Use at most 256 characters.";
|
|
479
|
+
if (/^(.)\1+$/.test(password2)) return "That password is too easy to guess.";
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
var PREFIX = "op.v1:";
|
|
483
|
+
function secretBox(keyHex) {
|
|
484
|
+
if (!keyHex) return { seal: (p) => p, open: (s) => s };
|
|
485
|
+
const key = Buffer.from(keyHex, "hex");
|
|
486
|
+
if (key.length !== 32)
|
|
487
|
+
throw new Error("[admin] operatorKey must be 32 bytes \u2014 64 hex chars (`openssl rand -hex 32`)");
|
|
488
|
+
return {
|
|
489
|
+
seal(plain) {
|
|
490
|
+
const iv = (0, import_node_crypto.randomBytes)(12);
|
|
491
|
+
const c = (0, import_node_crypto.createCipheriv)("aes-256-gcm", key, iv);
|
|
492
|
+
const data = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
493
|
+
return `${PREFIX}${iv.toString("hex")}:${c.getAuthTag().toString("hex")}:${data.toString("hex")}`;
|
|
494
|
+
},
|
|
495
|
+
open(stored) {
|
|
496
|
+
if (!stored.startsWith(PREFIX)) return stored;
|
|
497
|
+
const [iv, tag, data] = stored.slice(PREFIX.length).split(":");
|
|
498
|
+
if (!iv || !tag || !data) throw new Error("[admin] malformed operator secret");
|
|
499
|
+
const d = (0, import_node_crypto.createDecipheriv)("aes-256-gcm", key, Buffer.from(iv, "hex"));
|
|
500
|
+
d.setAuthTag(Buffer.from(tag, "hex"));
|
|
501
|
+
return Buffer.concat([d.update(Buffer.from(data, "hex")), d.final()]).toString("utf8");
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
var newOpaqueToken = (prefix) => `${prefix}_${(0, import_node_crypto.randomBytes)(32).toString("base64url")}`;
|
|
506
|
+
|
|
507
|
+
// src/operators/schemas.ts
|
|
508
|
+
function schema(fields, opts = {}) {
|
|
509
|
+
return {
|
|
510
|
+
safeParse(input) {
|
|
511
|
+
const fail = (path, message) => ({
|
|
512
|
+
success: false,
|
|
513
|
+
error: { issues: [{ path: [path], message }] }
|
|
514
|
+
});
|
|
515
|
+
const b = input && typeof input === "object" ? input : {};
|
|
516
|
+
const out = {};
|
|
517
|
+
for (const [key, f] of Object.entries(fields)) {
|
|
518
|
+
const v = b[key];
|
|
519
|
+
if (v === void 0 || v === null) {
|
|
520
|
+
if (f.required) return fail(key, `${key} is required`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (f.type === "string") {
|
|
524
|
+
if (typeof v !== "string" || v.length === 0 || v.length > f.max)
|
|
525
|
+
return fail(key, `${key} must be a string of 1\u2013${f.max} characters`);
|
|
526
|
+
} else if (f.type === "boolean") {
|
|
527
|
+
if (typeof v !== "boolean") return fail(key, `${key} must be true or false`);
|
|
528
|
+
} else if (f.type === "integer") {
|
|
529
|
+
if (!Number.isInteger(v) || v < f.min || v > f.max)
|
|
530
|
+
return fail(key, `${key} must be an integer ${f.min}\u2013${f.max}`);
|
|
531
|
+
} else if (!Array.isArray(v) || v.length === 0 || !v.every((s) => SCOPES.includes(s))) {
|
|
532
|
+
return fail(key, `${key} must be a non-empty list of ${SCOPES.join(", ")}`);
|
|
533
|
+
}
|
|
534
|
+
out[key] = v;
|
|
535
|
+
}
|
|
536
|
+
if (opts.oneOf && !opts.oneOf.some((k) => out[k] !== void 0)) {
|
|
537
|
+
return fail(opts.oneOf[0] ?? "body", `one of ${opts.oneOf.join(", ")} is required`);
|
|
538
|
+
}
|
|
539
|
+
return { success: true, data: out };
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
var email = { type: "string", required: true, max: 320 };
|
|
544
|
+
var password = { type: "string", required: true, max: 256 };
|
|
545
|
+
var name = { type: "string", max: 200 };
|
|
546
|
+
var token = { type: "string", required: true, max: 200 };
|
|
547
|
+
var claimSchema = schema({ email, password, name });
|
|
548
|
+
var loginSchema = schema({ email, password });
|
|
549
|
+
var enrollmentSchema = schema({ code: { type: "string", required: true, max: 12 } });
|
|
550
|
+
var factorSchema = schema(
|
|
551
|
+
{ code: { type: "string", max: 12 }, backupCode: { type: "string", max: 32 } },
|
|
552
|
+
{ oneOf: ["code", "backupCode"] }
|
|
553
|
+
);
|
|
554
|
+
var inspectLinkSchema = schema({ token });
|
|
555
|
+
var redeemLinkSchema = schema({ token, password, name });
|
|
556
|
+
var inviteSchema = schema({
|
|
557
|
+
email,
|
|
558
|
+
scopes: { type: "scopes", required: true },
|
|
559
|
+
expiresInHours: { type: "integer", min: 1, max: 336 }
|
|
560
|
+
});
|
|
561
|
+
var updateOperatorSchema = schema({
|
|
562
|
+
scopes: { type: "scopes" },
|
|
563
|
+
name,
|
|
564
|
+
disabled: { type: "boolean" }
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// src/operators/service.ts
|
|
568
|
+
var IDLE_MS = 30 * 6e4;
|
|
569
|
+
var ABSOLUTE_MS = 12 * 36e5;
|
|
570
|
+
var PENDING_MS = 10 * 6e4;
|
|
571
|
+
var STEP_UP_MS = 5 * 6e4;
|
|
572
|
+
var INVITE_HOURS = 72;
|
|
573
|
+
var RECOVERY_HOURS = 24;
|
|
574
|
+
var MAX_FAILURES = 5;
|
|
575
|
+
var OP_COLS = `id, email, name, password_hash AS "passwordHash", scopes, totp_secret AS "totpSecret",
|
|
576
|
+
totp_confirmed_at AS "totpConfirmedAt", totp_last_step AS "totpLastStep", backup_codes AS "backupCodes",
|
|
577
|
+
failed_attempts AS "failedAttempts", locked_until AS "lockedUntil", created_by AS "createdBy",
|
|
578
|
+
created_at AS "createdAt", last_login_at AS "lastLoginAt", disabled_at AS "disabledAt"`;
|
|
579
|
+
var SESSION_COLS = `id_hash AS "idHash", operator_id AS "operatorId", stage, created_at AS "createdAt",
|
|
580
|
+
last_seen_at AS "lastSeenAt", expires_at AS "expiresAt", step_up_at AS "stepUpAt"`;
|
|
581
|
+
var normalizeEmail = (e) => e.trim().toLowerCase();
|
|
582
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
583
|
+
async function operatorCount(store) {
|
|
584
|
+
const [row] = await store.query(
|
|
585
|
+
`SELECT count(*)::text AS n FROM fonderie_admin_operators`
|
|
586
|
+
);
|
|
587
|
+
return Number(row?.n ?? 0);
|
|
588
|
+
}
|
|
589
|
+
async function findOperator(store, by) {
|
|
590
|
+
const [row] = by.id ? await store.query(
|
|
591
|
+
`SELECT ${OP_COLS} FROM fonderie_admin_operators WHERE id = $1`,
|
|
592
|
+
[by.id]
|
|
593
|
+
) : await store.query(
|
|
594
|
+
`SELECT ${OP_COLS} FROM fonderie_admin_operators WHERE email = $1`,
|
|
595
|
+
[normalizeEmail(by.email ?? "")]
|
|
596
|
+
);
|
|
597
|
+
return row ?? null;
|
|
598
|
+
}
|
|
599
|
+
async function claimFirstOperator(store, input) {
|
|
600
|
+
const passwordHash = await hashPassword(input.password);
|
|
601
|
+
return store.transaction(async (tx) => {
|
|
602
|
+
await tx.query(`SELECT pg_advisory_xact_lock(hashtext('fonderie_admin_claim'))`);
|
|
603
|
+
const [row] = await tx.query(
|
|
604
|
+
`INSERT INTO fonderie_admin_operators (email, name, password_hash, scopes, created_by)
|
|
605
|
+
SELECT $1, $2, $3, $4, 'claim'
|
|
606
|
+
WHERE NOT EXISTS (SELECT 1 FROM fonderie_admin_operators)
|
|
607
|
+
RETURNING ${OP_COLS}`,
|
|
608
|
+
[normalizeEmail(input.email), input.name ?? null, passwordHash, ["read", "write", "secrets"]]
|
|
609
|
+
);
|
|
610
|
+
return row ?? null;
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
async function recordFailure(store, id) {
|
|
614
|
+
await store.query(
|
|
615
|
+
`UPDATE fonderie_admin_operators
|
|
616
|
+
SET failed_attempts = failed_attempts + 1,
|
|
617
|
+
locked_until = CASE WHEN failed_attempts + 1 >= $2
|
|
618
|
+
THEN now() + make_interval(mins => LEAST(60, power(2, failed_attempts + 1 - $2)::int))
|
|
619
|
+
ELSE locked_until END
|
|
620
|
+
WHERE id = $1`,
|
|
621
|
+
[id, MAX_FAILURES]
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
var isLocked = (op) => op.lockedUntil !== null && new Date(op.lockedUntil).getTime() > Date.now();
|
|
625
|
+
function lockMinutes(op) {
|
|
626
|
+
return op.lockedUntil ? Math.max(1, Math.ceil((new Date(op.lockedUntil).getTime() - Date.now()) / 6e4)) : 0;
|
|
627
|
+
}
|
|
628
|
+
async function checkPassword(store, email2, password2) {
|
|
629
|
+
const op = await findOperator(store, { email: email2 });
|
|
630
|
+
if (!op) {
|
|
631
|
+
await verifyPassword(password2, await dummyHash());
|
|
632
|
+
return { op: null, ok: false };
|
|
633
|
+
}
|
|
634
|
+
if (op.disabledAt) {
|
|
635
|
+
await verifyPassword(password2, await dummyHash());
|
|
636
|
+
return { op: null, ok: false };
|
|
637
|
+
}
|
|
638
|
+
if (isLocked(op)) return { op, ok: false, locked: true };
|
|
639
|
+
const ok = await verifyPassword(password2, op.passwordHash);
|
|
640
|
+
if (!ok) await recordFailure(store, op.id);
|
|
641
|
+
return { op, ok };
|
|
642
|
+
}
|
|
643
|
+
async function checkSecondFactor(store, box, op, input) {
|
|
644
|
+
if (isLocked(op)) return { ok: false };
|
|
645
|
+
if (typeof input.code === "string" && op.totpSecret) {
|
|
646
|
+
const last = op.totpLastStep === null ? null : Number(op.totpLastStep);
|
|
647
|
+
const step = verifyTotp(box.open(op.totpSecret), input.code, last);
|
|
648
|
+
if (step !== null) {
|
|
649
|
+
const rows = await store.query(
|
|
650
|
+
`UPDATE fonderie_admin_operators SET totp_last_step = $2, failed_attempts = 0, locked_until = NULL
|
|
651
|
+
WHERE id = $1 AND (totp_last_step IS NULL OR totp_last_step < $2) RETURNING id`,
|
|
652
|
+
[op.id, step]
|
|
653
|
+
);
|
|
654
|
+
if (rows.length) return { ok: true, via: "totp" };
|
|
655
|
+
}
|
|
656
|
+
} else if (typeof input.backupCode === "string") {
|
|
657
|
+
const h = sha256(normalizeBackupCode(input.backupCode));
|
|
658
|
+
const [row] = await store.query(
|
|
659
|
+
`UPDATE fonderie_admin_operators
|
|
660
|
+
SET backup_codes = array_remove(backup_codes, $2), failed_attempts = 0, locked_until = NULL
|
|
661
|
+
WHERE id = $1 AND $2 = ANY(backup_codes)
|
|
662
|
+
RETURNING coalesce(array_length(backup_codes, 1), 0) AS left`,
|
|
663
|
+
[op.id, h]
|
|
664
|
+
);
|
|
665
|
+
if (row) return { ok: true, via: "backup", backupLeft: Number(row.left ?? 0) };
|
|
666
|
+
}
|
|
667
|
+
await recordFailure(store, op.id);
|
|
668
|
+
return { ok: false };
|
|
669
|
+
}
|
|
670
|
+
async function enrollmentSecret(store, box, op) {
|
|
671
|
+
if (op.totpSecret && !op.totpConfirmedAt) return box.open(op.totpSecret);
|
|
672
|
+
const secret = newTotpSecret();
|
|
673
|
+
await store.query(
|
|
674
|
+
`UPDATE fonderie_admin_operators SET totp_secret = $2 WHERE id = $1 AND totp_confirmed_at IS NULL`,
|
|
675
|
+
[op.id, box.seal(secret)]
|
|
676
|
+
);
|
|
677
|
+
return secret;
|
|
678
|
+
}
|
|
679
|
+
async function confirmEnrollment(store, box, op, code) {
|
|
680
|
+
if (!op.totpSecret || op.totpConfirmedAt || isLocked(op)) return null;
|
|
681
|
+
const step = verifyTotp(box.open(op.totpSecret), code, null);
|
|
682
|
+
if (step === null) {
|
|
683
|
+
await recordFailure(store, op.id);
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
const codes = newBackupCodes();
|
|
687
|
+
await store.query(
|
|
688
|
+
`UPDATE fonderie_admin_operators
|
|
689
|
+
SET totp_confirmed_at = now(), totp_last_step = $2, backup_codes = $3,
|
|
690
|
+
failed_attempts = 0, locked_until = NULL, last_login_at = now()
|
|
691
|
+
WHERE id = $1`,
|
|
692
|
+
[op.id, step, codes.map((c) => sha256(normalizeBackupCode(c)))]
|
|
693
|
+
);
|
|
694
|
+
return codes;
|
|
695
|
+
}
|
|
696
|
+
async function listOperators(store) {
|
|
697
|
+
const operators = await store.query(
|
|
698
|
+
`SELECT ${OP_COLS} FROM fonderie_admin_operators ORDER BY created_at`
|
|
699
|
+
);
|
|
700
|
+
const links = await store.query(
|
|
701
|
+
`SELECT id, kind, email, scopes, created_by AS "createdBy", created_at AS "createdAt", expires_at AS "expiresAt"
|
|
702
|
+
FROM fonderie_admin_invites WHERE used_at IS NULL AND expires_at > now() ORDER BY created_at DESC`
|
|
703
|
+
);
|
|
704
|
+
return { operators: operators.map(publicOperator), links };
|
|
705
|
+
}
|
|
706
|
+
function publicOperator(op) {
|
|
707
|
+
return {
|
|
708
|
+
id: op.id,
|
|
709
|
+
email: op.email,
|
|
710
|
+
name: op.name,
|
|
711
|
+
scopes: op.scopes,
|
|
712
|
+
enrolled: op.totpConfirmedAt !== null,
|
|
713
|
+
backupCodesLeft: op.backupCodes.length,
|
|
714
|
+
locked: isLocked(op),
|
|
715
|
+
createdBy: op.createdBy,
|
|
716
|
+
createdAt: op.createdAt,
|
|
717
|
+
lastLoginAt: op.lastLoginAt,
|
|
718
|
+
disabledAt: op.disabledAt
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
async function createLink(store, input) {
|
|
722
|
+
const token2 = newOpaqueToken(input.kind === "invite" ? "fai" : "far");
|
|
723
|
+
if (input.operatorId) {
|
|
724
|
+
await store.query(
|
|
725
|
+
`UPDATE fonderie_admin_invites SET used_at = now() WHERE operator_id = $1 AND used_at IS NULL`,
|
|
726
|
+
[input.operatorId]
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
const [row] = await store.query(
|
|
730
|
+
`INSERT INTO fonderie_admin_invites (kind, token_hash, email, scopes, operator_id, created_by, expires_at)
|
|
731
|
+
VALUES ($1, $2, $3, $4, $5, $6, now() + make_interval(hours => $7)) RETURNING id, expires_at AS "expiresAt"`,
|
|
732
|
+
[
|
|
733
|
+
input.kind,
|
|
734
|
+
sha256(token2),
|
|
735
|
+
normalizeEmail(input.email),
|
|
736
|
+
input.scopes ?? [],
|
|
737
|
+
input.operatorId ?? null,
|
|
738
|
+
input.createdBy,
|
|
739
|
+
input.hours
|
|
740
|
+
]
|
|
741
|
+
);
|
|
742
|
+
if (!row) throw new Error("[admin] link insert returned no row");
|
|
743
|
+
return { id: row.id, token: token2, expiresAt: row.expiresAt };
|
|
744
|
+
}
|
|
745
|
+
async function findLink(store, token2) {
|
|
746
|
+
const [row] = await store.query(
|
|
747
|
+
`SELECT id, kind, email, scopes, operator_id AS "operatorId", created_by AS "createdBy"
|
|
748
|
+
FROM fonderie_admin_invites WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`,
|
|
749
|
+
[sha256(token2)]
|
|
750
|
+
);
|
|
751
|
+
return row ?? null;
|
|
752
|
+
}
|
|
753
|
+
async function redeemLink(store, token2, input) {
|
|
754
|
+
const passwordHash = await hashPassword(input.password);
|
|
755
|
+
return store.transaction(async (tx) => {
|
|
756
|
+
const [link] = await tx.query(
|
|
757
|
+
`UPDATE fonderie_admin_invites SET used_at = now()
|
|
758
|
+
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
|
|
759
|
+
RETURNING kind, email, scopes, operator_id AS "operatorId", created_by AS "createdBy"`,
|
|
760
|
+
[sha256(token2)]
|
|
761
|
+
);
|
|
762
|
+
if (!link) return { error: "INVALID_LINK" };
|
|
763
|
+
if (link.kind === "invite") {
|
|
764
|
+
const [op2] = await tx.query(
|
|
765
|
+
`INSERT INTO fonderie_admin_operators (email, name, password_hash, scopes, created_by)
|
|
766
|
+
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (email) DO NOTHING RETURNING ${OP_COLS}`,
|
|
767
|
+
[link.email, input.name ?? null, passwordHash, link.scopes, link.createdBy]
|
|
768
|
+
);
|
|
769
|
+
if (!op2)
|
|
770
|
+
throw Object.assign(new Error("already an operator"), { code: "ALREADY_OPERATOR" });
|
|
771
|
+
return { op: op2 };
|
|
772
|
+
}
|
|
773
|
+
const [op] = await tx.query(
|
|
774
|
+
`UPDATE fonderie_admin_operators
|
|
775
|
+
SET password_hash = $2, totp_secret = NULL, totp_confirmed_at = NULL, totp_last_step = NULL,
|
|
776
|
+
backup_codes = '{}', failed_attempts = 0, locked_until = NULL
|
|
777
|
+
WHERE id = $1 AND disabled_at IS NULL RETURNING ${OP_COLS}`,
|
|
778
|
+
[link.operatorId, passwordHash]
|
|
779
|
+
);
|
|
780
|
+
if (!op) return { error: "INVALID_LINK" };
|
|
781
|
+
await tx.query(`DELETE FROM fonderie_admin_sessions WHERE operator_id = $1`, [op.id]);
|
|
782
|
+
return { op };
|
|
783
|
+
}).catch((err) => {
|
|
784
|
+
if (err.code === "ALREADY_OPERATOR")
|
|
785
|
+
return { error: "ALREADY_OPERATOR" };
|
|
786
|
+
throw err;
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
async function createSession(store, ctx, operatorId, stage) {
|
|
790
|
+
const id = newOpaqueToken("fas");
|
|
791
|
+
const ttl = stage === "active" ? ABSOLUTE_MS : PENDING_MS;
|
|
792
|
+
await store.query(
|
|
793
|
+
`INSERT INTO fonderie_admin_sessions (id_hash, operator_id, stage, expires_at, client_ip, user_agent)
|
|
794
|
+
VALUES ($1, $2, $3, now() + make_interval(secs => $4), $5, $6)`,
|
|
795
|
+
[
|
|
796
|
+
sha256(id),
|
|
797
|
+
operatorId,
|
|
798
|
+
stage,
|
|
799
|
+
ttl / 1e3,
|
|
800
|
+
ctx.meta.clientIp ?? null,
|
|
801
|
+
ctx.request.headers.get("user-agent")?.slice(0, 300) ?? null
|
|
802
|
+
]
|
|
803
|
+
);
|
|
804
|
+
return id;
|
|
805
|
+
}
|
|
806
|
+
async function deleteSession(store, id) {
|
|
807
|
+
await store.query(`DELETE FROM fonderie_admin_sessions WHERE id_hash = $1`, [sha256(id)]);
|
|
808
|
+
}
|
|
809
|
+
async function deleteOperatorSessions(store, operatorId) {
|
|
810
|
+
await store.query(`DELETE FROM fonderie_admin_sessions WHERE operator_id = $1`, [operatorId]);
|
|
811
|
+
}
|
|
812
|
+
async function readSession(store, id) {
|
|
813
|
+
if (!id) return null;
|
|
814
|
+
const [session] = await store.query(
|
|
815
|
+
`SELECT ${SESSION_COLS} FROM fonderie_admin_sessions WHERE id_hash = $1 AND expires_at > now()`,
|
|
816
|
+
[sha256(id)]
|
|
817
|
+
);
|
|
818
|
+
if (!session) return null;
|
|
819
|
+
if (session.stage === "active" && Date.now() - new Date(session.lastSeenAt).getTime() > IDLE_MS) {
|
|
820
|
+
await store.query(`DELETE FROM fonderie_admin_sessions WHERE id_hash = $1`, [session.idHash]);
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
const op = await findOperator(store, { id: session.operatorId });
|
|
824
|
+
if (!op || op.disabledAt) return null;
|
|
825
|
+
if (Date.now() - new Date(session.lastSeenAt).getTime() > 6e4) {
|
|
826
|
+
void store.query(`UPDATE fonderie_admin_sessions SET last_seen_at = now() WHERE id_hash = $1`, [
|
|
827
|
+
session.idHash
|
|
828
|
+
]).catch(() => void 0);
|
|
829
|
+
}
|
|
830
|
+
return { session, op };
|
|
831
|
+
}
|
|
832
|
+
var stepUpFresh = (s) => s.stepUpAt !== null && Date.now() - new Date(s.stepUpAt).getTime() < STEP_UP_MS;
|
|
833
|
+
async function markStepUp(store, idHash) {
|
|
834
|
+
await store.query(`UPDATE fonderie_admin_sessions SET step_up_at = now() WHERE id_hash = $1`, [
|
|
835
|
+
idHash
|
|
836
|
+
]);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// src/operators/http.ts
|
|
840
|
+
var LOCAL = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
841
|
+
var isSecure = (ctx) => {
|
|
842
|
+
const u = new URL(ctx.request.url);
|
|
843
|
+
return !(u.protocol === "http:" && LOCAL.has(u.hostname));
|
|
844
|
+
};
|
|
845
|
+
var cookieName = (ctx) => isSecure(ctx) ? "__Host-fonderie_admin" : "fonderie_admin";
|
|
846
|
+
function readCookie(ctx) {
|
|
847
|
+
const header = ctx.request.headers.get("cookie") ?? "";
|
|
848
|
+
const want = cookieName(ctx);
|
|
849
|
+
for (const part of header.split(";")) {
|
|
850
|
+
const i = part.indexOf("=");
|
|
851
|
+
if (i > 0 && part.slice(0, i).trim() === want)
|
|
852
|
+
return decodeURIComponent(part.slice(i + 1).trim());
|
|
853
|
+
}
|
|
854
|
+
return "";
|
|
855
|
+
}
|
|
856
|
+
function withCookie(res, ctx, value, maxAgeMs) {
|
|
857
|
+
const attrs = [
|
|
858
|
+
`${cookieName(ctx)}=${value}`,
|
|
859
|
+
"Path=/",
|
|
860
|
+
"HttpOnly",
|
|
861
|
+
"SameSite=Strict",
|
|
862
|
+
`Max-Age=${Math.floor(maxAgeMs / 1e3)}`
|
|
863
|
+
];
|
|
864
|
+
if (isSecure(ctx)) attrs.push("Secure");
|
|
865
|
+
res.headers.append("set-cookie", attrs.join("; "));
|
|
866
|
+
res.headers.set("cache-control", "no-store");
|
|
867
|
+
return res;
|
|
868
|
+
}
|
|
869
|
+
var clearCookie = (res, ctx) => withCookie(res, ctx, "", 0);
|
|
870
|
+
function sameOrigin(ctx) {
|
|
871
|
+
const origin = ctx.request.headers.get("origin");
|
|
872
|
+
const host = ctx.request.headers.get("host") ?? new URL(ctx.request.url).host;
|
|
873
|
+
if (origin) {
|
|
874
|
+
try {
|
|
875
|
+
return new URL(origin).host === host;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const site = ctx.request.headers.get("sec-fetch-site");
|
|
881
|
+
return site === null || site === "same-origin";
|
|
882
|
+
}
|
|
883
|
+
var WINDOW_MS = 10 * 6e4;
|
|
884
|
+
var PER_IP = 30;
|
|
885
|
+
var attempts = /* @__PURE__ */ new Map();
|
|
886
|
+
function ipLimited(ctx) {
|
|
887
|
+
const ip = ctx.meta.clientIp ?? "unknown";
|
|
888
|
+
const now = Date.now();
|
|
889
|
+
const recent = (attempts.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
|
|
890
|
+
recent.push(now);
|
|
891
|
+
attempts.set(ip, recent);
|
|
892
|
+
if (attempts.size > 1e4) attempts.clear();
|
|
893
|
+
return recent.length > PER_IP;
|
|
894
|
+
}
|
|
895
|
+
var body = (ctx) => ctx.meta["body"] ?? {};
|
|
896
|
+
var str = (v) => typeof v === "string" ? v : "";
|
|
897
|
+
var bearer = (ctx) => {
|
|
898
|
+
const h = ctx.request.headers.get("authorization") ?? "";
|
|
899
|
+
return h.startsWith("Bearer ") ? h.slice(7) : "";
|
|
900
|
+
};
|
|
901
|
+
var BAD_CREDENTIALS = () => (0, import_core3.setApiResponse)(
|
|
902
|
+
import_core3.HTTP.UNAUTHORIZED,
|
|
903
|
+
"INVALID_CREDENTIALS",
|
|
904
|
+
"That email and password do not match an operator."
|
|
905
|
+
);
|
|
906
|
+
var BAD_CODE = () => (0, import_core3.setApiResponse)(
|
|
907
|
+
import_core3.HTTP.UNAUTHORIZED,
|
|
908
|
+
"INVALID_CODE",
|
|
909
|
+
"That code did not work. Codes change every 30 seconds."
|
|
910
|
+
);
|
|
911
|
+
var LOCKED = (op) => (0, import_core3.setApiResponse)(
|
|
912
|
+
import_core3.HTTP.TOO_MANY_REQUESTS,
|
|
913
|
+
"LOCKED",
|
|
914
|
+
`Too many attempts. Try again in ${lockMinutes(op)} minute(s).`
|
|
915
|
+
);
|
|
916
|
+
var TOO_FAST = () => (0, import_core3.setApiResponse)(
|
|
917
|
+
import_core3.HTTP.TOO_MANY_REQUESTS,
|
|
918
|
+
"RATE_LIMITED",
|
|
919
|
+
"Too many attempts from this address. Wait a few minutes."
|
|
920
|
+
);
|
|
921
|
+
var WRONG_STAGE = () => (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "NO_SESSION", "Start again from sign-in.");
|
|
922
|
+
var CROSS_ORIGIN = () => (0, import_core3.setApiResponse)(import_core3.HTTP.FORBIDDEN, "CROSS_ORIGIN", "This request did not come from the admin page.");
|
|
923
|
+
var stateOf = (stage) => stage === "active" ? "signed-in" : stage === "enroll" ? "needs-enrollment" : "needs-2fa";
|
|
924
|
+
async function startSession(deps, ctx, op, stage, payload = {}) {
|
|
925
|
+
ctx.meta["adminOperator"] = op.email;
|
|
926
|
+
const id = await createSession(deps.store, ctx, op.id, stage);
|
|
927
|
+
const res = (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "ADMIN_SESSION", "Session", {
|
|
928
|
+
state: stateOf(stage),
|
|
929
|
+
operator: publicOperator(op),
|
|
930
|
+
...payload
|
|
931
|
+
});
|
|
932
|
+
return withCookie(res, ctx, id, stage === "active" ? ABSOLUTE_MS : PENDING_MS);
|
|
933
|
+
}
|
|
934
|
+
async function sessionAt(deps, ctx, stage) {
|
|
935
|
+
const cookie = readCookie(ctx);
|
|
936
|
+
const found = await readSession(deps.store, cookie);
|
|
937
|
+
return found && found.session.stage === stage ? { ...found, cookie } : null;
|
|
938
|
+
}
|
|
939
|
+
function sessionRoutes(deps) {
|
|
940
|
+
const { store, box } = deps;
|
|
941
|
+
const check = async (ctx, next) => {
|
|
942
|
+
ctx.meta["adminActor"] = "anonymous";
|
|
943
|
+
if (ctx.request.method !== "GET" && !sameOrigin(ctx)) return CROSS_ORIGIN();
|
|
944
|
+
if (ctx.request.method !== "GET" && ipLimited(ctx)) return TOO_FAST();
|
|
945
|
+
return next();
|
|
946
|
+
};
|
|
947
|
+
return [
|
|
948
|
+
[
|
|
949
|
+
"GET",
|
|
950
|
+
"/_admin/session",
|
|
951
|
+
[
|
|
952
|
+
check,
|
|
953
|
+
async (ctx) => {
|
|
954
|
+
const found = await readSession(store, readCookie(ctx));
|
|
955
|
+
const claimable = await operatorCount(store) === 0;
|
|
956
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "ADMIN_SESSION", "Session", {
|
|
957
|
+
state: found ? stateOf(found.session.stage) : "signed-out",
|
|
958
|
+
operator: found ? publicOperator(found.op) : null,
|
|
959
|
+
claimable,
|
|
960
|
+
stepUpFresh: found ? stepUpFresh(found.session) : false
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
]
|
|
964
|
+
],
|
|
965
|
+
[
|
|
966
|
+
// The one-time claim. Needs the root token AND an empty operator table;
|
|
967
|
+
// after the first operator exists it is permanently refused.
|
|
968
|
+
"POST",
|
|
969
|
+
"/_admin/session/claim",
|
|
970
|
+
[
|
|
971
|
+
check,
|
|
972
|
+
(0, import_middlewares.validate)(claimSchema),
|
|
973
|
+
async (ctx) => {
|
|
974
|
+
if (!(0, import_core3.constantTimeEqual)(bearer(ctx), deps.rootToken)) {
|
|
975
|
+
return (0, import_core3.setApiResponse)(
|
|
976
|
+
import_core3.HTTP.UNAUTHORIZED,
|
|
977
|
+
"UNAUTHORIZED",
|
|
978
|
+
"Claiming needs the root admin token."
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
const b = body(ctx);
|
|
982
|
+
const email2 = normalizeEmail(str(b["email"]));
|
|
983
|
+
if (!EMAIL_RE.test(email2))
|
|
984
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID", "Enter a valid email address.");
|
|
985
|
+
const problem = passwordProblem(b["password"]);
|
|
986
|
+
if (problem) return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "WEAK_PASSWORD", problem);
|
|
987
|
+
const op = await claimFirstOperator(store, {
|
|
988
|
+
email: email2,
|
|
989
|
+
name: str(b["name"]) || void 0,
|
|
990
|
+
password: str(b["password"])
|
|
991
|
+
});
|
|
992
|
+
if (!op)
|
|
993
|
+
return (0, import_core3.setApiResponse)(
|
|
994
|
+
import_core3.HTTP.CONFLICT,
|
|
995
|
+
"ALREADY_CLAIMED",
|
|
996
|
+
"This console already has operators. Ask one for an invite."
|
|
997
|
+
);
|
|
998
|
+
return startSession(deps, ctx, op, "enroll");
|
|
999
|
+
}
|
|
1000
|
+
]
|
|
1001
|
+
],
|
|
1002
|
+
[
|
|
1003
|
+
"POST",
|
|
1004
|
+
"/_admin/session/login",
|
|
1005
|
+
[
|
|
1006
|
+
check,
|
|
1007
|
+
(0, import_middlewares.validate)(loginSchema),
|
|
1008
|
+
async (ctx) => {
|
|
1009
|
+
const b = body(ctx);
|
|
1010
|
+
const result = await checkPassword(store, str(b["email"]), str(b["password"]));
|
|
1011
|
+
if (result.op && "locked" in result && result.locked) return LOCKED(result.op);
|
|
1012
|
+
if (!result.ok || !result.op) return BAD_CREDENTIALS();
|
|
1013
|
+
return startSession(
|
|
1014
|
+
deps,
|
|
1015
|
+
ctx,
|
|
1016
|
+
result.op,
|
|
1017
|
+
result.op.totpConfirmedAt ? "password" : "enroll"
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
]
|
|
1021
|
+
],
|
|
1022
|
+
[
|
|
1023
|
+
"GET",
|
|
1024
|
+
"/_admin/session/enrollment",
|
|
1025
|
+
[
|
|
1026
|
+
check,
|
|
1027
|
+
async (ctx) => {
|
|
1028
|
+
const s = await sessionAt(deps, ctx, "enroll");
|
|
1029
|
+
if (!s) return WRONG_STAGE();
|
|
1030
|
+
const secret = await enrollmentSecret(store, box, s.op);
|
|
1031
|
+
const issuer = `Admin \xB7 ${new URL(ctx.request.url).hostname}`;
|
|
1032
|
+
const res = (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "ENROLLMENT", "Scan this with an authenticator app", {
|
|
1033
|
+
secret,
|
|
1034
|
+
uri: totpUri(issuer, s.op.email, secret),
|
|
1035
|
+
account: s.op.email,
|
|
1036
|
+
issuer
|
|
1037
|
+
});
|
|
1038
|
+
res.headers.set("cache-control", "no-store");
|
|
1039
|
+
return res;
|
|
1040
|
+
}
|
|
1041
|
+
]
|
|
1042
|
+
],
|
|
1043
|
+
[
|
|
1044
|
+
"POST",
|
|
1045
|
+
"/_admin/session/enrollment",
|
|
1046
|
+
[
|
|
1047
|
+
check,
|
|
1048
|
+
(0, import_middlewares.validate)(enrollmentSchema),
|
|
1049
|
+
async (ctx) => {
|
|
1050
|
+
const s = await sessionAt(deps, ctx, "enroll");
|
|
1051
|
+
if (!s) return WRONG_STAGE();
|
|
1052
|
+
const codes = await confirmEnrollment(store, box, s.op, str(body(ctx)["code"]));
|
|
1053
|
+
if (!codes) {
|
|
1054
|
+
const fresh = await findOperator(store, { id: s.op.id });
|
|
1055
|
+
return fresh && lockMinutes(fresh) ? LOCKED(fresh) : BAD_CODE();
|
|
1056
|
+
}
|
|
1057
|
+
await deleteSession(store, s.cookie);
|
|
1058
|
+
const op = await findOperator(store, { id: s.op.id }) ?? s.op;
|
|
1059
|
+
return startSession(deps, ctx, op, "active", { backupCodes: codes });
|
|
1060
|
+
}
|
|
1061
|
+
]
|
|
1062
|
+
],
|
|
1063
|
+
[
|
|
1064
|
+
"POST",
|
|
1065
|
+
"/_admin/session/verify",
|
|
1066
|
+
[
|
|
1067
|
+
check,
|
|
1068
|
+
(0, import_middlewares.validate)(factorSchema),
|
|
1069
|
+
async (ctx) => {
|
|
1070
|
+
const s = await sessionAt(deps, ctx, "password");
|
|
1071
|
+
if (!s) return WRONG_STAGE();
|
|
1072
|
+
const b = body(ctx);
|
|
1073
|
+
const r = await checkSecondFactor(store, box, s.op, {
|
|
1074
|
+
code: b["code"],
|
|
1075
|
+
backupCode: b["backupCode"]
|
|
1076
|
+
});
|
|
1077
|
+
if (!r.ok) {
|
|
1078
|
+
const fresh = await findOperator(store, { id: s.op.id });
|
|
1079
|
+
return fresh && lockMinutes(fresh) ? LOCKED(fresh) : BAD_CODE();
|
|
1080
|
+
}
|
|
1081
|
+
await deleteSession(store, s.cookie);
|
|
1082
|
+
await store.query(
|
|
1083
|
+
`UPDATE fonderie_admin_operators SET last_login_at = now() WHERE id = $1`,
|
|
1084
|
+
[s.op.id]
|
|
1085
|
+
);
|
|
1086
|
+
return startSession(
|
|
1087
|
+
deps,
|
|
1088
|
+
ctx,
|
|
1089
|
+
s.op,
|
|
1090
|
+
"active",
|
|
1091
|
+
r.via === "backup" ? { backupCodesLeft: r.backupLeft } : {}
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
]
|
|
1095
|
+
],
|
|
1096
|
+
[
|
|
1097
|
+
// A fresh code for the next five minutes of dangerous actions.
|
|
1098
|
+
"POST",
|
|
1099
|
+
"/_admin/session/step-up",
|
|
1100
|
+
[
|
|
1101
|
+
check,
|
|
1102
|
+
(0, import_middlewares.validate)(factorSchema),
|
|
1103
|
+
async (ctx) => {
|
|
1104
|
+
const s = await sessionAt(deps, ctx, "active");
|
|
1105
|
+
if (!s) return WRONG_STAGE();
|
|
1106
|
+
const b = body(ctx);
|
|
1107
|
+
const r = await checkSecondFactor(store, box, s.op, {
|
|
1108
|
+
code: b["code"],
|
|
1109
|
+
backupCode: b["backupCode"]
|
|
1110
|
+
});
|
|
1111
|
+
if (!r.ok) return BAD_CODE();
|
|
1112
|
+
await markStepUp(store, s.session.idHash);
|
|
1113
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "STEPPED_UP", "Confirmed for five minutes", {
|
|
1114
|
+
stepUpFresh: true,
|
|
1115
|
+
...r.via === "backup" ? { backupCodesLeft: r.backupLeft } : {}
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
]
|
|
1119
|
+
],
|
|
1120
|
+
[
|
|
1121
|
+
"DELETE",
|
|
1122
|
+
"/_admin/session",
|
|
1123
|
+
[
|
|
1124
|
+
check,
|
|
1125
|
+
async (ctx) => {
|
|
1126
|
+
const cookie = readCookie(ctx);
|
|
1127
|
+
if (cookie) await deleteSession(store, cookie);
|
|
1128
|
+
return clearCookie((0, import_core3.setApiResponse)(import_core3.HTTP.OK, "SIGNED_OUT", "Signed out"), ctx);
|
|
1129
|
+
}
|
|
1130
|
+
]
|
|
1131
|
+
],
|
|
1132
|
+
[
|
|
1133
|
+
// What a link is for, before the person sets a password. POST so the
|
|
1134
|
+
// token never sits in a URL a proxy logs.
|
|
1135
|
+
"POST",
|
|
1136
|
+
"/_admin/session/link/inspect",
|
|
1137
|
+
[
|
|
1138
|
+
check,
|
|
1139
|
+
(0, import_middlewares.validate)(inspectLinkSchema),
|
|
1140
|
+
async (ctx) => {
|
|
1141
|
+
const link = await findLink(store, str(body(ctx)["token"]));
|
|
1142
|
+
return link ? (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "LINK", "Link", { kind: link.kind, email: link.email }) : (0, import_core3.setApiResponse)(
|
|
1143
|
+
import_core3.HTTP.NOT_FOUND,
|
|
1144
|
+
"INVALID_LINK",
|
|
1145
|
+
"This link has expired or was already used. Ask for a new one."
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
]
|
|
1149
|
+
],
|
|
1150
|
+
[
|
|
1151
|
+
"POST",
|
|
1152
|
+
"/_admin/session/link",
|
|
1153
|
+
[
|
|
1154
|
+
check,
|
|
1155
|
+
(0, import_middlewares.validate)(redeemLinkSchema),
|
|
1156
|
+
async (ctx) => {
|
|
1157
|
+
const b = body(ctx);
|
|
1158
|
+
const problem = passwordProblem(b["password"]);
|
|
1159
|
+
if (problem) return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "WEAK_PASSWORD", problem);
|
|
1160
|
+
const r = await redeemLink(store, str(b["token"]), {
|
|
1161
|
+
password: str(b["password"]),
|
|
1162
|
+
name: str(b["name"]) || void 0
|
|
1163
|
+
});
|
|
1164
|
+
if ("error" in r) {
|
|
1165
|
+
return r.error === "ALREADY_OPERATOR" ? (0, import_core3.setApiResponse)(
|
|
1166
|
+
import_core3.HTTP.CONFLICT,
|
|
1167
|
+
"ALREADY_OPERATOR",
|
|
1168
|
+
"That email is already an operator. Sign in instead."
|
|
1169
|
+
) : (0, import_core3.setApiResponse)(
|
|
1170
|
+
import_core3.HTTP.NOT_FOUND,
|
|
1171
|
+
"INVALID_LINK",
|
|
1172
|
+
"This link has expired or was already used. Ask for a new one."
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
return startSession(deps, ctx, r.op, "enroll");
|
|
1176
|
+
}
|
|
1177
|
+
]
|
|
1178
|
+
]
|
|
1179
|
+
];
|
|
1180
|
+
}
|
|
1181
|
+
function needsStepUp(method, path, needed) {
|
|
1182
|
+
const m = method.toUpperCase();
|
|
1183
|
+
if (m === "GET" || m === "HEAD") return false;
|
|
1184
|
+
return needed === "root" || needed === "secrets" || m === "DELETE" || /\/migrations\/[^/]+\/apply$/.test(path);
|
|
1185
|
+
}
|
|
1186
|
+
async function authorizeOperator(store, ctx, needed, stepUp) {
|
|
1187
|
+
const found = await readSession(store, readCookie(ctx));
|
|
1188
|
+
if (!found || found.session.stage !== "active") return null;
|
|
1189
|
+
const method = ctx.request.method.toUpperCase();
|
|
1190
|
+
if (method !== "GET" && method !== "HEAD" && !sameOrigin(ctx)) return CROSS_ORIGIN();
|
|
1191
|
+
const scope = needed === "root" ? "secrets" : needed;
|
|
1192
|
+
if (!grants(found.op.scopes, scope)) {
|
|
1193
|
+
return (0, import_core3.setApiResponse)(
|
|
1194
|
+
import_core3.HTTP.FORBIDDEN,
|
|
1195
|
+
"FORBIDDEN",
|
|
1196
|
+
`Your operator account lacks the '${scope}' scope`
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
ctx.meta["adminOperator"] = found.op.email;
|
|
1200
|
+
if (stepUp && !stepUpFresh(found.session)) {
|
|
1201
|
+
return (0, import_core3.setApiResponse)(
|
|
1202
|
+
import_core3.HTTP.FORBIDDEN,
|
|
1203
|
+
"STEP_UP_REQUIRED",
|
|
1204
|
+
"Enter a code from your authenticator app to continue."
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
return "ok";
|
|
1208
|
+
}
|
|
1209
|
+
var actorOf = (ctx) => typeof ctx.meta["adminOperator"] === "string" ? `operator:${ctx.meta["adminOperator"]}` : "root-token";
|
|
1210
|
+
var validScopes = (v) => Array.isArray(v) && v.length > 0 && v.every((s) => SCOPES.includes(s)) ? [...new Set(v)] : null;
|
|
1211
|
+
function operatorAdminRoutes(deps) {
|
|
1212
|
+
const { store } = deps;
|
|
1213
|
+
const link = (ctx, token2) => `${deps.uiPath(ctx)}#/link/${token2}`;
|
|
1214
|
+
const idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
|
|
1215
|
+
return [
|
|
1216
|
+
[
|
|
1217
|
+
"GET",
|
|
1218
|
+
"/_admin/access/operators",
|
|
1219
|
+
[async () => (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "OPERATORS", "Operators", await listOperators(store))],
|
|
1220
|
+
"read"
|
|
1221
|
+
],
|
|
1222
|
+
[
|
|
1223
|
+
"POST",
|
|
1224
|
+
"/_admin/access/operators/invites",
|
|
1225
|
+
[
|
|
1226
|
+
(0, import_middlewares.validate)(inviteSchema),
|
|
1227
|
+
async (ctx) => {
|
|
1228
|
+
const b = body(ctx);
|
|
1229
|
+
const email2 = normalizeEmail(str(b["email"]));
|
|
1230
|
+
if (!EMAIL_RE.test(email2))
|
|
1231
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID", "Enter a valid email address.");
|
|
1232
|
+
const scopes = validScopes(b["scopes"]);
|
|
1233
|
+
if (!scopes)
|
|
1234
|
+
return (0, import_core3.setApiResponse)(
|
|
1235
|
+
import_core3.HTTP.UNPROCESSABLE,
|
|
1236
|
+
"INVALID",
|
|
1237
|
+
`scopes must be a non-empty list of ${SCOPES.join(", ")}`
|
|
1238
|
+
);
|
|
1239
|
+
if (await findOperator(store, { email: email2 }))
|
|
1240
|
+
return (0, import_core3.setApiResponse)(
|
|
1241
|
+
import_core3.HTTP.CONFLICT,
|
|
1242
|
+
"ALREADY_OPERATOR",
|
|
1243
|
+
"That email is already an operator."
|
|
1244
|
+
);
|
|
1245
|
+
const hours = Number.isInteger(b["expiresInHours"]) ? Math.min(Math.max(b["expiresInHours"], 1), 336) : INVITE_HOURS;
|
|
1246
|
+
const l = await createLink(store, {
|
|
1247
|
+
kind: "invite",
|
|
1248
|
+
email: email2,
|
|
1249
|
+
scopes,
|
|
1250
|
+
createdBy: actorOf(ctx),
|
|
1251
|
+
hours
|
|
1252
|
+
});
|
|
1253
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.CREATED, "INVITE_CREATED", "Invite link \u2014 shown once", {
|
|
1254
|
+
id: l.id,
|
|
1255
|
+
email: email2,
|
|
1256
|
+
scopes,
|
|
1257
|
+
expiresAt: l.expiresAt,
|
|
1258
|
+
token: l.token,
|
|
1259
|
+
url: link(ctx, l.token)
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
],
|
|
1263
|
+
"root"
|
|
1264
|
+
],
|
|
1265
|
+
[
|
|
1266
|
+
// Lost password or lost authenticator: a single-use link that sets a new
|
|
1267
|
+
// password and makes them enroll again. Signs them out everywhere.
|
|
1268
|
+
"POST",
|
|
1269
|
+
"/_admin/access/operators/:id/recovery",
|
|
1270
|
+
[
|
|
1271
|
+
async (ctx) => {
|
|
1272
|
+
const op = await findOperator(store, { id: idOf(ctx) });
|
|
1273
|
+
if (!op) return (0, import_core3.setApiResponse)(import_core3.HTTP.NOT_FOUND, "NOT_FOUND", "No such operator");
|
|
1274
|
+
await deleteOperatorSessions(store, op.id);
|
|
1275
|
+
const l = await createLink(store, {
|
|
1276
|
+
kind: "recovery",
|
|
1277
|
+
email: op.email,
|
|
1278
|
+
operatorId: op.id,
|
|
1279
|
+
createdBy: actorOf(ctx),
|
|
1280
|
+
hours: RECOVERY_HOURS
|
|
1281
|
+
});
|
|
1282
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.CREATED, "RECOVERY_CREATED", "Recovery link \u2014 shown once", {
|
|
1283
|
+
id: l.id,
|
|
1284
|
+
email: op.email,
|
|
1285
|
+
expiresAt: l.expiresAt,
|
|
1286
|
+
token: l.token,
|
|
1287
|
+
url: link(ctx, l.token)
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
],
|
|
1291
|
+
"root"
|
|
1292
|
+
],
|
|
1293
|
+
[
|
|
1294
|
+
"PUT",
|
|
1295
|
+
"/_admin/access/operators/:id",
|
|
1296
|
+
[
|
|
1297
|
+
(0, import_middlewares.validate)(updateOperatorSchema),
|
|
1298
|
+
async (ctx) => {
|
|
1299
|
+
const b = body(ctx);
|
|
1300
|
+
const op = await findOperator(store, { id: idOf(ctx) });
|
|
1301
|
+
if (!op) return (0, import_core3.setApiResponse)(import_core3.HTTP.NOT_FOUND, "NOT_FOUND", "No such operator");
|
|
1302
|
+
const scopes = b["scopes"] === void 0 ? op.scopes : validScopes(b["scopes"]);
|
|
1303
|
+
if (!scopes)
|
|
1304
|
+
return (0, import_core3.setApiResponse)(
|
|
1305
|
+
import_core3.HTTP.UNPROCESSABLE,
|
|
1306
|
+
"INVALID",
|
|
1307
|
+
`scopes must be a non-empty list of ${SCOPES.join(", ")}`
|
|
1308
|
+
);
|
|
1309
|
+
if (ctx.meta["adminOperator"] === op.email && !scopes.includes("secrets") && op.scopes.includes("secrets")) {
|
|
1310
|
+
return (0, import_core3.setApiResponse)(
|
|
1311
|
+
import_core3.HTTP.CONFLICT,
|
|
1312
|
+
"SELF_DEMOTION",
|
|
1313
|
+
"You cannot remove your own highest scope. Ask another operator."
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
const disabled = b["disabled"];
|
|
1317
|
+
if (disabled === true && ctx.meta["adminOperator"] === op.email) {
|
|
1318
|
+
return (0, import_core3.setApiResponse)(
|
|
1319
|
+
import_core3.HTTP.CONFLICT,
|
|
1320
|
+
"SELF_DISABLE",
|
|
1321
|
+
"You cannot disable yourself. Ask another operator."
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
const [row] = await store.query(
|
|
1325
|
+
`UPDATE fonderie_admin_operators
|
|
1326
|
+
SET scopes = $2, name = coalesce($3, name),
|
|
1327
|
+
disabled_at = CASE WHEN $4::boolean IS NULL THEN disabled_at WHEN $4 THEN coalesce(disabled_at, now()) ELSE NULL END
|
|
1328
|
+
WHERE id = $1
|
|
1329
|
+
RETURNING id, email, name, password_hash AS "passwordHash", scopes, totp_secret AS "totpSecret",
|
|
1330
|
+
totp_confirmed_at AS "totpConfirmedAt", totp_last_step AS "totpLastStep", backup_codes AS "backupCodes",
|
|
1331
|
+
failed_attempts AS "failedAttempts", locked_until AS "lockedUntil", created_by AS "createdBy",
|
|
1332
|
+
created_at AS "createdAt", last_login_at AS "lastLoginAt", disabled_at AS "disabledAt"`,
|
|
1333
|
+
[
|
|
1334
|
+
op.id,
|
|
1335
|
+
scopes,
|
|
1336
|
+
typeof b["name"] === "string" ? b["name"] : null,
|
|
1337
|
+
typeof disabled === "boolean" ? disabled : null
|
|
1338
|
+
]
|
|
1339
|
+
);
|
|
1340
|
+
if (!row) return (0, import_core3.setApiResponse)(import_core3.HTTP.NOT_FOUND, "NOT_FOUND", "No such operator");
|
|
1341
|
+
await deleteOperatorSessions(store, op.id);
|
|
1342
|
+
return (0, import_core3.setApiResponse)(
|
|
1343
|
+
import_core3.HTTP.OK,
|
|
1344
|
+
"OPERATOR_UPDATED",
|
|
1345
|
+
"Operator updated",
|
|
1346
|
+
publicOperator(row)
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
],
|
|
1350
|
+
"root"
|
|
1351
|
+
],
|
|
1352
|
+
[
|
|
1353
|
+
"DELETE",
|
|
1354
|
+
"/_admin/access/operators/links/:id",
|
|
1355
|
+
[
|
|
1356
|
+
async (ctx) => {
|
|
1357
|
+
const rows = await store.query(
|
|
1358
|
+
`UPDATE fonderie_admin_invites SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`,
|
|
1359
|
+
[idOf(ctx)]
|
|
1360
|
+
);
|
|
1361
|
+
return rows.length ? (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "LINK_REVOKED", "Link revoked") : (0, import_core3.setApiResponse)(import_core3.HTTP.NOT_FOUND, "NOT_FOUND", "No such live link");
|
|
1362
|
+
}
|
|
1363
|
+
],
|
|
1364
|
+
"root"
|
|
1365
|
+
]
|
|
1366
|
+
];
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// src/tokens.ts
|
|
362
1370
|
var SCOPES = ["read", "write", "secrets"];
|
|
363
1371
|
function scopeFor(method, path) {
|
|
364
1372
|
if (/\/secrets(\/|$)/.test(path)) return "secrets";
|
|
@@ -370,8 +1378,8 @@ function grants(held, needed) {
|
|
|
370
1378
|
if (held.includes("write")) return true;
|
|
371
1379
|
return needed === "read" && held.includes("read");
|
|
372
1380
|
}
|
|
373
|
-
var hashToken = (
|
|
374
|
-
var mintToken = () => `fad_${(0,
|
|
1381
|
+
var hashToken = (token2) => (0, import_node_crypto2.createHash)("sha256").update(token2).digest("hex");
|
|
1382
|
+
var mintToken = () => `fad_${(0, import_node_crypto2.randomBytes)(24).toString("base64url")}`;
|
|
375
1383
|
var COLUMNS = `id, name, scopes, created_by AS "createdBy", created_at AS "createdAt",
|
|
376
1384
|
expires_at AS "expiresAt", revoked_at AS "revokedAt", last_used_at AS "lastUsedAt"`;
|
|
377
1385
|
async function listTokens(store) {
|
|
@@ -380,15 +1388,15 @@ async function listTokens(store) {
|
|
|
380
1388
|
);
|
|
381
1389
|
}
|
|
382
1390
|
async function issueToken(store, input) {
|
|
383
|
-
const
|
|
1391
|
+
const token2 = mintToken();
|
|
384
1392
|
const expiresAt = input.expiresInDays ? new Date(Date.now() + input.expiresInDays * 864e5) : null;
|
|
385
1393
|
const [record] = await store.query(
|
|
386
1394
|
`INSERT INTO fonderie_admin_tokens (name, token_hash, scopes, created_by, expires_at)
|
|
387
1395
|
VALUES ($1, $2, $3, $4, $5) RETURNING ${COLUMNS}`,
|
|
388
|
-
[input.name, hashToken(
|
|
1396
|
+
[input.name, hashToken(token2), input.scopes, input.createdBy, expiresAt]
|
|
389
1397
|
);
|
|
390
1398
|
if (!record) throw new Error("[admin] token insert returned no row");
|
|
391
|
-
return { token, record };
|
|
1399
|
+
return { token: token2, record };
|
|
392
1400
|
}
|
|
393
1401
|
async function revokeToken(store, id) {
|
|
394
1402
|
const rows = await store.query(
|
|
@@ -397,29 +1405,34 @@ async function revokeToken(store, id) {
|
|
|
397
1405
|
);
|
|
398
1406
|
return rows.length > 0;
|
|
399
1407
|
}
|
|
400
|
-
async function findLive(store,
|
|
1408
|
+
async function findLive(store, token2) {
|
|
401
1409
|
const [row] = await store.query(
|
|
402
1410
|
`SELECT ${COLUMNS} FROM fonderie_admin_tokens
|
|
403
1411
|
WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`,
|
|
404
|
-
[hashToken(
|
|
1412
|
+
[hashToken(token2)]
|
|
405
1413
|
);
|
|
406
1414
|
return row ?? null;
|
|
407
1415
|
}
|
|
408
|
-
var
|
|
1416
|
+
var bearer2 = (ctx) => {
|
|
409
1417
|
const h = ctx.request.headers.get("authorization") ?? "";
|
|
410
1418
|
return h.startsWith("Bearer ") ? h.slice(7) : "";
|
|
411
1419
|
};
|
|
412
|
-
var UNAUTHORIZED = () => (0,
|
|
413
|
-
function requireAdminScope(bootstrap, store, needed) {
|
|
1420
|
+
var UNAUTHORIZED = () => (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token");
|
|
1421
|
+
function requireAdminScope(bootstrap, store, needed, opts = {}) {
|
|
414
1422
|
return async (ctx, next) => {
|
|
415
|
-
const
|
|
416
|
-
if (!
|
|
417
|
-
|
|
1423
|
+
const token2 = bearer2(ctx);
|
|
1424
|
+
if (!token2) {
|
|
1425
|
+
if (!store || !opts.operators) return UNAUTHORIZED();
|
|
1426
|
+
const verdict = await authorizeOperator(store, ctx, needed, opts.stepUp ?? false);
|
|
1427
|
+
if (verdict === null) return UNAUTHORIZED();
|
|
1428
|
+
return verdict === "ok" ? next() : verdict;
|
|
1429
|
+
}
|
|
1430
|
+
if ((0, import_core4.constantTimeEqual)(token2, bootstrap)) return next();
|
|
418
1431
|
if (needed === "root" || !store) return UNAUTHORIZED();
|
|
419
|
-
const record = await findLive(store,
|
|
1432
|
+
const record = await findLive(store, token2);
|
|
420
1433
|
if (!record) return UNAUTHORIZED();
|
|
421
1434
|
if (!grants(record.scopes, needed)) {
|
|
422
|
-
return (0,
|
|
1435
|
+
return (0, import_core4.setApiResponse)(import_core4.HTTP.FORBIDDEN, "FORBIDDEN", `This token lacks the '${needed}' scope`);
|
|
423
1436
|
}
|
|
424
1437
|
ctx.meta["adminTokenName"] = record.name;
|
|
425
1438
|
void store.query(`UPDATE fonderie_admin_tokens SET last_used_at = now() WHERE id = $1`, [record.id]).catch(() => void 0);
|
|
@@ -433,8 +1446,8 @@ var issueTokenSchema = {
|
|
|
433
1446
|
error: { issues: [{ path: [path], message }] }
|
|
434
1447
|
});
|
|
435
1448
|
const b = input ?? {};
|
|
436
|
-
const
|
|
437
|
-
if (!
|
|
1449
|
+
const name2 = typeof b["name"] === "string" ? b["name"].trim() : "";
|
|
1450
|
+
if (!name2 || name2.length > 100) return fail("name", "name is required (1\u2013100 chars)");
|
|
438
1451
|
const scopes = b["scopes"];
|
|
439
1452
|
if (!Array.isArray(scopes) || scopes.length === 0 || !scopes.every((s) => SCOPES.includes(s)))
|
|
440
1453
|
return fail("scopes", `scopes must be a non-empty list of ${SCOPES.join(", ")}`);
|
|
@@ -444,7 +1457,7 @@ var issueTokenSchema = {
|
|
|
444
1457
|
return {
|
|
445
1458
|
success: true,
|
|
446
1459
|
data: {
|
|
447
|
-
name,
|
|
1460
|
+
name: name2,
|
|
448
1461
|
scopes: [...new Set(scopes)],
|
|
449
1462
|
...days !== void 0 ? { expiresInDays: days } : {}
|
|
450
1463
|
}
|
|
@@ -593,7 +1606,7 @@ var uiHtml = (scriptPath) => `<!doctype html>
|
|
|
593
1606
|
// src/module.ts
|
|
594
1607
|
var DEFAULT_ADMIN_PATH = "/_admin";
|
|
595
1608
|
var DEFAULT_CHECK_TIMEOUT_MS = 1e4;
|
|
596
|
-
var ADMIN_VERSION = "1.1
|
|
1609
|
+
var ADMIN_VERSION = "1.2.1";
|
|
597
1610
|
function normalizeHostPattern(host) {
|
|
598
1611
|
return host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
|
|
599
1612
|
}
|
|
@@ -602,13 +1615,13 @@ function requireAdminHost(hosts) {
|
|
|
602
1615
|
return (ctx, next) => {
|
|
603
1616
|
const url = new URL(ctx.request.url);
|
|
604
1617
|
const ok = allowed.has(url.host.toLowerCase()) || allowed.has(url.hostname.toLowerCase());
|
|
605
|
-
return ok ? next() : Promise.resolve((0,
|
|
1618
|
+
return ok ? next() : Promise.resolve((0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "Not found"));
|
|
606
1619
|
};
|
|
607
1620
|
}
|
|
608
1621
|
var AdminModule = class {
|
|
609
1622
|
constructor(options = {}) {
|
|
610
1623
|
this.options = options;
|
|
611
|
-
this.path = (0,
|
|
1624
|
+
this.path = (0, import_core5.normalizeMountPath)(options.path ?? DEFAULT_ADMIN_PATH);
|
|
612
1625
|
this.hosts = options.host === void 0 ? null : [options.host].flat();
|
|
613
1626
|
}
|
|
614
1627
|
options;
|
|
@@ -618,8 +1631,8 @@ var AdminModule = class {
|
|
|
618
1631
|
// null = answer on any hostname.
|
|
619
1632
|
hosts;
|
|
620
1633
|
install(app) {
|
|
621
|
-
const
|
|
622
|
-
if (!
|
|
1634
|
+
const token2 = this.options.adminToken;
|
|
1635
|
+
if (!token2) return;
|
|
623
1636
|
app.reserve(this.path);
|
|
624
1637
|
const at = (p) => this.path + p.slice(DEFAULT_ADMIN_PATH.length);
|
|
625
1638
|
const store = this.options.store;
|
|
@@ -631,8 +1644,8 @@ var AdminModule = class {
|
|
|
631
1644
|
[
|
|
632
1645
|
"GET",
|
|
633
1646
|
"/_admin",
|
|
634
|
-
async () => (0,
|
|
635
|
-
|
|
1647
|
+
async () => (0, import_core5.setApiResponse)(
|
|
1648
|
+
import_core5.HTTP.OK,
|
|
636
1649
|
"ADMIN_ATTENTION",
|
|
637
1650
|
"What needs attention",
|
|
638
1651
|
attention(app, await doctor())
|
|
@@ -641,8 +1654,8 @@ var AdminModule = class {
|
|
|
641
1654
|
[
|
|
642
1655
|
"GET",
|
|
643
1656
|
"/_admin/manifest",
|
|
644
|
-
async () => (0,
|
|
645
|
-
|
|
1657
|
+
async () => (0, import_core5.setApiResponse)(
|
|
1658
|
+
import_core5.HTTP.OK,
|
|
646
1659
|
"ADMIN_MANIFEST",
|
|
647
1660
|
"Deployment manifest",
|
|
648
1661
|
buildManifest(app, { version: this.version, log: Boolean(store), host: this.hosts })
|
|
@@ -651,7 +1664,7 @@ var AdminModule = class {
|
|
|
651
1664
|
[
|
|
652
1665
|
"GET",
|
|
653
1666
|
"/_admin/doctor",
|
|
654
|
-
async () => (0,
|
|
1667
|
+
async () => (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "ADMIN_DOCTOR", "Reconciliation checks", await doctor())
|
|
655
1668
|
],
|
|
656
1669
|
[
|
|
657
1670
|
"GET",
|
|
@@ -664,8 +1677,8 @@ var AdminModule = class {
|
|
|
664
1677
|
// every deployment, built a client against admin's own report and
|
|
665
1678
|
// crashed the page with "a.map is not a function".
|
|
666
1679
|
"/_admin/environment",
|
|
667
|
-
async () => (0,
|
|
668
|
-
|
|
1680
|
+
async () => (0, import_core5.setApiResponse)(
|
|
1681
|
+
import_core5.HTTP.OK,
|
|
669
1682
|
"ADMIN_ENVIRONMENT",
|
|
670
1683
|
"Declared vs held",
|
|
671
1684
|
environmentReport(app, this.options.env ?? [])
|
|
@@ -674,13 +1687,13 @@ var AdminModule = class {
|
|
|
674
1687
|
[
|
|
675
1688
|
"GET",
|
|
676
1689
|
"/_admin/routes",
|
|
677
|
-
async () => (0,
|
|
1690
|
+
async () => (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "ADMIN_ROUTES", "Exposed routes", routesReport(app, this.name))
|
|
678
1691
|
],
|
|
679
1692
|
[
|
|
680
1693
|
"GET",
|
|
681
1694
|
"/_admin/access/tokens",
|
|
682
|
-
async () => (0,
|
|
683
|
-
|
|
1695
|
+
async () => (0, import_core5.setApiResponse)(
|
|
1696
|
+
import_core5.HTTP.OK,
|
|
684
1697
|
"ADMIN_TOKENS",
|
|
685
1698
|
"Admin tokens",
|
|
686
1699
|
tokensReport(app, this.name, store ? await listTokens(store) : null)
|
|
@@ -699,7 +1712,7 @@ var AdminModule = class {
|
|
|
699
1712
|
...limit ? { limit } : {},
|
|
700
1713
|
...before ? { before } : {}
|
|
701
1714
|
});
|
|
702
|
-
return (0,
|
|
1715
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "ADMIN_LOG", "Admin activity", page);
|
|
703
1716
|
}
|
|
704
1717
|
]);
|
|
705
1718
|
}
|
|
@@ -708,15 +1721,15 @@ var AdminModule = class {
|
|
|
708
1721
|
"POST",
|
|
709
1722
|
"/_admin/access/tokens",
|
|
710
1723
|
[
|
|
711
|
-
(0,
|
|
1724
|
+
(0, import_middlewares2.validate)(issueTokenSchema),
|
|
712
1725
|
async (ctx) => {
|
|
713
|
-
const
|
|
1726
|
+
const body2 = ctx.meta["body"];
|
|
714
1727
|
const createdBy = ctx.request.headers.get("x-actor") || "admin-token";
|
|
715
1728
|
const { token: plaintext, record } = await issueToken(store, {
|
|
716
|
-
...
|
|
1729
|
+
...body2,
|
|
717
1730
|
createdBy
|
|
718
1731
|
});
|
|
719
|
-
return (0,
|
|
1732
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.CREATED, "TOKEN_ISSUED", "Token issued \u2014 shown once", {
|
|
720
1733
|
token: plaintext,
|
|
721
1734
|
...record
|
|
722
1735
|
});
|
|
@@ -729,7 +1742,7 @@ var AdminModule = class {
|
|
|
729
1742
|
[
|
|
730
1743
|
async (ctx) => {
|
|
731
1744
|
const ok = await revokeToken(store, ctx.meta.params?.["id"] ?? "");
|
|
732
|
-
return ok ? (0,
|
|
1745
|
+
return ok ? (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "TOKEN_REVOKED", "Token revoked") : (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "No such live token");
|
|
733
1746
|
}
|
|
734
1747
|
]
|
|
735
1748
|
]
|
|
@@ -740,8 +1753,8 @@ var AdminModule = class {
|
|
|
740
1753
|
"GET",
|
|
741
1754
|
"/_admin/migrations",
|
|
742
1755
|
[
|
|
743
|
-
async () => (0,
|
|
744
|
-
|
|
1756
|
+
async () => (0, import_core5.setApiResponse)(
|
|
1757
|
+
import_core5.HTTP.OK,
|
|
745
1758
|
"MIGRATIONS",
|
|
746
1759
|
"Pending migrations by module",
|
|
747
1760
|
await migrationsReport(store, migrationSets)
|
|
@@ -752,43 +1765,43 @@ var AdminModule = class {
|
|
|
752
1765
|
"POST",
|
|
753
1766
|
"/_admin/migrations/:module/apply",
|
|
754
1767
|
[
|
|
755
|
-
(0,
|
|
1768
|
+
(0, import_middlewares2.validate)(applyMigrationsSchema),
|
|
756
1769
|
async (ctx) => {
|
|
757
1770
|
const { expect } = ctx.meta["body"];
|
|
758
|
-
const
|
|
759
|
-
const out = await applyModuleMigrations(store, migrationSets,
|
|
1771
|
+
const name2 = ctx.meta.params?.["module"] ?? "";
|
|
1772
|
+
const out = await applyModuleMigrations(store, migrationSets, name2, expect);
|
|
760
1773
|
if (out.ok) {
|
|
761
|
-
return (0,
|
|
762
|
-
|
|
1774
|
+
return (0, import_core5.setApiResponse)(
|
|
1775
|
+
import_core5.HTTP.OK,
|
|
763
1776
|
out.reason,
|
|
764
|
-
out.reason === "MIGRATIONS_APPLIED" ? `Applied. ${out.module.pending.length} still pending in ${
|
|
1777
|
+
out.reason === "MIGRATIONS_APPLIED" ? `Applied. ${out.module.pending.length} still pending in ${name2}.` : `${name2} is already up to date`,
|
|
765
1778
|
out.module
|
|
766
1779
|
);
|
|
767
1780
|
}
|
|
768
1781
|
switch (out.reason) {
|
|
769
1782
|
case "NOT_FOUND":
|
|
770
|
-
return (0,
|
|
771
|
-
|
|
1783
|
+
return (0, import_core5.setApiResponse)(
|
|
1784
|
+
import_core5.HTTP.NOT_FOUND,
|
|
772
1785
|
"NOT_FOUND",
|
|
773
|
-
`No migration set named "${
|
|
1786
|
+
`No migration set named "${name2}"`
|
|
774
1787
|
);
|
|
775
1788
|
case "MIGRATIONS_OUT_OF_ORDER":
|
|
776
|
-
return (0,
|
|
777
|
-
|
|
1789
|
+
return (0, import_core5.setApiResponse)(
|
|
1790
|
+
import_core5.HTTP.CONFLICT,
|
|
778
1791
|
out.reason,
|
|
779
|
-
`Apply "${out.blockedBy}" first \u2014 it runs before "${
|
|
1792
|
+
`Apply "${out.blockedBy}" first \u2014 it runs before "${name2}" and is behind.`,
|
|
780
1793
|
{ blockedBy: out.blockedBy }
|
|
781
1794
|
);
|
|
782
1795
|
case "MIGRATIONS_CHANGED":
|
|
783
|
-
return (0,
|
|
784
|
-
|
|
1796
|
+
return (0, import_core5.setApiResponse)(
|
|
1797
|
+
import_core5.HTTP.CONFLICT,
|
|
785
1798
|
out.reason,
|
|
786
1799
|
"What is pending changed since you looked. Refresh and read it again.",
|
|
787
1800
|
{ expected: out.expected, actual: out.actual }
|
|
788
1801
|
);
|
|
789
1802
|
default:
|
|
790
|
-
return (0,
|
|
791
|
-
|
|
1803
|
+
return (0, import_core5.setApiResponse)(
|
|
1804
|
+
import_core5.HTTP.UNPROCESSABLE,
|
|
792
1805
|
out.reason,
|
|
793
1806
|
"A pending migration deletes data. No down-migration brings it back \u2014 apply it through CI or `npm run migrate`, not from here.",
|
|
794
1807
|
{ files: out.files }
|
|
@@ -798,6 +1811,18 @@ var AdminModule = class {
|
|
|
798
1811
|
]
|
|
799
1812
|
]
|
|
800
1813
|
] : [];
|
|
1814
|
+
const operatorsOn = Boolean(store) && this.options.operators !== false;
|
|
1815
|
+
const operatorDeps = store && operatorsOn ? {
|
|
1816
|
+
store,
|
|
1817
|
+
box: secretBox(this.options.operatorKey),
|
|
1818
|
+
rootToken: token2,
|
|
1819
|
+
// From the request, so a basePath in front of the surface is kept.
|
|
1820
|
+
uiPath: (ctx) => {
|
|
1821
|
+
const p = new URL(ctx.request.url).pathname;
|
|
1822
|
+
const i = p.indexOf(this.path);
|
|
1823
|
+
return `${i >= 0 ? p.slice(0, i + this.path.length) : this.path}/ui`;
|
|
1824
|
+
}
|
|
1825
|
+
} : null;
|
|
801
1826
|
const mounted = /* @__PURE__ */ new Map();
|
|
802
1827
|
const mount = (module2, method, path, handlers, needed) => {
|
|
803
1828
|
const key = `${method.toUpperCase()} ${path}`;
|
|
@@ -808,7 +1833,11 @@ var AdminModule = class {
|
|
|
808
1833
|
);
|
|
809
1834
|
}
|
|
810
1835
|
mounted.set(key, module2);
|
|
811
|
-
const
|
|
1836
|
+
const need = needed ?? scopeFor(method, path);
|
|
1837
|
+
const guard = requireAdminScope(token2, store, need, {
|
|
1838
|
+
stepUp: needsStepUp(method, path, need),
|
|
1839
|
+
operators: operatorsOn
|
|
1840
|
+
});
|
|
812
1841
|
const chain = [
|
|
813
1842
|
// Logged first so a wrong-host attempt still leaves a row: the
|
|
814
1843
|
// caller learns nothing from a 404, the operator learns something.
|
|
@@ -837,7 +1866,7 @@ var AdminModule = class {
|
|
|
837
1866
|
"GET",
|
|
838
1867
|
at("/_admin/ui"),
|
|
839
1868
|
...ui(async (ctx) => {
|
|
840
|
-
const here = (0,
|
|
1869
|
+
const here = (0, import_core5.normalizeRequestPath)(new URL(ctx.request.url).pathname);
|
|
841
1870
|
return new Response(uiHtml(`${here}/app.js`), {
|
|
842
1871
|
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }
|
|
843
1872
|
});
|
|
@@ -847,20 +1876,35 @@ var AdminModule = class {
|
|
|
847
1876
|
"GET",
|
|
848
1877
|
scriptPath,
|
|
849
1878
|
...ui(async () => {
|
|
850
|
-
const
|
|
851
|
-
return
|
|
1879
|
+
const body2 = loadJs();
|
|
1880
|
+
return body2 ? new Response(body2, {
|
|
852
1881
|
headers: {
|
|
853
1882
|
"content-type": "text/javascript; charset=utf-8",
|
|
854
1883
|
"cache-control": "public, max-age=300"
|
|
855
1884
|
}
|
|
856
|
-
}) : (0,
|
|
857
|
-
|
|
1885
|
+
}) : (0, import_core5.setApiResponse)(
|
|
1886
|
+
import_core5.HTTP.SERVICE_UNAVAILABLE,
|
|
858
1887
|
"UI_NOT_BUILT",
|
|
859
1888
|
"This copy of @fonderie/admin has no built dashboard (dist/ui). Install the published package, or run its build."
|
|
860
1889
|
);
|
|
861
1890
|
})
|
|
862
1891
|
);
|
|
863
1892
|
}
|
|
1893
|
+
if (operatorDeps && store) {
|
|
1894
|
+
for (const [method, path, handlers] of sessionRoutes(operatorDeps)) {
|
|
1895
|
+
const full = at(path);
|
|
1896
|
+
mounted.set(`${method} ${full}`, this.name);
|
|
1897
|
+
app.addRoute(
|
|
1898
|
+
method,
|
|
1899
|
+
full,
|
|
1900
|
+
adminLog(store, full, this.name),
|
|
1901
|
+
...hostGuard ? [hostGuard] : [],
|
|
1902
|
+
...handlers
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
for (const [method, path, handlers, needed] of operatorAdminRoutes(operatorDeps))
|
|
1906
|
+
mount(this.name, method, at(path), handlers, needed);
|
|
1907
|
+
}
|
|
864
1908
|
for (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);
|
|
865
1909
|
for (const [method, path, handlers] of rootOnly)
|
|
866
1910
|
mount(this.name, method, at(path), handlers, "root");
|
|
@@ -871,7 +1915,16 @@ var AdminModule = class {
|
|
|
871
1915
|
}
|
|
872
1916
|
}
|
|
873
1917
|
checkReadiness() {
|
|
874
|
-
|
|
1918
|
+
const problems = (0, import_middlewares2.validateAdminToken)(this.options.adminToken, { module: this.name });
|
|
1919
|
+
const operatorsOn = Boolean(this.options.store) && this.options.operators !== false;
|
|
1920
|
+
if (operatorsOn && this.options.operatorKey && !/^[0-9a-fA-F]{64}$/.test(this.options.operatorKey)) {
|
|
1921
|
+
problems.push({
|
|
1922
|
+
module: this.name,
|
|
1923
|
+
severity: "error",
|
|
1924
|
+
message: "operatorKey must be 64 hex characters (`openssl rand -hex 32`)"
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
return problems;
|
|
875
1928
|
}
|
|
876
1929
|
};
|
|
877
1930
|
// Annotate the CommonJS export names for ESM import in node:
|