@fonderie/admin 0.5.0 → 0.7.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 CHANGED
@@ -111,6 +111,62 @@ fonderie admin log --limit 20 # who did what
111
111
 
112
112
  `FONDERIE_ADMIN_PREFIX` when the surface was moved off `/_admin`.
113
113
 
114
+ ## The served dashboard
115
+
116
+ ```ts
117
+ app.register(new AdminModule({ adminToken, store, ui: true }));
118
+ ```
119
+
120
+ `GET /_admin/ui` then serves the React shell — the same
121
+ `@fonderie/react-admin-screens` you would otherwise mount yourself, compiled
122
+ into one file (82 KB gzipped) and shipped in this package. No frontend build,
123
+ no extra dependency: React and the screens are devDependencies here, bundled
124
+ at publish time.
125
+
126
+ Off by default, because most deployments reach the surface through their own
127
+ frontend and would rather not serve the bundle.
128
+
129
+ - **The two static routes are unguarded, deliberately.** A browser navigating
130
+ to a page cannot send an `Authorization` header, and neither file carries
131
+ data — the HTML is an empty shell and the script is the same code npm
132
+ serves. The page asks for a token and sends it itself, so every request that
133
+ *reads* anything is guarded and logged as usual. The two asset fetches are
134
+ not logged: they carry no token to attribute.
135
+ - **The token lives in `sessionStorage`** — this tab only, never on the
136
+ server. Give it a `read` token (below) and the dashboard cannot reveal a
137
+ secret or change anything.
138
+ - **The page shows only what is mounted.** It reads the manifest first and
139
+ builds a client per brick that actually described routes, so a deployment
140
+ without billing has no Money pages rather than pages that 404.
141
+ - The script path is derived from the request, so the page works under a
142
+ `basePath`, under a moved `path`, and with or without a trailing slash.
143
+
144
+ ## Scoped tokens
145
+
146
+ The `adminToken` you configure is the **root**: every scope, and the only
147
+ credential that can issue or revoke tokens — a scoped token can never mint
148
+ one. With a store (and the package's migrations), issue tokens for daily
149
+ use and for the shell:
150
+
151
+ ```sh
152
+ fonderie admin token issue dashboard --scopes read --days 90 # root token in FONDERIE_ADMIN_TOKEN
153
+ fonderie admin token revoke <id>
154
+ ```
155
+
156
+ | Scope | Allows |
157
+ |---|---|
158
+ | `read` | every `GET` — except anything under `/secrets` |
159
+ | `write` | every mutation (plans, grants, templates, config, suspend, sign-out) — implies `read` |
160
+ | `secrets` | anything under `/secrets`, including the plaintext reveal — implies both |
161
+
162
+ The scope a route needs is derived from the route itself, never annotated.
163
+ Only the token's hash is stored; the plaintext is shown once. A valid token
164
+ short of the scope is `403`; an unknown, revoked or expired one is the same
165
+ `401` as a missing one. In the admin log the actor is `token:<name>` unless
166
+ `X-Actor` says otherwise. `GET /_admin/access/tokens` lists what was issued
167
+ (never a hash). Without a store, only the root token works and issuing is
168
+ off — the tokens page says `issued: null`.
169
+
114
170
  ## The admin log
115
171
 
116
172
  ```ts
package/brain/outcomes.md CHANGED
@@ -25,6 +25,20 @@ request_id TEXT
25
25
  client_ip TEXT
26
26
  ```
27
27
 
28
+ ### `fonderie_admin_tokens`
29
+
30
+ ```sql
31
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
32
+ name TEXT NOT NULL
33
+ token_hash TEXT NOT NULL UNIQUE
34
+ scopes TEXT[] NOT NULL
35
+ created_by TEXT NOT NULL
36
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
37
+ expires_at TIMESTAMPTZ
38
+ revoked_at TIMESTAMPTZ
39
+ last_used_at TIMESTAMPTZ
40
+ ```
41
+
28
42
  Raw SQL ships in `node_modules/@fonderie/admin/dist/migrations/sql/` — read it there if you must; never download tarballs.
29
43
 
30
44
  ## HTTP routes registered
@@ -32,7 +46,9 @@ Raw SQL ships in `node_modules/@fonderie/admin/dist/migrations/sql/` — read it
32
46
  | Method | Path | Middleware chain (auth / validation / handler) |
33
47
  |---|---|---|
34
48
  | GET | `/_admin` | `async () => setApiResponse( HTTP.OK, 'ADMIN_ATTENTION', 'What needs attention', attention(app, await doctor()), )` |
35
- | GET | `/_admin/access/tokens` | `async () => setApiResponse(HTTP.OK, 'ADMIN_TOKENS', 'Admin tokens', tokensReport(app, this.name))` |
49
+ | GET | `/_admin/access/tokens` | `async () => setApiResponse( HTTP.OK, 'ADMIN_TOKENS', 'Admin tokens', tokensReport(app, this.name, store ? await listTokens(store) : null), )` |
50
+ | POST | `/_admin/access/tokens` | `[ validate(issueTokenSchema), async (ctx) => { const body = ctx.meta['body'] as { name: string; scopes: AdminScope[]; expiresInDays?: number; }; const createdBy = ctx.request.headers.get('x-actor') || 'admin-token'; const { token: plaintext, record } = await issueToken(store, { ...body, createdBy, }); // The plaintext is returned once and never stored. return setApiResponse(HTTP.CREATED, 'TOKEN_ISSUED', 'Token issued — shown once', { token: plaintext, ...record, }); }, ]` |
51
+ | DELETE | `/_admin/access/tokens/:id` | `[ async (ctx) => { const ok = await revokeToken(store, ctx.meta.params?.['id'] ?? ''); return ok ? setApiResponse(HTTP.OK, 'TOKEN_REVOKED', 'Token revoked') : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such live token'); }, ]` |
36
52
  | GET | `/_admin/activity/admin-log` | `async (ctx) => { const q = new URL(ctx.request.url).searchParams; const limit = Number(q.get('limit')) || undefined; const before = q.get('before') ?? undefined; const page = await readAdminLog(store, { ...(limit ? { limit } : {}), ...(before ? { before } : {}), }); return setApiResponse(HTTP.OK, 'ADMIN_LOG', 'Admin activity', page); }` |
37
53
  | GET | `/_admin/config` | `async () => setApiResponse( HTTP.OK, 'ADMIN_CONFIG', 'Declared vs held', configReport(app, this.options.env ?? []), )` |
38
54
  | GET | `/_admin/doctor` | `async () => setApiResponse(HTTP.OK, 'ADMIN_DOCTOR', 'Reconciliation checks', await doctor())` |
@@ -40,13 +40,30 @@ function configReport(app: IFonderieApp, env: string[]): IAdminConfigReport
40
40
 
41
41
  function routesReport(app: IFonderieApp, adminModule: string): IAdminRoutesReport
42
42
 
43
- function tokensReport(app: IFonderieApp, adminModule: string): IAdminTokensReport
43
+ function tokensReport(app: IFonderieApp, adminModule: string, issued: IAdminTokenRecord[] | null): IAdminTokensReport
44
+
45
+ function requireAdminScope(bootstrap: string, store: IStoreAdapter | undefined, needed: AdminScope | "root"): Middleware
46
+
47
+ function scopeFor(method: string, path: string): AdminScope
48
+
49
+ function grants(held: readonly AdminScope[], needed: AdminScope): boolean
50
+
51
+ function issueToken(store: IStoreAdapter, input: { name: string; scopes: AdminScope[]; expiresInDays?: number; createdBy: string; }): Promise<{ token: string; record: IAdminTokenRecord; }>
52
+
53
+ function revokeToken(store: IStoreAdapter, id: string): Promise<boolean>
54
+
55
+ function listTokens(store: IStoreAdapter): Promise<IAdminTokenRecord[]>
56
+
57
+ function hashToken(token: string): string
58
+
59
+ const SCOPES: readonly AdminScope[]
44
60
 
45
61
  interface IAdminOptions {
46
62
  adminToken?: string;
47
63
  path?: string;
48
64
  checks?: IAdminCheck[];
49
65
  checkTimeoutMs?: number;
66
+ ui?: boolean;
50
67
  store?: IStoreAdapter;
51
68
  env?: string[];
52
69
  }
@@ -151,5 +168,19 @@ interface IAdminTokensReport {
151
168
  problems: IReadinessReport['problems'];
152
169
  };
153
170
  legacy: IAdminTokenEntry[];
171
+ issued: IAdminTokenRecord[] | null;
172
+ }
173
+
174
+ type AdminScope = 'read' | 'write' | 'secrets';
175
+
176
+ interface IAdminTokenRecord {
177
+ id: string;
178
+ name: string;
179
+ scopes: AdminScope[];
180
+ createdBy: string;
181
+ createdAt: string;
182
+ expiresAt: string | null;
183
+ revokedAt: string | null;
184
+ lastUsedAt: string | null;
154
185
  }
