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