@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 +56 -0
- package/brain/outcomes.md +17 -1
- package/brain/signatures.md +32 -1
- package/dist/index.cjs +229 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -2
- package/dist/index.d.ts +32 -2
- package/dist/index.js +218 -20
- package/dist/index.js.map +1 -1
- package/dist/migrations/sql/002_admin_tokens.sql +14 -0
- package/dist/ui/app.global.js +66 -0
- package/package.json +13 -5
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())` |
|
package/brain/signatures.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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,
|
|
281
|
-
|
|
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,
|
|
291
|
-
|
|
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,
|
|
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,
|
|
306
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|