155
186
  ```
package/dist/index.cjs CHANGED
@@ -26,20 +26,33 @@ __export(index_exports, {
26
26
  DEFAULT_ADMIN_PATH: () => DEFAULT_ADMIN_PATH,
27
27
  DEFAULT_CHECK_TIMEOUT_MS: () => DEFAULT_CHECK_TIMEOUT_MS,
28
28
  MAX_PAGE: () => MAX_PAGE,
29
+ SCOPES: () => SCOPES,
29
30
  adminLog: () => adminLog,
30
31
  attention: () => attention,
31
32
  buildManifest: () => buildManifest,
32
33
  collectChecks: () => collectChecks,
33
34
  configReport: () => configReport,
35
+ grants: () => grants,
36
+ hashToken: () => hashToken,
37
+ issueToken: () => issueToken,
38
+ listTokens: () => listTokens,
34
39
  readAdminLog: () => readAdminLog,
40
+ requireAdminScope: () => requireAdminScope,
41
+ revokeToken: () => revokeToken,
35
42
  routesReport: () => routesReport,
36
43
  runDoctor: () => runDoctor,
44
+ scopeFor: () => scopeFor,
37
45
  tokensReport: () => tokensReport
38
46
  });
39
47
  module.exports = __toCommonJS(index_exports);
40
48
 
49
+ // ../../node_modules/tsup/assets/cjs_shims.js
50
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
51
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
52
+
41
53
  // src/module.ts
42
- var import_core = require("@fonderie/core");
54
+ var import_node_fs = require("fs");
55
+ var import_core3 = require("@fonderie/core");
43
56
  var import_middlewares = require("@fonderie/core/middlewares");
44
57
 
45
58
  // src/doctor.ts
@@ -107,15 +120,19 @@ function attention(app, doctor) {
107
120
  }
108
121
 
109
122
  // src/log.ts
123
+ var import_core = require("@fonderie/core");
110
124
  var DEFAULT_ACTOR = "admin-token";
111
125
  function adminLog(store, route, module2) {
112
126
  return async (ctx, next) => {
113
127
  const started = Date.now();
114
128
  const res = await next();
115
129
  const row = {
116
- actor: ctx.request.headers.get("x-actor") || DEFAULT_ACTOR,
130
+ // A scoped token names itself; X-Actor refines it; the root token is 'admin-token'.
131
+ actor: ctx.request.headers.get("x-actor") || (typeof ctx.meta["adminTokenName"] === "string" ? `token:${ctx.meta["adminTokenName"]}` : DEFAULT_ACTOR),
117
132
  method: ctx.request.method,
118
- path: new URL(ctx.request.url).pathname,
133
+ // Normalized, so '/x' and '/x/' are one row for one route — the same
134
+ // path the router matched and `route` below records the pattern of.
135
+ path: (0, import_core.normalizeRequestPath)(new URL(ctx.request.url).pathname),
119
136
  route,
120
137
  module: module2,
121
138
  status: res.status,
@@ -232,7 +249,7 @@ function routesReport(app, adminModule) {
232
249
  }))
233
250
  };
234
251
  }
235
- function tokensReport(app, adminModule) {
252
+ function tokensReport(app, adminModule, issued) {
236
253
  const report = app.securityReport();
237
254
  const adminProblems = report.readiness.problems.filter((p) => p.module === adminModule);
238
255
  const legacyOwners = new Set(
@@ -241,7 +258,8 @@ function tokensReport(app, adminModule) {
241
258
  return {
242
259
  generatedAt: report.generatedAt,
243
260
  admin: { ok: !adminProblems.some((p) => p.severity === "error"), problems: adminProblems },
244
- legacy: report.modules.map(({ name }) => ({ module: name, set: legacyOwners.has(name) })).filter((e) => e.set)
261
+ legacy: report.modules.map(({ name }) => ({ module: name, set: legacyOwners.has(name) })).filter((e) => e.set),
262
+ issued
245
263
  };
246
264
  }
247
265
  function isLegacyAdminPath(method, path) {
@@ -250,14 +268,127 @@ function isLegacyAdminPath(method, path) {
250
268
  return /\/billing\/wallet\/grant$/.test(path);
251
269
  }
252
270
 
271
+ // src/tokens.ts
272
+ var import_node_crypto = require("crypto");
273
+ var import_core2 = require("@fonderie/core");
274
+ var SCOPES = ["read", "write", "secrets"];
275
+ function scopeFor(method, path) {
276
+ if (/\/secrets(\/|$)/.test(path)) return "secrets";
277
+ return method.toUpperCase() === "GET" ? "read" : "write";
278
+ }
279
+ function grants(held, needed) {
280
+ if (held.includes("secrets")) return true;
281
+ if (needed === "secrets") return false;
282
+ if (held.includes("write")) return true;
283
+ return needed === "read" && held.includes("read");
284
+ }
285
+ var hashToken = (token) => (0, import_node_crypto.createHash)("sha256").update(token).digest("hex");
286
+ var mintToken = () => `fad_${(0, import_node_crypto.randomBytes)(24).toString("base64url")}`;
287
+ var COLUMNS = `id, name, scopes, created_by AS "createdBy", created_at AS "createdAt",
288
+ expires_at AS "expiresAt", revoked_at AS "revokedAt", last_used_at AS "lastUsedAt"`;
289
+ async function listTokens(store) {
290
+ return store.query(
291
+ `SELECT ${COLUMNS} FROM fonderie_admin_tokens ORDER BY created_at DESC`
292
+ );
293
+ }
294
+ async function issueToken(store, input) {
295
+ const token = mintToken();
296
+ const expiresAt = input.expiresInDays ? new Date(Date.now() + input.expiresInDays * 864e5) : null;
297
+ const [record] = await store.query(
298
+ `INSERT INTO fonderie_admin_tokens (name, token_hash, scopes, created_by, expires_at)
299
+ VALUES ($1, $2, $3, $4, $5) RETURNING ${COLUMNS}`,
300
+ [input.name, hashToken(token), input.scopes, input.createdBy, expiresAt]
301
+ );
302
+ if (!record) throw new Error("[admin] token insert returned no row");
303
+ return { token, record };
304
+ }
305
+ async function revokeToken(store, id) {
306
+ const rows = await store.query(
307
+ `UPDATE fonderie_admin_tokens SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL RETURNING id`,
308
+ [id]
309
+ );
310
+ return rows.length > 0;
311
+ }
312
+ async function findLive(store, token) {
313
+ const [row] = await store.query(
314
+ `SELECT ${COLUMNS} FROM fonderie_admin_tokens
315
+ WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`,
316
+ [hashToken(token)]
317
+ );
318
+ return row ?? null;
319
+ }
320
+ var bearer = (ctx) => {
321
+ const h = ctx.request.headers.get("authorization") ?? "";
322
+ return h.startsWith("Bearer ") ? h.slice(7) : "";
323
+ };
324
+ var UNAUTHORIZED = () => (0, import_core2.setApiResponse)(import_core2.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token");
325
+ function requireAdminScope(bootstrap, store, needed) {
326
+ return async (ctx, next) => {
327
+ const token = bearer(ctx);
328
+ if (!token) return UNAUTHORIZED();
329
+ if ((0, import_core2.constantTimeEqual)(token, bootstrap)) return next();
330
+ if (needed === "root" || !store) return UNAUTHORIZED();
331
+ const record = await findLive(store, token);
332
+ if (!record) return UNAUTHORIZED();
333
+ if (!grants(record.scopes, needed)) {
334
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.FORBIDDEN, "FORBIDDEN", `This token lacks the '${needed}' scope`);
335
+ }
336
+ ctx.meta["adminTokenName"] = record.name;
337
+ void store.query(`UPDATE fonderie_admin_tokens SET last_used_at = now() WHERE id = $1`, [record.id]).catch(() => void 0);
338
+ return next();
339
+ };
340
+ }
341
+ var issueTokenSchema = {
342
+ safeParse(input) {
343
+ const fail = (path, message) => ({
344
+ success: false,
345
+ error: { issues: [{ path: [path], message }] }
346
+ });
347
+ const b = input ?? {};
348
+ const name = typeof b["name"] === "string" ? b["name"].trim() : "";
349
+ if (!name || name.length > 100) return fail("name", "name is required (1\u2013100 chars)");
350
+ const scopes = b["scopes"];
351
+ if (!Array.isArray(scopes) || scopes.length === 0 || !scopes.every((s) => SCOPES.includes(s)))
352
+ return fail("scopes", `scopes must be a non-empty list of ${SCOPES.join(", ")}`);
353
+ const days = b["expiresInDays"];
354
+ if (days !== void 0 && (!Number.isInteger(days) || days < 1 || days > 3650))
355
+ return fail("expiresInDays", "expiresInDays must be an integer between 1 and 3650");
356
+ return {
357
+ success: true,
358
+ data: {
359
+ name,
360
+ scopes: [...new Set(scopes)],
361
+ ...days !== void 0 ? { expiresInDays: days } : {}
362
+ }
363
+ };
364
+ }
365
+ };
366
+
367
+ // src/ui/html.ts
368
+ var uiHtml = (scriptPath) => `<!doctype html>
369
+ <html lang="en">
370
+ <head>
371
+ <meta charset="utf-8">
372
+ <meta name="viewport" content="width=device-width, initial-scale=1">
373
+ <meta name="robots" content="noindex, nofollow">
374
+ <title>Admin</title>
375
+ <style>html,body{margin:0;background:#fff;color:#111}</style>
376
+ </head>
377
+ <body>
378
+ <div id="root"></div>
379
+ <script src="${scriptPath}" defer></script>
380
+ </body>
381
+ </html>
382
+ `;
383
+
253
384
  // src/module.ts
254
385
  var DEFAULT_ADMIN_PATH = "/_admin";
255
386
  var DEFAULT_CHECK_TIMEOUT_MS = 1e4;
256
- var ADMIN_VERSION = "0.5.0";
387
+ var ADMIN_VERSION = "0.7.0";
257
388
  var AdminModule = class {
258
389
  constructor(options = {}) {
259
390
  this.options = options;
260
- this.path = (options.path ?? DEFAULT_ADMIN_PATH).replace(/\/+$/, "");
391
+ this.path = (0, import_core3.normalizeMountPath)(options.path ?? DEFAULT_ADMIN_PATH);
261
392
  }
262
393
  options;
263
394
  name = "@fonderie/admin";
@@ -267,7 +398,6 @@ var AdminModule = class {
267
398
  const token = this.options.adminToken;
268
399
  if (!token) return;
269
400
  app.reserve(this.path);
270
- const guard = (0, import_middlewares.requireAdminToken)(token);
271
401
  const at = (p) => this.path + p.slice(DEFAULT_ADMIN_PATH.length);
272
402
  const store = this.options.store;
273
403
  const checks = collectChecks(app, this.options.checks ?? []);
@@ -277,8 +407,8 @@ var AdminModule = class {
277
407
  [
278
408
  "GET",
279
409
  "/_admin",
280
- async () => (0, import_core.setApiResponse)(
281
- import_core.HTTP.OK,
410
+ async () => (0, import_core3.setApiResponse)(
411
+ import_core3.HTTP.OK,
282
412
  "ADMIN_ATTENTION",
283
413
  "What needs attention",
284
414
  attention(app, await doctor())
@@ -287,8 +417,8 @@ var AdminModule = class {
287
417
  [
288
418
  "GET",
289
419
  "/_admin/manifest",
290
- async () => (0, import_core.setApiResponse)(
291
- import_core.HTTP.OK,
420
+ async () => (0, import_core3.setApiResponse)(
421
+ import_core3.HTTP.OK,
292
422
  "ADMIN_MANIFEST",
293
423
  "Deployment manifest",
294
424
  buildManifest(app, { version: this.version, log: Boolean(store) })
@@ -297,13 +427,13 @@ var AdminModule = class {
297
427
  [
298
428
  "GET",
299
429
  "/_admin/doctor",
300
- async () => (0, import_core.setApiResponse)(import_core.HTTP.OK, "ADMIN_DOCTOR", "Reconciliation checks", await doctor())
430
+ async () => (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "ADMIN_DOCTOR", "Reconciliation checks", await doctor())
301
431
  ],
302
432
  [
303
433
  "GET",
304
434
  "/_admin/config",
305
- async () => (0, import_core.setApiResponse)(
306
- import_core.HTTP.OK,
435
+ async () => (0, import_core3.setApiResponse)(
436
+ import_core3.HTTP.OK,
307
437
  "ADMIN_CONFIG",
308
438
  "Declared vs held",
309
439
  configReport(app, this.options.env ?? [])
@@ -312,12 +442,17 @@ var AdminModule = class {
312
442
  [
313
443
  "GET",
314
444
  "/_admin/routes",
315
- async () => (0, import_core.setApiResponse)(import_core.HTTP.OK, "ADMIN_ROUTES", "Exposed routes", routesReport(app, this.name))
445
+ async () => (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "ADMIN_ROUTES", "Exposed routes", routesReport(app, this.name))
316
446
  ],
317
447
  [
318
448
  "GET",
319
449
  "/_admin/access/tokens",
320
- async () => (0, import_core.setApiResponse)(import_core.HTTP.OK, "ADMIN_TOKENS", "Admin tokens", tokensReport(app, this.name))
450
+ async () => (0, import_core3.setApiResponse)(
451
+ import_core3.HTTP.OK,
452
+ "ADMIN_TOKENS",
453
+ "Admin tokens",
454
+ tokensReport(app, this.name, store ? await listTokens(store) : null)
455
+ )
321
456
  ]
322
457
  ];
323
458
  if (store) {
@@ -332,12 +467,43 @@ var AdminModule = class {
332
467
  ...limit ? { limit } : {},
333
468
  ...before ? { before } : {}
334
469
  });
335
- return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ADMIN_LOG", "Admin activity", page);
470
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "ADMIN_LOG", "Admin activity", page);
336
471
  }
337
472
  ]);
338
473
  }
474
+ const rootOnly = store ? [
475
+ [
476
+ "POST",
477
+ "/_admin/access/tokens",
478
+ [
479
+ (0, import_middlewares.validate)(issueTokenSchema),
480
+ async (ctx) => {
481
+ const body = ctx.meta["body"];
482
+ const createdBy = ctx.request.headers.get("x-actor") || "admin-token";
483
+ const { token: plaintext, record } = await issueToken(store, {
484
+ ...body,
485
+ createdBy
486
+ });
487
+ return (0, import_core3.setApiResponse)(import_core3.HTTP.CREATED, "TOKEN_ISSUED", "Token issued \u2014 shown once", {
488
+ token: plaintext,
489
+ ...record
490
+ });
491
+ }
492
+ ]
493
+ ],
494
+ [
495
+ "DELETE",
496
+ "/_admin/access/tokens/:id",
497
+ [
498
+ async (ctx) => {
499
+ const ok = await revokeToken(store, ctx.meta.params?.["id"] ?? "");
500
+ return ok ? (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "TOKEN_REVOKED", "Token revoked") : (0, import_core3.setApiResponse)(import_core3.HTTP.NOT_FOUND, "NOT_FOUND", "No such live token");
501
+ }
502
+ ]
503
+ ]
504
+ ] : [];
339
505
  const mounted = /* @__PURE__ */ new Map();
340
- const mount = (module2, method, path, handlers) => {
506
+ const mount = (module2, method, path, handlers, needed) => {
341
507
  const key = `${method.toUpperCase()} ${path}`;
342
508
  const prior = mounted.get(key);
343
509
  if (prior) {
@@ -346,10 +512,46 @@ var AdminModule = class {
346
512
  );
347
513
  }
348
514
  mounted.set(key, module2);
515
+ const guard = requireAdminScope(token, store, needed ?? scopeFor(method, path));
349
516
  const chain = store ? [adminLog(store, path, module2), guard, ...handlers] : [guard, ...handlers];
350
517
  app.addRoute(method, path, ...chain);
351
518
  };
519
+ if (this.options.ui) {
520
+ const scriptPath = at("/_admin/ui/app.js");
521
+ let js = null;
522
+ const loadJs = () => {
523
+ if (js === null) {
524
+ try {
525
+ js = (0, import_node_fs.readFileSync)(new URL("./ui/app.global.js", importMetaUrl), "utf8");
526
+ } catch {
527
+ js = "";
528
+ }
529
+ }
530
+ return js || null;
531
+ };
532
+ app.addRoute("GET", at("/_admin/ui"), async (ctx) => {
533
+ const here = (0, import_core3.normalizeRequestPath)(new URL(ctx.request.url).pathname);
534
+ return new Response(uiHtml(`${here}/app.js`), {
535
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }
536
+ });
537
+ });
538
+ app.addRoute("GET", scriptPath, async () => {
539
+ const body = loadJs();
540
+ return body ? new Response(body, {
541
+ headers: {
542
+ "content-type": "text/javascript; charset=utf-8",
543
+ "cache-control": "public, max-age=300"
544
+ }
545
+ }) : (0, import_core3.setApiResponse)(
546
+ import_core3.HTTP.SERVICE_UNAVAILABLE,
547
+ "UI_NOT_BUILT",
548
+ "This copy of @fonderie/admin has no built dashboard (dist/ui). Install the published package, or run its build."
549
+ );
550
+ });
551
+ }
352
552
  for (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);
553
+ for (const [method, path, handlers] of rootOnly)
554
+ mount(this.name, method, at(path), handlers, "root");
353
555
  for (const { module: module2, description } of app.adminDescriptions()) {
354
556
  for (const r of description.routes ?? [])
355
557
  mount(module2, r.method, this.path + r.path, r.handlers);
@@ -367,14 +569,22 @@ var AdminModule = class {
367
569
  DEFAULT_ADMIN_PATH,
368
570
  DEFAULT_CHECK_TIMEOUT_MS,
369
571
  MAX_PAGE,
572
+ SCOPES,
370
573
  adminLog,
371
574
  attention,
372
575
  buildManifest,
373
576
  collectChecks,
374
577
  configReport,
578
+ grants,
579
+ hashToken,
580
+ issueToken,
581
+ listTokens,
375
582
  readAdminLog,
583
+ requireAdminScope,
584
+ revokeToken,
376
585
  routesReport,
377
586
  runDoctor,
587
+ scopeFor,
378
588
  tokensReport
379
589
  });
380
590
  //# sourceMappingURL=index.cjs.map