@fonderie/admin 0.2.0 → 0.4.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
@@ -46,6 +46,68 @@ curl -H "Authorization: Bearer $ADMIN_TOKEN" https://api.example.com/_admin/mani
46
46
  problems are shown here in production — unlike `/readyz`, which is public —
47
47
  because the operator is the audience.
48
48
 
49
+ ## `GET /_admin/doctor` and `GET /_admin`
50
+
51
+ The five reconciliation checks from `docs/OPERATIONS.md` — and any the app
52
+ adds — with a permanent address. Each check reads the other side of a copy
53
+ (the provider, DNS, the database), so `/doctor` is on demand, not cached.
54
+
55
+ ```json
56
+ { "generatedAt": "…", "ok": false, "checks": [
57
+ { "name": "billing.subscription-drift", "module": "@fonderie/billing", "ok": false,
58
+ "findings": ["workspace:ws_1 (sub_x): status ours=active theirs=canceled [over-granting]"], "durationMs": 412 },
59
+ { "name": "billing.webhook-registration", "module": "@fonderie/billing", "ok": true,
60
+ "findings": [], "skipped": "config.publicUrl is not set", "durationMs": 0 },
61
+ { "name": "courier.sender-dns", "module": "@fonderie/courier", "ok": true,
62
+ "findings": ["DMARC p=none — monitoring only"], "durationMs": 88 }
63
+ ] }
64
+ ```
65
+
66
+ - `ok` is false only for a hard failure. Findings on a passing check are
67
+ advice and never turn a healthy deployment red.
68
+ - A check that throws becomes a finding; one that exceeds `checkTimeoutMs`
69
+ (default 10 s) is reported as timed out. The doctor itself never throws.
70
+ - `skipped` says why a check could not run — a provider that cannot be asked,
71
+ a `publicUrl` that is not set — instead of guessing.
72
+
73
+ `GET /_admin` is the attention page: readiness problems as reported, failed
74
+ checks as errors, advice as advice. `ok: true, items: []` is green.
75
+
76
+ Checks a module cannot own belong to the app:
77
+
78
+ ```ts
79
+ new AdminModule({
80
+ adminToken,
81
+ checks: [{
82
+ name: 'app.migrations',
83
+ run: async () => {
84
+ const pending = await runner.pending();
85
+ return { ok: pending.length === 0, findings: pending.map((f) => `pending: ${f}`) };
86
+ },
87
+ }],
88
+ });
89
+ ```
90
+
91
+ ## The admin log
92
+
93
+ ```ts
94
+ import { AdminModule } from '@fonderie/admin';
95
+ import { getMigrationsPath } from '@fonderie/admin/migrations';
96
+
97
+ app.register(new AdminModule({ adminToken, store })); // run the migrations first
98
+ ```
99
+
100
+ With a store, every request served by this surface is a row in
101
+ `fonderie_admin_log` — actor (the `X-Actor` header, else `admin-token`),
102
+ method, path, route, module, status, duration, request id, client IP —
103
+ **including the ones the token guard refused.** The log middleware runs
104
+ before the guard, and a failed write never fails the request it describes.
105
+ Without a store nothing is recorded and the manifest says so
106
+ (`admin.log: false`).
107
+
108
+ `GET /_admin/activity/admin-log?limit=50&before=<cursor>` reads it, newest
109
+ first; `next` is the cursor for the following page.
110
+
49
111
  ## Composed routes
50
112
 
51
113
  Bricks that implement `describeAdmin()` have their admin routes mounted here,
package/brain/outcomes.md CHANGED
@@ -7,8 +7,31 @@ rows it seeds, routes it registers. Generated from the migration SQL and
7
7
  route tables in source — trust this file instead of reading `dist/` or
8
8
  downloading tarballs.
9
9
 
10
+ ## Database tables (after all migrations)
11
+
12
+ ### `fonderie_admin_log`
13
+
14
+ ```sql
15
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
16
+ at TIMESTAMPTZ NOT NULL DEFAULT now()
17
+ actor TEXT NOT NULL
18
+ method TEXT NOT NULL
19
+ path TEXT NOT NULL
20
+ route TEXT NOT NULL
21
+ module TEXT NOT NULL
22
+ status INTEGER NOT NULL
23
+ duration_ms INTEGER NOT NULL
24
+ request_id TEXT
25
+ client_ip TEXT
26
+ ```
27
+
28
+ Raw SQL ships in `node_modules/@fonderie/admin/dist/migrations/sql/` — read it there if you must; never download tarballs.
29
+
10
30
  ## HTTP routes registered
11
31
 
12
32
  | Method | Path | Middleware chain (auth / validation / handler) |
13
33
  |---|---|---|
14
- | GET | `/_admin/manifest` | `async () => setApiResponse( HTTP.OK, 'ADMIN_MANIFEST', 'Deployment manifest', buildManifest(app, { version: this.version }), )` |
34
+ | GET | `/_admin` | `async () => setApiResponse( HTTP.OK, 'ADMIN_ATTENTION', 'What needs attention', attention(app, await doctor()), )` |
35
+ | 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); }` |
36
+ | GET | `/_admin/doctor` | `async () => setApiResponse(HTTP.OK, 'ADMIN_DOCTOR', 'Reconciliation checks', await doctor())` |
37
+ | GET | `/_admin/manifest` | `async () => setApiResponse( HTTP.OK, 'ADMIN_MANIFEST', 'Deployment manifest', buildManifest(app, { version: this.version, log: Boolean(store) }), )` |
@@ -4,6 +4,8 @@
4
4
 
5
5
  ## @fonderie/admin
6
6
 
7
+ Subpath exports: `@fonderie/admin/migrations`
8
+
7
9
  ```ts
8
10
  new AdminModule(options?: IAdminOptions): AdminModule
9
11
  .name: "@fonderie/admin"
@@ -16,11 +18,30 @@ const ADMIN_VERSION: string
16
18
 
17
19
  const DEFAULT_ADMIN_PATH: "/_admin"
18
20
 
19
- function buildManifest(app: IFonderieApp, admin: { version: string; }): IAdminManifest
21
+ const DEFAULT_CHECK_TIMEOUT_MS: 10000
22
+
23
+ function buildManifest(app: IFonderieApp, admin: { version: string; log: boolean; }): IAdminManifest
24
+
25
+ function runDoctor(checks: INamedCheck[], timeoutMs: number): Promise<IAdminDoctorReport>
26
+
27
+ function collectChecks(app: IFonderieApp, own: IAdminCheck[]): INamedCheck[]
28
+
29
+ function attention(app: IFonderieApp, doctor: IAdminDoctorReport): IAdminAttention
30
+
31
+ function adminLog(store: IStoreAdapter, route: string, module: string): Middleware
32
+
33
+ function readAdminLog(store: IStoreAdapter, opts?: { limit?: number; before?: string; }): Promise<IAdminLogPage>
34
+
35
+ const DEFAULT_ACTOR: "admin-token"
36
+
37
+ const MAX_PAGE: 200
20
38
 
21
39
  interface IAdminOptions {
22
40
  adminToken?: string;
23
41
  path?: string;
42
+ checks?: IAdminCheck[];
43
+ checkTimeoutMs?: number;
44
+ store?: IStoreAdapter;
24
45
  }
25
46
 
26
47
  interface IAdminManifest {
@@ -28,6 +49,7 @@ interface IAdminManifest {
28
49
  env: string;
29
50
  admin: {
30
51
  version: string;
52
+ log: boolean;
31
53
  };
32
54
  modules: IAdminModuleEntry[];
33
55
  readiness: IReadinessReport;
@@ -40,4 +62,47 @@ interface IAdminModuleEntry {
40
62
  readiness: IReadinessReport;
41
63
  describesAdmin: boolean;
42
64
  }
65
+
66
+ interface IAdminCheckResult extends IAdminCheckReport {
67
+ name: string;
68
+ module: string;
69
+ durationMs: number;
70
+ }
71
+
72
+ interface IAdminDoctorReport {
73
+ generatedAt: string;
74
+ ok: boolean;
75
+ checks: IAdminCheckResult[];
76
+ }
77
+
78
+ interface IAdminAttention {
79
+ generatedAt: string;
80
+ ok: boolean;
81
+ items: IAdminAttentionItem[];
82
+ }
83
+
84
+ interface IAdminAttentionItem {
85
+ source: string;
86
+ severity: 'error' | 'advice';
87
+ message: string;
88
+ }
89
+
90
+ interface IAdminLogEntry {
91
+ id: string;
92
+ at: string;
93
+ actor: string;
94
+ method: string;
95
+ path: string;
96
+ route: string;
97
+ module: string;
98
+ status: number;
99
+ durationMs: number;
100
+ requestId: string | null;
101
+ clientIp: string | null;
102
+ }
103
+
104
+ interface IAdminLogPage {
105
+ entries: IAdminLogEntry[];
106
+ next: string | null;
107
+ }
43
108
  ```
package/dist/index.cjs CHANGED
@@ -22,8 +22,16 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ADMIN_VERSION: () => ADMIN_VERSION,
24
24
  AdminModule: () => AdminModule,
25
+ DEFAULT_ACTOR: () => DEFAULT_ACTOR,
25
26
  DEFAULT_ADMIN_PATH: () => DEFAULT_ADMIN_PATH,
26
- buildManifest: () => buildManifest
27
+ DEFAULT_CHECK_TIMEOUT_MS: () => DEFAULT_CHECK_TIMEOUT_MS,
28
+ MAX_PAGE: () => MAX_PAGE,
29
+ adminLog: () => adminLog,
30
+ attention: () => attention,
31
+ buildManifest: () => buildManifest,
32
+ collectChecks: () => collectChecks,
33
+ readAdminLog: () => readAdminLog,
34
+ runDoctor: () => runDoctor
27
35
  });
28
36
  module.exports = __toCommonJS(index_exports);
29
37
 
@@ -31,6 +39,141 @@ module.exports = __toCommonJS(index_exports);
31
39
  var import_core = require("@fonderie/core");
32
40
  var import_middlewares = require("@fonderie/core/middlewares");
33
41
 
42
+ // src/doctor.ts
43
+ function collectChecks(app, own) {
44
+ const seen = /* @__PURE__ */ new Map();
45
+ const out = [];
46
+ const add = (module2, check) => {
47
+ const prior = seen.get(check.name);
48
+ if (prior)
49
+ throw new Error(
50
+ `[fonderie] ${module2} cannot offer check "${check.name}": ${prior} already does`
51
+ );
52
+ seen.set(check.name, module2);
53
+ out.push({ ...check, module: module2 });
54
+ };
55
+ for (const { module: module2, description } of app.adminDescriptions()) {
56
+ for (const c of description.checks ?? []) add(module2, c);
57
+ }
58
+ for (const c of own) add("application", c);
59
+ return out;
60
+ }
61
+ async function runOne(check, timeoutMs) {
62
+ const started = Date.now();
63
+ let timer;
64
+ const timeout = new Promise((resolve) => {
65
+ timer = setTimeout(
66
+ () => resolve({ ok: false, findings: [`timed out after ${timeoutMs} ms`] }),
67
+ timeoutMs
68
+ );
69
+ });
70
+ let report;
71
+ try {
72
+ report = await Promise.race([check.run(), timeout]);
73
+ } catch (err) {
74
+ report = {
75
+ ok: false,
76
+ findings: [`check threw: ${err instanceof Error ? err.message : String(err)}`]
77
+ };
78
+ } finally {
79
+ if (timer) clearTimeout(timer);
80
+ }
81
+ return { name: check.name, module: check.module, ...report, durationMs: Date.now() - started };
82
+ }
83
+ async function runDoctor(checks, timeoutMs) {
84
+ const results = await Promise.all(checks.map((c) => runOne(c, timeoutMs)));
85
+ return { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), ok: results.every((r) => r.ok), checks: results };
86
+ }
87
+ function attention(app, doctor) {
88
+ const items = [];
89
+ for (const p of app.checkProductionReadiness().problems) {
90
+ items.push({
91
+ source: p.module,
92
+ severity: p.severity === "error" ? "error" : "advice",
93
+ message: p.message
94
+ });
95
+ }
96
+ for (const c of doctor.checks) {
97
+ if (c.skipped) continue;
98
+ const severity = c.ok ? "advice" : "error";
99
+ if (!c.ok && c.findings.length === 0)
100
+ items.push({ source: c.name, severity, message: "failed" });
101
+ for (const f of c.findings) items.push({ source: c.name, severity, message: f });
102
+ }
103
+ return { generatedAt: doctor.generatedAt, ok: !items.some((i) => i.severity === "error"), items };
104
+ }
105
+
106
+ // src/log.ts
107
+ var DEFAULT_ACTOR = "admin-token";
108
+ function adminLog(store, route, module2) {
109
+ return async (ctx, next) => {
110
+ const started = Date.now();
111
+ const res = await next();
112
+ const row = {
113
+ actor: ctx.request.headers.get("x-actor") || DEFAULT_ACTOR,
114
+ method: ctx.request.method,
115
+ path: new URL(ctx.request.url).pathname,
116
+ route,
117
+ module: module2,
118
+ status: res.status,
119
+ durationMs: Date.now() - started,
120
+ requestId: typeof ctx.meta["requestId"] === "string" ? ctx.meta["requestId"] : null,
121
+ clientIp: ctx.meta.clientIp ?? null
122
+ };
123
+ try {
124
+ await store.query(
125
+ `INSERT INTO fonderie_admin_log
126
+ (actor, method, path, route, module, status, duration_ms, request_id, client_ip)
127
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
128
+ [
129
+ row.actor,
130
+ row.method,
131
+ row.path,
132
+ row.route,
133
+ row.module,
134
+ row.status,
135
+ row.durationMs,
136
+ row.requestId,
137
+ row.clientIp
138
+ ]
139
+ );
140
+ } catch (err) {
141
+ console.error(
142
+ "[admin] could not write the admin log:",
143
+ err instanceof Error ? err.message : err
144
+ );
145
+ }
146
+ return res;
147
+ };
148
+ }
149
+ var MAX_PAGE = 200;
150
+ async function readAdminLog(store, opts = {}) {
151
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), MAX_PAGE);
152
+ const cursor = opts.before ? decodeCursor(opts.before) : null;
153
+ const rows = await store.query(
154
+ `SELECT id, at, actor, method, path, route, module, status,
155
+ duration_ms AS "durationMs", request_id AS "requestId", client_ip AS "clientIp"
156
+ FROM fonderie_admin_log
157
+ WHERE ($1::timestamptz IS NULL OR (at, id) < ($1::timestamptz, $2::uuid))
158
+ ORDER BY at DESC, id DESC
159
+ LIMIT $3`,
160
+ [cursor?.at ?? null, cursor?.id ?? null, limit + 1]
161
+ );
162
+ const page = rows.slice(0, limit);
163
+ const last = page[page.length - 1];
164
+ return {
165
+ entries: page,
166
+ next: rows.length > limit && last ? encodeCursor(last) : null
167
+ };
168
+ }
169
+ function encodeCursor(e) {
170
+ return Buffer.from(`${new Date(e.at).toISOString()}|${e.id}`, "utf8").toString("base64url");
171
+ }
172
+ function decodeCursor(c) {
173
+ const [at, id] = Buffer.from(c, "base64url").toString("utf8").split("|");
174
+ return at && id ? { at, id } : null;
175
+ }
176
+
34
177
  // src/manifest.ts
35
178
  function buildManifest(app, admin) {
36
179
  const report = app.securityReport();
@@ -62,7 +205,8 @@ function buildManifest(app, admin) {
62
205
 
63
206
  // src/module.ts
64
207
  var DEFAULT_ADMIN_PATH = "/_admin";
65
- var ADMIN_VERSION = "0.2.0";
208
+ var DEFAULT_CHECK_TIMEOUT_MS = 1e4;
209
+ var ADMIN_VERSION = "0.4.0";
66
210
  var AdminModule = class {
67
211
  constructor(options = {}) {
68
212
  this.options = options;
@@ -78,7 +222,21 @@ var AdminModule = class {
78
222
  app.reserve(this.path);
79
223
  const guard = (0, import_middlewares.requireAdminToken)(token);
80
224
  const at = (p) => this.path + p.slice(DEFAULT_ADMIN_PATH.length);
225
+ const store = this.options.store;
226
+ const checks = collectChecks(app, this.options.checks ?? []);
227
+ const timeoutMs = this.options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS;
228
+ const doctor = () => runDoctor(checks, timeoutMs);
81
229
  const own = [
230
+ [
231
+ "GET",
232
+ "/_admin",
233
+ async () => (0, import_core.setApiResponse)(
234
+ import_core.HTTP.OK,
235
+ "ADMIN_ATTENTION",
236
+ "What needs attention",
237
+ attention(app, await doctor())
238
+ )
239
+ ],
82
240
  [
83
241
  "GET",
84
242
  "/_admin/manifest",
@@ -86,10 +244,31 @@ var AdminModule = class {
86
244
  import_core.HTTP.OK,
87
245
  "ADMIN_MANIFEST",
88
246
  "Deployment manifest",
89
- buildManifest(app, { version: this.version })
247
+ buildManifest(app, { version: this.version, log: Boolean(store) })
90
248
  )
249
+ ],
250
+ [
251
+ "GET",
252
+ "/_admin/doctor",
253
+ async () => (0, import_core.setApiResponse)(import_core.HTTP.OK, "ADMIN_DOCTOR", "Reconciliation checks", await doctor())
91
254
  ]
92
255
  ];
256
+ if (store) {
257
+ own.push([
258
+ "GET",
259
+ "/_admin/activity/admin-log",
260
+ async (ctx) => {
261
+ const q = new URL(ctx.request.url).searchParams;
262
+ const limit = Number(q.get("limit")) || void 0;
263
+ const before = q.get("before") ?? void 0;
264
+ const page = await readAdminLog(store, {
265
+ ...limit ? { limit } : {},
266
+ ...before ? { before } : {}
267
+ });
268
+ return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ADMIN_LOG", "Admin activity", page);
269
+ }
270
+ ]);
271
+ }
93
272
  const mounted = /* @__PURE__ */ new Map();
94
273
  const mount = (module2, method, path, handlers) => {
95
274
  const key = `${method.toUpperCase()} ${path}`;
@@ -100,7 +279,8 @@ var AdminModule = class {
100
279
  );
101
280
  }
102
281
  mounted.set(key, module2);
103
- app.addRoute(method, path, guard, ...handlers);
282
+ const chain = store ? [adminLog(store, path, module2), guard, ...handlers] : [guard, ...handlers];
283
+ app.addRoute(method, path, ...chain);
104
284
  };
105
285
  for (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);
106
286
  for (const { module: module2, description } of app.adminDescriptions()) {
@@ -116,7 +296,15 @@ var AdminModule = class {
116
296
  0 && (module.exports = {
117
297
  ADMIN_VERSION,
118
298
  AdminModule,
299
+ DEFAULT_ACTOR,
119
300
  DEFAULT_ADMIN_PATH,
120
- buildManifest
301
+ DEFAULT_CHECK_TIMEOUT_MS,
302
+ MAX_PAGE,
303
+ adminLog,
304
+ attention,
305
+ buildManifest,
306
+ collectChecks,
307
+ readAdminLog,
308
+ runDoctor
121
309
  });
122
310
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/module.ts","../src/manifest.ts"],"sourcesContent":["export { AdminModule, ADMIN_VERSION, DEFAULT_ADMIN_PATH } from './module';\nexport { buildManifest } from './manifest';\nexport type { IAdminOptions, IAdminManifest, IAdminModuleEntry } from './types';\n","import type { IFonderieApp, IFonderieModule, IReadinessProblem, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAdminToken, validateAdminToken } from '@fonderie/core/middlewares';\n\nimport { buildManifest } from './manifest';\nimport type { IAdminOptions } from './types';\n\nexport const DEFAULT_ADMIN_PATH = '/_admin';\n\n// Replaced at build time (tsup env); the fallback is what tests see.\nexport const ADMIN_VERSION: string = process.env['FONDERIE_ADMIN_VERSION'] ?? '0.0.0-dev';\n\nexport class AdminModule implements IFonderieModule {\n\treadonly name = '@fonderie/admin';\n\treadonly version = ADMIN_VERSION;\n\treadonly path: string;\n\n\tconstructor(private options: IAdminOptions = {}) {\n\t\tthis.path = (options.path ?? DEFAULT_ADMIN_PATH).replace(/\\/+$/, '');\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst token = this.options.adminToken;\n\t\tif (!token) return;\n\n\t\tapp.reserve(this.path);\n\t\tconst guard = requireAdminToken(token);\n\t\t// Declared at the default path so the route table reads literally; re-based when configured.\n\t\tconst at = (p: string): string => this.path + p.slice(DEFAULT_ADMIN_PATH.length);\n\n\t\tconst own: Array<[string, string, Middleware]> = [\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/manifest',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(\n\t\t\t\t\t\tHTTP.OK,\n\t\t\t\t\t\t'ADMIN_MANIFEST',\n\t\t\t\t\t\t'Deployment manifest',\n\t\t\t\t\t\tbuildManifest(app, { version: this.version }),\n\t\t\t\t\t),\n\t\t\t],\n\t\t];\n\t\tconst mounted = new Map<string, string>();\n\t\tconst mount = (module: string, method: string, path: string, handlers: Middleware[]): void => {\n\t\t\tconst key = `${method.toUpperCase()} ${path}`;\n\t\t\tconst prior = mounted.get(key);\n\t\t\tif (prior) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`[fonderie] ${module} cannot describe ${key}: ${prior} already describes it`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tmounted.set(key, module);\n\t\t\tapp.addRoute(method, path, guard, ...handlers);\n\t\t};\n\n\t\tfor (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);\n\t\tfor (const { module, description } of app.adminDescriptions()) {\n\t\t\tfor (const r of description.routes ?? [])\n\t\t\t\tmount(module, r.method, this.path + r.path, r.handlers);\n\t\t}\n\t}\n\n\tcheckReadiness(): IReadinessProblem[] {\n\t\treturn validateAdminToken(this.options.adminToken, { module: this.name });\n\t}\n}\n","import type { IFonderieApp, IReadinessProblem } from '@fonderie/core';\n\nimport type { IAdminManifest, IAdminModuleEntry } from './types';\n\nexport function buildManifest(app: IFonderieApp, admin: { version: string }): IAdminManifest {\n\tconst report = app.securityReport();\n\tconst byModule = new Map<string, IReadinessProblem[]>();\n\tfor (const p of report.readiness.problems) {\n\t\tconst list = byModule.get(p.module) ?? [];\n\t\tlist.push(p);\n\t\tbyModule.set(p.module, list);\n\t}\n\tconst describing = new Set(app.adminDescriptions().map((d) => d.module));\n\tconst modules: IAdminModuleEntry[] = report.modules.map(({ name, version }) => {\n\t\tconst problems = byModule.get(name) ?? [];\n\t\treturn {\n\t\t\tname,\n\t\t\tversion: version ?? null,\n\t\t\treadiness: { ok: !problems.some((p) => p.severity === 'error'), problems },\n\t\t\tdescribesAdmin: describing.has(name),\n\t\t};\n\t});\n\treturn {\n\t\tgeneratedAt: report.generatedAt,\n\t\tenv: report.env,\n\t\tadmin,\n\t\tmodules,\n\t\treadiness: report.readiness,\n\t\troutes: app.routes(),\n\t};\n}\n"],"mappings":"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","names":["module"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/module.ts","../src/doctor.ts","../src/log.ts","../src/manifest.ts"],"sourcesContent":["export { AdminModule, ADMIN_VERSION, DEFAULT_ADMIN_PATH, DEFAULT_CHECK_TIMEOUT_MS } from './module';\nexport { buildManifest } from './manifest';\nexport { runDoctor, collectChecks, attention } from './doctor';\nexport { adminLog, readAdminLog, DEFAULT_ACTOR, MAX_PAGE } from './log';\nexport type {\n\tIAdminOptions,\n\tIAdminManifest,\n\tIAdminModuleEntry,\n\tIAdminCheckResult,\n\tIAdminDoctorReport,\n\tIAdminAttention,\n\tIAdminAttentionItem,\n\tIAdminLogEntry,\n\tIAdminLogPage,\n} from './types';\n","import type { IFonderieApp, IFonderieModule, IReadinessProblem, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAdminToken, validateAdminToken } from '@fonderie/core/middlewares';\n\nimport { attention, collectChecks, runDoctor } from './doctor';\nimport { adminLog, readAdminLog } from './log';\nimport { buildManifest } from './manifest';\nimport type { IAdminOptions } from './types';\n\nexport const DEFAULT_ADMIN_PATH = '/_admin';\nexport const DEFAULT_CHECK_TIMEOUT_MS = 10_000;\n\n// Replaced at build time (tsup env); the fallback is what tests see.\nexport const ADMIN_VERSION: string = process.env['FONDERIE_ADMIN_VERSION'] ?? '0.0.0-dev';\n\nexport class AdminModule implements IFonderieModule {\n\treadonly name = '@fonderie/admin';\n\treadonly version = ADMIN_VERSION;\n\treadonly path: string;\n\n\tconstructor(private options: IAdminOptions = {}) {\n\t\tthis.path = (options.path ?? DEFAULT_ADMIN_PATH).replace(/\\/+$/, '');\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst token = this.options.adminToken;\n\t\tif (!token) return;\n\n\t\tapp.reserve(this.path);\n\t\tconst guard = requireAdminToken(token);\n\t\t// Declared at the default path so the route table reads literally; re-based when configured.\n\t\tconst at = (p: string): string => this.path + p.slice(DEFAULT_ADMIN_PATH.length);\n\n\t\tconst store = this.options.store;\n\t\t// Collected once at boot: a duplicate name is refused here, not at request time.\n\t\tconst checks = collectChecks(app, this.options.checks ?? []);\n\t\tconst timeoutMs = this.options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS;\n\t\tconst doctor = () => runDoctor(checks, timeoutMs);\n\n\t\tconst own: Array<[string, string, Middleware]> = [\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(\n\t\t\t\t\t\tHTTP.OK,\n\t\t\t\t\t\t'ADMIN_ATTENTION',\n\t\t\t\t\t\t'What needs attention',\n\t\t\t\t\t\tattention(app, await doctor()),\n\t\t\t\t\t),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/manifest',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(\n\t\t\t\t\t\tHTTP.OK,\n\t\t\t\t\t\t'ADMIN_MANIFEST',\n\t\t\t\t\t\t'Deployment manifest',\n\t\t\t\t\t\tbuildManifest(app, { version: this.version, log: Boolean(store) }),\n\t\t\t\t\t),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/doctor',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(HTTP.OK, 'ADMIN_DOCTOR', 'Reconciliation checks', await doctor()),\n\t\t\t],\n\t\t];\n\t\tif (store) {\n\t\t\town.push([\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/activity/admin-log',\n\t\t\t\tasync (ctx) => {\n\t\t\t\t\tconst q = new URL(ctx.request.url).searchParams;\n\t\t\t\t\tconst limit = Number(q.get('limit')) || undefined;\n\t\t\t\t\tconst before = q.get('before') ?? undefined;\n\t\t\t\t\tconst page = await readAdminLog(store, {\n\t\t\t\t\t\t...(limit ? { limit } : {}),\n\t\t\t\t\t\t...(before ? { before } : {}),\n\t\t\t\t\t});\n\t\t\t\t\treturn setApiResponse(HTTP.OK, 'ADMIN_LOG', 'Admin activity', page);\n\t\t\t\t},\n\t\t\t]);\n\t\t}\n\t\tconst mounted = new Map<string, string>();\n\t\tconst mount = (module: string, method: string, path: string, handlers: Middleware[]): void => {\n\t\t\tconst key = `${method.toUpperCase()} ${path}`;\n\t\t\tconst prior = mounted.get(key);\n\t\t\tif (prior) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`[fonderie] ${module} cannot describe ${key}: ${prior} already describes it`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tmounted.set(key, module);\n\t\t\t// The log sits before the guard: a refused request is a row too.\n\t\t\tconst chain = store\n\t\t\t\t? [adminLog(store, path, module), guard, ...handlers]\n\t\t\t\t: [guard, ...handlers];\n\t\t\tapp.addRoute(method, path, ...chain);\n\t\t};\n\n\t\tfor (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);\n\t\tfor (const { module, description } of app.adminDescriptions()) {\n\t\t\tfor (const r of description.routes ?? [])\n\t\t\t\tmount(module, r.method, this.path + r.path, r.handlers);\n\t\t}\n\t}\n\n\tcheckReadiness(): IReadinessProblem[] {\n\t\treturn validateAdminToken(this.options.adminToken, { module: this.name });\n\t}\n}\n","import type { IAdminCheck, IAdminCheckReport, IFonderieApp } from '@fonderie/core';\n\nimport type { IAdminAttention, IAdminCheckResult, IAdminDoctorReport } from './types';\n\nexport interface INamedCheck extends IAdminCheck {\n\tmodule: string;\n}\n\n// Module checks first (sorted by module), then the app's own. A name used\n// twice would make a report ambiguous, so it is refused up front.\nexport function collectChecks(app: IFonderieApp, own: IAdminCheck[]): INamedCheck[] {\n\tconst seen = new Map<string, string>();\n\tconst out: INamedCheck[] = [];\n\tconst add = (module: string, check: IAdminCheck): void => {\n\t\tconst prior = seen.get(check.name);\n\t\tif (prior)\n\t\t\tthrow new Error(\n\t\t\t\t`[fonderie] ${module} cannot offer check \"${check.name}\": ${prior} already does`,\n\t\t\t);\n\t\tseen.set(check.name, module);\n\t\tout.push({ ...check, module });\n\t};\n\tfor (const { module, description } of app.adminDescriptions()) {\n\t\tfor (const c of description.checks ?? []) add(module, c);\n\t}\n\tfor (const c of own) add('application', c);\n\treturn out;\n}\n\nasync function runOne(check: INamedCheck, timeoutMs: number): Promise<IAdminCheckResult> {\n\tconst started = Date.now();\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\tconst timeout = new Promise<IAdminCheckReport>((resolve) => {\n\t\ttimer = setTimeout(\n\t\t\t() => resolve({ ok: false, findings: [`timed out after ${timeoutMs} ms`] }),\n\t\t\ttimeoutMs,\n\t\t);\n\t});\n\tlet report: IAdminCheckReport;\n\ttry {\n\t\treport = await Promise.race([check.run(), timeout]);\n\t} catch (err) {\n\t\treport = {\n\t\t\tok: false,\n\t\t\tfindings: [`check threw: ${err instanceof Error ? err.message : String(err)}`],\n\t\t};\n\t} finally {\n\t\tif (timer) clearTimeout(timer);\n\t}\n\treturn { name: check.name, module: check.module, ...report, durationMs: Date.now() - started };\n}\n\nexport async function runDoctor(\n\tchecks: INamedCheck[],\n\ttimeoutMs: number,\n): Promise<IAdminDoctorReport> {\n\tconst results = await Promise.all(checks.map((c) => runOne(c, timeoutMs)));\n\treturn { generatedAt: new Date().toISOString(), ok: results.every((r) => r.ok), checks: results };\n}\n\n// What needs the operator today: readiness problems as reported, failed\n// checks as errors, findings on a passing check as advice. Empty = green.\nexport function attention(app: IFonderieApp, doctor: IAdminDoctorReport): IAdminAttention {\n\tconst items: IAdminAttention['items'] = [];\n\tfor (const p of app.checkProductionReadiness().problems) {\n\t\titems.push({\n\t\t\tsource: p.module,\n\t\t\tseverity: p.severity === 'error' ? 'error' : 'advice',\n\t\t\tmessage: p.message,\n\t\t});\n\t}\n\tfor (const c of doctor.checks) {\n\t\tif (c.skipped) continue;\n\t\tconst severity = c.ok ? 'advice' : 'error';\n\t\tif (!c.ok && c.findings.length === 0)\n\t\t\titems.push({ source: c.name, severity, message: 'failed' });\n\t\tfor (const f of c.findings) items.push({ source: c.name, severity, message: f });\n\t}\n\treturn { generatedAt: doctor.generatedAt, ok: !items.some((i) => i.severity === 'error'), items };\n}\n","import type { IFonderieContext, Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAdminLogEntry, IAdminLogPage } from './types';\n\nexport const DEFAULT_ACTOR = 'admin-token';\n\n// Sits BEFORE the token guard, so a refused request is a row too. A failed\n// write must not fail the request it describes.\nexport function adminLog(store: IStoreAdapter, route: string, module: string): Middleware {\n\treturn async (ctx: IFonderieContext, next) => {\n\t\tconst started = Date.now();\n\t\tconst res = await next();\n\t\tconst row = {\n\t\t\tactor: ctx.request.headers.get('x-actor') || DEFAULT_ACTOR,\n\t\t\tmethod: ctx.request.method,\n\t\t\tpath: new URL(ctx.request.url).pathname,\n\t\t\troute,\n\t\t\tmodule,\n\t\t\tstatus: res.status,\n\t\t\tdurationMs: Date.now() - started,\n\t\t\trequestId: typeof ctx.meta['requestId'] === 'string' ? ctx.meta['requestId'] : null,\n\t\t\tclientIp: ctx.meta.clientIp ?? null,\n\t\t};\n\t\ttry {\n\t\t\tawait store.query(\n\t\t\t\t`INSERT INTO fonderie_admin_log\n\t\t\t\t (actor, method, path, route, module, status, duration_ms, request_id, client_ip)\n\t\t\t\t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,\n\t\t\t\t[\n\t\t\t\t\trow.actor,\n\t\t\t\t\trow.method,\n\t\t\t\t\trow.path,\n\t\t\t\t\trow.route,\n\t\t\t\t\trow.module,\n\t\t\t\t\trow.status,\n\t\t\t\t\trow.durationMs,\n\t\t\t\t\trow.requestId,\n\t\t\t\t\trow.clientIp,\n\t\t\t\t],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tconsole.error(\n\t\t\t\t'[admin] could not write the admin log:',\n\t\t\t\terr instanceof Error ? err.message : err,\n\t\t\t);\n\t\t}\n\t\treturn res;\n\t};\n}\n\nexport const MAX_PAGE = 200;\n\n// Newest first, keyset on (at, id). `before` is the last entry's cursor.\nexport async function readAdminLog(\n\tstore: IStoreAdapter,\n\topts: { limit?: number; before?: string } = {},\n): Promise<IAdminLogPage> {\n\tconst limit = Math.min(Math.max(opts.limit ?? 50, 1), MAX_PAGE);\n\tconst cursor = opts.before ? decodeCursor(opts.before) : null;\n\tconst rows = await store.query<IAdminLogEntry>(\n\t\t`SELECT id, at, actor, method, path, route, module, status,\n\t\t duration_ms AS \"durationMs\", request_id AS \"requestId\", client_ip AS \"clientIp\"\n\t\t FROM fonderie_admin_log\n\t\t WHERE ($1::timestamptz IS NULL OR (at, id) < ($1::timestamptz, $2::uuid))\n\t\t ORDER BY at DESC, id DESC\n\t\t LIMIT $3`,\n\t\t[cursor?.at ?? null, cursor?.id ?? null, limit + 1],\n\t);\n\tconst page = rows.slice(0, limit);\n\tconst last = page[page.length - 1];\n\treturn {\n\t\tentries: page,\n\t\tnext: rows.length > limit && last ? encodeCursor(last) : null,\n\t};\n}\n\nfunction encodeCursor(e: IAdminLogEntry): string {\n\treturn Buffer.from(`${new Date(e.at).toISOString()}|${e.id}`, 'utf8').toString('base64url');\n}\n\nfunction decodeCursor(c: string): { at: string; id: string } | null {\n\tconst [at, id] = Buffer.from(c, 'base64url').toString('utf8').split('|');\n\treturn at && id ? { at, id } : null;\n}\n","import type { IFonderieApp, IReadinessProblem } from '@fonderie/core';\n\nimport type { IAdminManifest, IAdminModuleEntry } from './types';\n\nexport function buildManifest(\n\tapp: IFonderieApp,\n\tadmin: { version: string; log: boolean },\n): IAdminManifest {\n\tconst report = app.securityReport();\n\tconst byModule = new Map<string, IReadinessProblem[]>();\n\tfor (const p of report.readiness.problems) {\n\t\tconst list = byModule.get(p.module) ?? [];\n\t\tlist.push(p);\n\t\tbyModule.set(p.module, list);\n\t}\n\tconst describing = new Set(app.adminDescriptions().map((d) => d.module));\n\tconst modules: IAdminModuleEntry[] = report.modules.map(({ name, version }) => {\n\t\tconst problems = byModule.get(name) ?? [];\n\t\treturn {\n\t\t\tname,\n\t\t\tversion: version ?? null,\n\t\t\treadiness: { ok: !problems.some((p) => p.severity === 'error'), problems },\n\t\t\tdescribesAdmin: describing.has(name),\n\t\t};\n\t});\n\treturn {\n\t\tgeneratedAt: report.generatedAt,\n\t\tenv: report.env,\n\t\tadmin,\n\t\tmodules,\n\t\treadiness: report.readiness,\n\t\troutes: app.routes(),\n\t};\n}\n"],"mappings":"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","names":["module","module","module"]}
package/dist/index.d.cts CHANGED
@@ -1,8 +1,12 @@
1
- import { IReadinessReport, IRouteEntry, IFonderieModule, IFonderieApp, IReadinessProblem } from '@fonderie/core';
1
+ import { IAdminCheck, IAdminCheckReport, IReadinessReport, IRouteEntry, IFonderieModule, IFonderieApp, IReadinessProblem, Middleware } from '@fonderie/core';
2
+ import { IStoreAdapter } from '@fonderie/store';
2
3
 
3
4
  interface IAdminOptions {
4
5
  adminToken?: string;
5
6
  path?: string;
7
+ checks?: IAdminCheck[];
8
+ checkTimeoutMs?: number;
9
+ store?: IStoreAdapter;
6
10
  }
7
11
  interface IAdminModuleEntry {
8
12
  name: string;
@@ -15,13 +19,52 @@ interface IAdminManifest {
15
19
  env: string;
16
20
  admin: {
17
21
  version: string;
22
+ log: boolean;
18
23
  };
19
24
  modules: IAdminModuleEntry[];
20
25
  readiness: IReadinessReport;
21
26
  routes: IRouteEntry[];
22
27
  }
28
+ interface IAdminCheckResult extends IAdminCheckReport {
29
+ name: string;
30
+ module: string;
31
+ durationMs: number;
32
+ }
33
+ interface IAdminDoctorReport {
34
+ generatedAt: string;
35
+ ok: boolean;
36
+ checks: IAdminCheckResult[];
37
+ }
38
+ interface IAdminAttentionItem {
39
+ source: string;
40
+ severity: 'error' | 'advice';
41
+ message: string;
42
+ }
43
+ interface IAdminAttention {
44
+ generatedAt: string;
45
+ ok: boolean;
46
+ items: IAdminAttentionItem[];
47
+ }
48
+ interface IAdminLogEntry {
49
+ id: string;
50
+ at: string;
51
+ actor: string;
52
+ method: string;
53
+ path: string;
54
+ route: string;
55
+ module: string;
56
+ status: number;
57
+ durationMs: number;
58
+ requestId: string | null;
59
+ clientIp: string | null;
60
+ }
61
+ interface IAdminLogPage {
62
+ entries: IAdminLogEntry[];
63
+ next: string | null;
64
+ }
23
65
 
24
66
  declare const DEFAULT_ADMIN_PATH = "/_admin";
67
+ declare const DEFAULT_CHECK_TIMEOUT_MS = 10000;
25
68
  declare const ADMIN_VERSION: string;
26
69
  declare class AdminModule implements IFonderieModule {
27
70
  private options;
@@ -35,6 +78,22 @@ declare class AdminModule implements IFonderieModule {
35
78
 
36
79
  declare function buildManifest(app: IFonderieApp, admin: {
37
80
  version: string;
81
+ log: boolean;
38
82
  }): IAdminManifest;
39
83
 
40
- export { ADMIN_VERSION, AdminModule, DEFAULT_ADMIN_PATH, type IAdminManifest, type IAdminModuleEntry, type IAdminOptions, buildManifest };
84
+ interface INamedCheck extends IAdminCheck {
85
+ module: string;
86
+ }
87
+ declare function collectChecks(app: IFonderieApp, own: IAdminCheck[]): INamedCheck[];
88
+ declare function runDoctor(checks: INamedCheck[], timeoutMs: number): Promise<IAdminDoctorReport>;
89
+ declare function attention(app: IFonderieApp, doctor: IAdminDoctorReport): IAdminAttention;
90
+
91
+ declare const DEFAULT_ACTOR = "admin-token";
92
+ declare function adminLog(store: IStoreAdapter, route: string, module: string): Middleware;
93
+ declare const MAX_PAGE = 200;
94
+ declare function readAdminLog(store: IStoreAdapter, opts?: {
95
+ limit?: number;
96
+ before?: string;
97
+ }): Promise<IAdminLogPage>;
98
+
99
+ export { ADMIN_VERSION, AdminModule, DEFAULT_ACTOR, DEFAULT_ADMIN_PATH, DEFAULT_CHECK_TIMEOUT_MS, type IAdminAttention, type IAdminAttentionItem, type IAdminCheckResult, type IAdminDoctorReport, type IAdminLogEntry, type IAdminLogPage, type IAdminManifest, type IAdminModuleEntry, type IAdminOptions, MAX_PAGE, adminLog, attention, buildManifest, collectChecks, readAdminLog, runDoctor };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
- import { IReadinessReport, IRouteEntry, IFonderieModule, IFonderieApp, IReadinessProblem } from '@fonderie/core';
1
+ import { IAdminCheck, IAdminCheckReport, IReadinessReport, IRouteEntry, IFonderieModule, IFonderieApp, IReadinessProblem, Middleware } from '@fonderie/core';
2
+ import { IStoreAdapter } from '@fonderie/store';
2
3
 
3
4
  interface IAdminOptions {
4
5
  adminToken?: string;
5
6
  path?: string;
7
+ checks?: IAdminCheck[];
8
+ checkTimeoutMs?: number;
9
+ store?: IStoreAdapter;
6
10
  }
7
11
  interface IAdminModuleEntry {
8
12
  name: string;
@@ -15,13 +19,52 @@ interface IAdminManifest {
15
19
  env: string;
16
20
  admin: {
17
21
  version: string;
22
+ log: boolean;
18
23
  };
19
24
  modules: IAdminModuleEntry[];
20
25
  readiness: IReadinessReport;
21
26
  routes: IRouteEntry[];
22
27
  }
28
+ interface IAdminCheckResult extends IAdminCheckReport {
29
+ name: string;
30
+ module: string;
31
+ durationMs: number;
32
+ }
33
+ interface IAdminDoctorReport {
34
+ generatedAt: string;
35
+ ok: boolean;
36
+ checks: IAdminCheckResult[];
37
+ }
38
+ interface IAdminAttentionItem {
39
+ source: string;
40
+ severity: 'error' | 'advice';
41
+ message: string;
42
+ }
43
+ interface IAdminAttention {
44
+ generatedAt: string;
45
+ ok: boolean;
46
+ items: IAdminAttentionItem[];
47
+ }
48
+ interface IAdminLogEntry {
49
+ id: string;
50
+ at: string;
51
+ actor: string;
52
+ method: string;
53
+ path: string;
54
+ route: string;
55
+ module: string;
56
+ status: number;
57
+ durationMs: number;
58
+ requestId: string | null;
59
+ clientIp: string | null;
60
+ }
61
+ interface IAdminLogPage {
62
+ entries: IAdminLogEntry[];
63
+ next: string | null;
64
+ }
23
65
 
24
66
  declare const DEFAULT_ADMIN_PATH = "/_admin";
67
+ declare const DEFAULT_CHECK_TIMEOUT_MS = 10000;
25
68
  declare const ADMIN_VERSION: string;
26
69
  declare class AdminModule implements IFonderieModule {
27
70
  private options;
@@ -35,6 +78,22 @@ declare class AdminModule implements IFonderieModule {
35
78
 
36
79
  declare function buildManifest(app: IFonderieApp, admin: {
37
80
  version: string;
81
+ log: boolean;
38
82
  }): IAdminManifest;
39
83
 
40
- export { ADMIN_VERSION, AdminModule, DEFAULT_ADMIN_PATH, type IAdminManifest, type IAdminModuleEntry, type IAdminOptions, buildManifest };
84
+ interface INamedCheck extends IAdminCheck {
85
+ module: string;
86
+ }
87
+ declare function collectChecks(app: IFonderieApp, own: IAdminCheck[]): INamedCheck[];
88
+ declare function runDoctor(checks: INamedCheck[], timeoutMs: number): Promise<IAdminDoctorReport>;
89
+ declare function attention(app: IFonderieApp, doctor: IAdminDoctorReport): IAdminAttention;
90
+
91
+ declare const DEFAULT_ACTOR = "admin-token";
92
+ declare function adminLog(store: IStoreAdapter, route: string, module: string): Middleware;
93
+ declare const MAX_PAGE = 200;
94
+ declare function readAdminLog(store: IStoreAdapter, opts?: {
95
+ limit?: number;
96
+ before?: string;
97
+ }): Promise<IAdminLogPage>;
98
+
99
+ export { ADMIN_VERSION, AdminModule, DEFAULT_ACTOR, DEFAULT_ADMIN_PATH, DEFAULT_CHECK_TIMEOUT_MS, type IAdminAttention, type IAdminAttentionItem, type IAdminCheckResult, type IAdminDoctorReport, type IAdminLogEntry, type IAdminLogPage, type IAdminManifest, type IAdminModuleEntry, type IAdminOptions, MAX_PAGE, adminLog, attention, buildManifest, collectChecks, readAdminLog, runDoctor };
package/dist/index.js CHANGED
@@ -2,6 +2,141 @@
2
2
  import { HTTP, setApiResponse } from "@fonderie/core";
3
3
  import { requireAdminToken, validateAdminToken } from "@fonderie/core/middlewares";
4
4
 
5
+ // src/doctor.ts
6
+ function collectChecks(app, own) {
7
+ const seen = /* @__PURE__ */ new Map();
8
+ const out = [];
9
+ const add = (module, check) => {
10
+ const prior = seen.get(check.name);
11
+ if (prior)
12
+ throw new Error(
13
+ `[fonderie] ${module} cannot offer check "${check.name}": ${prior} already does`
14
+ );
15
+ seen.set(check.name, module);
16
+ out.push({ ...check, module });
17
+ };
18
+ for (const { module, description } of app.adminDescriptions()) {
19
+ for (const c of description.checks ?? []) add(module, c);
20
+ }
21
+ for (const c of own) add("application", c);
22
+ return out;
23
+ }
24
+ async function runOne(check, timeoutMs) {
25
+ const started = Date.now();
26
+ let timer;
27
+ const timeout = new Promise((resolve) => {
28
+ timer = setTimeout(
29
+ () => resolve({ ok: false, findings: [`timed out after ${timeoutMs} ms`] }),
30
+ timeoutMs
31
+ );
32
+ });
33
+ let report;
34
+ try {
35
+ report = await Promise.race([check.run(), timeout]);
36
+ } catch (err) {
37
+ report = {
38
+ ok: false,
39
+ findings: [`check threw: ${err instanceof Error ? err.message : String(err)}`]
40
+ };
41
+ } finally {
42
+ if (timer) clearTimeout(timer);
43
+ }
44
+ return { name: check.name, module: check.module, ...report, durationMs: Date.now() - started };
45
+ }
46
+ async function runDoctor(checks, timeoutMs) {
47
+ const results = await Promise.all(checks.map((c) => runOne(c, timeoutMs)));
48
+ return { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), ok: results.every((r) => r.ok), checks: results };
49
+ }
50
+ function attention(app, doctor) {
51
+ const items = [];
52
+ for (const p of app.checkProductionReadiness().problems) {
53
+ items.push({
54
+ source: p.module,
55
+ severity: p.severity === "error" ? "error" : "advice",
56
+ message: p.message
57
+ });
58
+ }
59
+ for (const c of doctor.checks) {
60
+ if (c.skipped) continue;
61
+ const severity = c.ok ? "advice" : "error";
62
+ if (!c.ok && c.findings.length === 0)
63
+ items.push({ source: c.name, severity, message: "failed" });
64
+ for (const f of c.findings) items.push({ source: c.name, severity, message: f });
65
+ }
66
+ return { generatedAt: doctor.generatedAt, ok: !items.some((i) => i.severity === "error"), items };
67
+ }
68
+
69
+ // src/log.ts
70
+ var DEFAULT_ACTOR = "admin-token";
71
+ function adminLog(store, route, module) {
72
+ return async (ctx, next) => {
73
+ const started = Date.now();
74
+ const res = await next();
75
+ const row = {
76
+ actor: ctx.request.headers.get("x-actor") || DEFAULT_ACTOR,
77
+ method: ctx.request.method,
78
+ path: new URL(ctx.request.url).pathname,
79
+ route,
80
+ module,
81
+ status: res.status,
82
+ durationMs: Date.now() - started,
83
+ requestId: typeof ctx.meta["requestId"] === "string" ? ctx.meta["requestId"] : null,
84
+ clientIp: ctx.meta.clientIp ?? null
85
+ };
86
+ try {
87
+ await store.query(
88
+ `INSERT INTO fonderie_admin_log
89
+ (actor, method, path, route, module, status, duration_ms, request_id, client_ip)
90
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
91
+ [
92
+ row.actor,
93
+ row.method,
94
+ row.path,
95
+ row.route,
96
+ row.module,
97
+ row.status,
98
+ row.durationMs,
99
+ row.requestId,
100
+ row.clientIp
101
+ ]
102
+ );
103
+ } catch (err) {
104
+ console.error(
105
+ "[admin] could not write the admin log:",
106
+ err instanceof Error ? err.message : err
107
+ );
108
+ }
109
+ return res;
110
+ };
111
+ }
112
+ var MAX_PAGE = 200;
113
+ async function readAdminLog(store, opts = {}) {
114
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), MAX_PAGE);
115
+ const cursor = opts.before ? decodeCursor(opts.before) : null;
116
+ const rows = await store.query(
117
+ `SELECT id, at, actor, method, path, route, module, status,
118
+ duration_ms AS "durationMs", request_id AS "requestId", client_ip AS "clientIp"
119
+ FROM fonderie_admin_log
120
+ WHERE ($1::timestamptz IS NULL OR (at, id) < ($1::timestamptz, $2::uuid))
121
+ ORDER BY at DESC, id DESC
122
+ LIMIT $3`,
123
+ [cursor?.at ?? null, cursor?.id ?? null, limit + 1]
124
+ );
125
+ const page = rows.slice(0, limit);
126
+ const last = page[page.length - 1];
127
+ return {
128
+ entries: page,
129
+ next: rows.length > limit && last ? encodeCursor(last) : null
130
+ };
131
+ }
132
+ function encodeCursor(e) {
133
+ return Buffer.from(`${new Date(e.at).toISOString()}|${e.id}`, "utf8").toString("base64url");
134
+ }
135
+ function decodeCursor(c) {
136
+ const [at, id] = Buffer.from(c, "base64url").toString("utf8").split("|");
137
+ return at && id ? { at, id } : null;
138
+ }
139
+
5
140
  // src/manifest.ts
6
141
  function buildManifest(app, admin) {
7
142
  const report = app.securityReport();
@@ -33,7 +168,8 @@ function buildManifest(app, admin) {
33
168
 
34
169
  // src/module.ts
35
170
  var DEFAULT_ADMIN_PATH = "/_admin";
36
- var ADMIN_VERSION = "0.2.0";
171
+ var DEFAULT_CHECK_TIMEOUT_MS = 1e4;
172
+ var ADMIN_VERSION = "0.4.0";
37
173
  var AdminModule = class {
38
174
  constructor(options = {}) {
39
175
  this.options = options;
@@ -49,7 +185,21 @@ var AdminModule = class {
49
185
  app.reserve(this.path);
50
186
  const guard = requireAdminToken(token);
51
187
  const at = (p) => this.path + p.slice(DEFAULT_ADMIN_PATH.length);
188
+ const store = this.options.store;
189
+ const checks = collectChecks(app, this.options.checks ?? []);
190
+ const timeoutMs = this.options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS;
191
+ const doctor = () => runDoctor(checks, timeoutMs);
52
192
  const own = [
193
+ [
194
+ "GET",
195
+ "/_admin",
196
+ async () => setApiResponse(
197
+ HTTP.OK,
198
+ "ADMIN_ATTENTION",
199
+ "What needs attention",
200
+ attention(app, await doctor())
201
+ )
202
+ ],
53
203
  [
54
204
  "GET",
55
205
  "/_admin/manifest",
@@ -57,10 +207,31 @@ var AdminModule = class {
57
207
  HTTP.OK,
58
208
  "ADMIN_MANIFEST",
59
209
  "Deployment manifest",
60
- buildManifest(app, { version: this.version })
210
+ buildManifest(app, { version: this.version, log: Boolean(store) })
61
211
  )
212
+ ],
213
+ [
214
+ "GET",
215
+ "/_admin/doctor",
216
+ async () => setApiResponse(HTTP.OK, "ADMIN_DOCTOR", "Reconciliation checks", await doctor())
62
217
  ]
63
218
  ];
219
+ if (store) {
220
+ own.push([
221
+ "GET",
222
+ "/_admin/activity/admin-log",
223
+ async (ctx) => {
224
+ const q = new URL(ctx.request.url).searchParams;
225
+ const limit = Number(q.get("limit")) || void 0;
226
+ const before = q.get("before") ?? void 0;
227
+ const page = await readAdminLog(store, {
228
+ ...limit ? { limit } : {},
229
+ ...before ? { before } : {}
230
+ });
231
+ return setApiResponse(HTTP.OK, "ADMIN_LOG", "Admin activity", page);
232
+ }
233
+ ]);
234
+ }
64
235
  const mounted = /* @__PURE__ */ new Map();
65
236
  const mount = (module, method, path, handlers) => {
66
237
  const key = `${method.toUpperCase()} ${path}`;
@@ -71,7 +242,8 @@ var AdminModule = class {
71
242
  );
72
243
  }
73
244
  mounted.set(key, module);
74
- app.addRoute(method, path, guard, ...handlers);
245
+ const chain = store ? [adminLog(store, path, module), guard, ...handlers] : [guard, ...handlers];
246
+ app.addRoute(method, path, ...chain);
75
247
  };
76
248
  for (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);
77
249
  for (const { module, description } of app.adminDescriptions()) {
@@ -86,7 +258,15 @@ var AdminModule = class {
86
258
  export {
87
259
  ADMIN_VERSION,
88
260
  AdminModule,
261
+ DEFAULT_ACTOR,
89
262
  DEFAULT_ADMIN_PATH,
90
- buildManifest
263
+ DEFAULT_CHECK_TIMEOUT_MS,
264
+ MAX_PAGE,
265
+ adminLog,
266
+ attention,
267
+ buildManifest,
268
+ collectChecks,
269
+ readAdminLog,
270
+ runDoctor
91
271
  };
92
272
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/module.ts","../src/manifest.ts"],"sourcesContent":["import type { IFonderieApp, IFonderieModule, IReadinessProblem, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAdminToken, validateAdminToken } from '@fonderie/core/middlewares';\n\nimport { buildManifest } from './manifest';\nimport type { IAdminOptions } from './types';\n\nexport const DEFAULT_ADMIN_PATH = '/_admin';\n\n// Replaced at build time (tsup env); the fallback is what tests see.\nexport const ADMIN_VERSION: string = process.env['FONDERIE_ADMIN_VERSION'] ?? '0.0.0-dev';\n\nexport class AdminModule implements IFonderieModule {\n\treadonly name = '@fonderie/admin';\n\treadonly version = ADMIN_VERSION;\n\treadonly path: string;\n\n\tconstructor(private options: IAdminOptions = {}) {\n\t\tthis.path = (options.path ?? DEFAULT_ADMIN_PATH).replace(/\\/+$/, '');\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst token = this.options.adminToken;\n\t\tif (!token) return;\n\n\t\tapp.reserve(this.path);\n\t\tconst guard = requireAdminToken(token);\n\t\t// Declared at the default path so the route table reads literally; re-based when configured.\n\t\tconst at = (p: string): string => this.path + p.slice(DEFAULT_ADMIN_PATH.length);\n\n\t\tconst own: Array<[string, string, Middleware]> = [\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/manifest',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(\n\t\t\t\t\t\tHTTP.OK,\n\t\t\t\t\t\t'ADMIN_MANIFEST',\n\t\t\t\t\t\t'Deployment manifest',\n\t\t\t\t\t\tbuildManifest(app, { version: this.version }),\n\t\t\t\t\t),\n\t\t\t],\n\t\t];\n\t\tconst mounted = new Map<string, string>();\n\t\tconst mount = (module: string, method: string, path: string, handlers: Middleware[]): void => {\n\t\t\tconst key = `${method.toUpperCase()} ${path}`;\n\t\t\tconst prior = mounted.get(key);\n\t\t\tif (prior) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`[fonderie] ${module} cannot describe ${key}: ${prior} already describes it`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tmounted.set(key, module);\n\t\t\tapp.addRoute(method, path, guard, ...handlers);\n\t\t};\n\n\t\tfor (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);\n\t\tfor (const { module, description } of app.adminDescriptions()) {\n\t\t\tfor (const r of description.routes ?? [])\n\t\t\t\tmount(module, r.method, this.path + r.path, r.handlers);\n\t\t}\n\t}\n\n\tcheckReadiness(): IReadinessProblem[] {\n\t\treturn validateAdminToken(this.options.adminToken, { module: this.name });\n\t}\n}\n","import type { IFonderieApp, IReadinessProblem } from '@fonderie/core';\n\nimport type { IAdminManifest, IAdminModuleEntry } from './types';\n\nexport function buildManifest(app: IFonderieApp, admin: { version: string }): IAdminManifest {\n\tconst report = app.securityReport();\n\tconst byModule = new Map<string, IReadinessProblem[]>();\n\tfor (const p of report.readiness.problems) {\n\t\tconst list = byModule.get(p.module) ?? [];\n\t\tlist.push(p);\n\t\tbyModule.set(p.module, list);\n\t}\n\tconst describing = new Set(app.adminDescriptions().map((d) => d.module));\n\tconst modules: IAdminModuleEntry[] = report.modules.map(({ name, version }) => {\n\t\tconst problems = byModule.get(name) ?? [];\n\t\treturn {\n\t\t\tname,\n\t\t\tversion: version ?? null,\n\t\t\treadiness: { ok: !problems.some((p) => p.severity === 'error'), problems },\n\t\t\tdescribesAdmin: describing.has(name),\n\t\t};\n\t});\n\treturn {\n\t\tgeneratedAt: report.generatedAt,\n\t\tenv: report.env,\n\t\tadmin,\n\t\tmodules,\n\t\treadiness: report.readiness,\n\t\troutes: app.routes(),\n\t};\n}\n"],"mappings":";AACA,SAAS,MAAM,sBAAsB;AACrC,SAAS,mBAAmB,0BAA0B;;;ACE/C,SAAS,cAAc,KAAmB,OAA4C;AAC5F,QAAM,SAAS,IAAI,eAAe;AAClC,QAAM,WAAW,oBAAI,IAAiC;AACtD,aAAW,KAAK,OAAO,UAAU,UAAU;AAC1C,UAAM,OAAO,SAAS,IAAI,EAAE,MAAM,KAAK,CAAC;AACxC,SAAK,KAAK,CAAC;AACX,aAAS,IAAI,EAAE,QAAQ,IAAI;AAAA,EAC5B;AACA,QAAM,aAAa,IAAI,IAAI,IAAI,kBAAkB,EAAE,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC;AACvE,QAAM,UAA+B,OAAO,QAAQ,IAAI,CAAC,EAAE,MAAM,QAAQ,MAAM;AAC9E,UAAM,WAAW,SAAS,IAAI,IAAI,KAAK,CAAC;AACxC,WAAO;AAAA,MACN;AAAA,MACA,SAAS,WAAW;AAAA,MACpB,WAAW,EAAE,IAAI,CAAC,SAAS,KAAK,CAAC,MAAM,EAAE,aAAa,OAAO,GAAG,SAAS;AAAA,MACzE,gBAAgB,WAAW,IAAI,IAAI;AAAA,IACpC;AAAA,EACD,CAAC;AACD,SAAO;AAAA,IACN,aAAa,OAAO;AAAA,IACpB,KAAK,OAAO;AAAA,IACZ;AAAA,IACA;AAAA,IACA,WAAW,OAAO;AAAA,IAClB,QAAQ,IAAI,OAAO;AAAA,EACpB;AACD;;;ADvBO,IAAM,qBAAqB;AAG3B,IAAM,gBAAwB;AAE9B,IAAM,cAAN,MAA6C;AAAA,EAKnD,YAAoB,UAAyB,CAAC,GAAG;AAA7B;AACnB,SAAK,QAAQ,QAAQ,QAAQ,oBAAoB,QAAQ,QAAQ,EAAE;AAAA,EACpE;AAAA,EAFoB;AAAA,EAJX,OAAO;AAAA,EACP,UAAU;AAAA,EACV;AAAA,EAMT,QAAQ,KAAyB;AAChC,UAAM,QAAQ,KAAK,QAAQ;AAC3B,QAAI,CAAC,MAAO;AAEZ,QAAI,QAAQ,KAAK,IAAI;AACrB,UAAM,QAAQ,kBAAkB,KAAK;AAErC,UAAM,KAAK,CAAC,MAAsB,KAAK,OAAO,EAAE,MAAM,mBAAmB,MAAM;AAE/E,UAAM,MAA2C;AAAA,MAChD;AAAA,QACC;AAAA,QACA;AAAA,QACA,YACC;AAAA,UACC,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA,cAAc,KAAK,EAAE,SAAS,KAAK,QAAQ,CAAC;AAAA,QAC7C;AAAA,MACF;AAAA,IACD;AACA,UAAM,UAAU,oBAAI,IAAoB;AACxC,UAAM,QAAQ,CAAC,QAAgB,QAAgB,MAAc,aAAiC;AAC7F,YAAM,MAAM,GAAG,OAAO,YAAY,CAAC,IAAI,IAAI;AAC3C,YAAM,QAAQ,QAAQ,IAAI,GAAG;AAC7B,UAAI,OAAO;AACV,cAAM,IAAI;AAAA,UACT,cAAc,MAAM,oBAAoB,GAAG,KAAK,KAAK;AAAA,QACtD;AAAA,MACD;AACA,cAAQ,IAAI,KAAK,MAAM;AACvB,UAAI,SAAS,QAAQ,MAAM,OAAO,GAAG,QAAQ;AAAA,IAC9C;AAEA,eAAW,CAAC,QAAQ,MAAM,OAAO,KAAK,IAAK,OAAM,KAAK,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC;AACvF,eAAW,EAAE,QAAQ,YAAY,KAAK,IAAI,kBAAkB,GAAG;AAC9D,iBAAW,KAAK,YAAY,UAAU,CAAC;AACtC,cAAM,QAAQ,EAAE,QAAQ,KAAK,OAAO,EAAE,MAAM,EAAE,QAAQ;AAAA,IACxD;AAAA,EACD;AAAA,EAEA,iBAAsC;AACrC,WAAO,mBAAmB,KAAK,QAAQ,YAAY,EAAE,QAAQ,KAAK,KAAK,CAAC;AAAA,EACzE;AACD;","names":[]}
1
+ {"version":3,"sources":["../src/module.ts","../src/doctor.ts","../src/log.ts","../src/manifest.ts"],"sourcesContent":["import type { IFonderieApp, IFonderieModule, IReadinessProblem, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAdminToken, validateAdminToken } from '@fonderie/core/middlewares';\n\nimport { attention, collectChecks, runDoctor } from './doctor';\nimport { adminLog, readAdminLog } from './log';\nimport { buildManifest } from './manifest';\nimport type { IAdminOptions } from './types';\n\nexport const DEFAULT_ADMIN_PATH = '/_admin';\nexport const DEFAULT_CHECK_TIMEOUT_MS = 10_000;\n\n// Replaced at build time (tsup env); the fallback is what tests see.\nexport const ADMIN_VERSION: string = process.env['FONDERIE_ADMIN_VERSION'] ?? '0.0.0-dev';\n\nexport class AdminModule implements IFonderieModule {\n\treadonly name = '@fonderie/admin';\n\treadonly version = ADMIN_VERSION;\n\treadonly path: string;\n\n\tconstructor(private options: IAdminOptions = {}) {\n\t\tthis.path = (options.path ?? DEFAULT_ADMIN_PATH).replace(/\\/+$/, '');\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst token = this.options.adminToken;\n\t\tif (!token) return;\n\n\t\tapp.reserve(this.path);\n\t\tconst guard = requireAdminToken(token);\n\t\t// Declared at the default path so the route table reads literally; re-based when configured.\n\t\tconst at = (p: string): string => this.path + p.slice(DEFAULT_ADMIN_PATH.length);\n\n\t\tconst store = this.options.store;\n\t\t// Collected once at boot: a duplicate name is refused here, not at request time.\n\t\tconst checks = collectChecks(app, this.options.checks ?? []);\n\t\tconst timeoutMs = this.options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS;\n\t\tconst doctor = () => runDoctor(checks, timeoutMs);\n\n\t\tconst own: Array<[string, string, Middleware]> = [\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(\n\t\t\t\t\t\tHTTP.OK,\n\t\t\t\t\t\t'ADMIN_ATTENTION',\n\t\t\t\t\t\t'What needs attention',\n\t\t\t\t\t\tattention(app, await doctor()),\n\t\t\t\t\t),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/manifest',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(\n\t\t\t\t\t\tHTTP.OK,\n\t\t\t\t\t\t'ADMIN_MANIFEST',\n\t\t\t\t\t\t'Deployment manifest',\n\t\t\t\t\t\tbuildManifest(app, { version: this.version, log: Boolean(store) }),\n\t\t\t\t\t),\n\t\t\t],\n\t\t\t[\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/doctor',\n\t\t\t\tasync () =>\n\t\t\t\t\tsetApiResponse(HTTP.OK, 'ADMIN_DOCTOR', 'Reconciliation checks', await doctor()),\n\t\t\t],\n\t\t];\n\t\tif (store) {\n\t\t\town.push([\n\t\t\t\t'GET',\n\t\t\t\t'/_admin/activity/admin-log',\n\t\t\t\tasync (ctx) => {\n\t\t\t\t\tconst q = new URL(ctx.request.url).searchParams;\n\t\t\t\t\tconst limit = Number(q.get('limit')) || undefined;\n\t\t\t\t\tconst before = q.get('before') ?? undefined;\n\t\t\t\t\tconst page = await readAdminLog(store, {\n\t\t\t\t\t\t...(limit ? { limit } : {}),\n\t\t\t\t\t\t...(before ? { before } : {}),\n\t\t\t\t\t});\n\t\t\t\t\treturn setApiResponse(HTTP.OK, 'ADMIN_LOG', 'Admin activity', page);\n\t\t\t\t},\n\t\t\t]);\n\t\t}\n\t\tconst mounted = new Map<string, string>();\n\t\tconst mount = (module: string, method: string, path: string, handlers: Middleware[]): void => {\n\t\t\tconst key = `${method.toUpperCase()} ${path}`;\n\t\t\tconst prior = mounted.get(key);\n\t\t\tif (prior) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`[fonderie] ${module} cannot describe ${key}: ${prior} already describes it`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tmounted.set(key, module);\n\t\t\t// The log sits before the guard: a refused request is a row too.\n\t\t\tconst chain = store\n\t\t\t\t? [adminLog(store, path, module), guard, ...handlers]\n\t\t\t\t: [guard, ...handlers];\n\t\t\tapp.addRoute(method, path, ...chain);\n\t\t};\n\n\t\tfor (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);\n\t\tfor (const { module, description } of app.adminDescriptions()) {\n\t\t\tfor (const r of description.routes ?? [])\n\t\t\t\tmount(module, r.method, this.path + r.path, r.handlers);\n\t\t}\n\t}\n\n\tcheckReadiness(): IReadinessProblem[] {\n\t\treturn validateAdminToken(this.options.adminToken, { module: this.name });\n\t}\n}\n","import type { IAdminCheck, IAdminCheckReport, IFonderieApp } from '@fonderie/core';\n\nimport type { IAdminAttention, IAdminCheckResult, IAdminDoctorReport } from './types';\n\nexport interface INamedCheck extends IAdminCheck {\n\tmodule: string;\n}\n\n// Module checks first (sorted by module), then the app's own. A name used\n// twice would make a report ambiguous, so it is refused up front.\nexport function collectChecks(app: IFonderieApp, own: IAdminCheck[]): INamedCheck[] {\n\tconst seen = new Map<string, string>();\n\tconst out: INamedCheck[] = [];\n\tconst add = (module: string, check: IAdminCheck): void => {\n\t\tconst prior = seen.get(check.name);\n\t\tif (prior)\n\t\t\tthrow new Error(\n\t\t\t\t`[fonderie] ${module} cannot offer check \"${check.name}\": ${prior} already does`,\n\t\t\t);\n\t\tseen.set(check.name, module);\n\t\tout.push({ ...check, module });\n\t};\n\tfor (const { module, description } of app.adminDescriptions()) {\n\t\tfor (const c of description.checks ?? []) add(module, c);\n\t}\n\tfor (const c of own) add('application', c);\n\treturn out;\n}\n\nasync function runOne(check: INamedCheck, timeoutMs: number): Promise<IAdminCheckResult> {\n\tconst started = Date.now();\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\tconst timeout = new Promise<IAdminCheckReport>((resolve) => {\n\t\ttimer = setTimeout(\n\t\t\t() => resolve({ ok: false, findings: [`timed out after ${timeoutMs} ms`] }),\n\t\t\ttimeoutMs,\n\t\t);\n\t});\n\tlet report: IAdminCheckReport;\n\ttry {\n\t\treport = await Promise.race([check.run(), timeout]);\n\t} catch (err) {\n\t\treport = {\n\t\t\tok: false,\n\t\t\tfindings: [`check threw: ${err instanceof Error ? err.message : String(err)}`],\n\t\t};\n\t} finally {\n\t\tif (timer) clearTimeout(timer);\n\t}\n\treturn { name: check.name, module: check.module, ...report, durationMs: Date.now() - started };\n}\n\nexport async function runDoctor(\n\tchecks: INamedCheck[],\n\ttimeoutMs: number,\n): Promise<IAdminDoctorReport> {\n\tconst results = await Promise.all(checks.map((c) => runOne(c, timeoutMs)));\n\treturn { generatedAt: new Date().toISOString(), ok: results.every((r) => r.ok), checks: results };\n}\n\n// What needs the operator today: readiness problems as reported, failed\n// checks as errors, findings on a passing check as advice. Empty = green.\nexport function attention(app: IFonderieApp, doctor: IAdminDoctorReport): IAdminAttention {\n\tconst items: IAdminAttention['items'] = [];\n\tfor (const p of app.checkProductionReadiness().problems) {\n\t\titems.push({\n\t\t\tsource: p.module,\n\t\t\tseverity: p.severity === 'error' ? 'error' : 'advice',\n\t\t\tmessage: p.message,\n\t\t});\n\t}\n\tfor (const c of doctor.checks) {\n\t\tif (c.skipped) continue;\n\t\tconst severity = c.ok ? 'advice' : 'error';\n\t\tif (!c.ok && c.findings.length === 0)\n\t\t\titems.push({ source: c.name, severity, message: 'failed' });\n\t\tfor (const f of c.findings) items.push({ source: c.name, severity, message: f });\n\t}\n\treturn { generatedAt: doctor.generatedAt, ok: !items.some((i) => i.severity === 'error'), items };\n}\n","import type { IFonderieContext, Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAdminLogEntry, IAdminLogPage } from './types';\n\nexport const DEFAULT_ACTOR = 'admin-token';\n\n// Sits BEFORE the token guard, so a refused request is a row too. A failed\n// write must not fail the request it describes.\nexport function adminLog(store: IStoreAdapter, route: string, module: string): Middleware {\n\treturn async (ctx: IFonderieContext, next) => {\n\t\tconst started = Date.now();\n\t\tconst res = await next();\n\t\tconst row = {\n\t\t\tactor: ctx.request.headers.get('x-actor') || DEFAULT_ACTOR,\n\t\t\tmethod: ctx.request.method,\n\t\t\tpath: new URL(ctx.request.url).pathname,\n\t\t\troute,\n\t\t\tmodule,\n\t\t\tstatus: res.status,\n\t\t\tdurationMs: Date.now() - started,\n\t\t\trequestId: typeof ctx.meta['requestId'] === 'string' ? ctx.meta['requestId'] : null,\n\t\t\tclientIp: ctx.meta.clientIp ?? null,\n\t\t};\n\t\ttry {\n\t\t\tawait store.query(\n\t\t\t\t`INSERT INTO fonderie_admin_log\n\t\t\t\t (actor, method, path, route, module, status, duration_ms, request_id, client_ip)\n\t\t\t\t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,\n\t\t\t\t[\n\t\t\t\t\trow.actor,\n\t\t\t\t\trow.method,\n\t\t\t\t\trow.path,\n\t\t\t\t\trow.route,\n\t\t\t\t\trow.module,\n\t\t\t\t\trow.status,\n\t\t\t\t\trow.durationMs,\n\t\t\t\t\trow.requestId,\n\t\t\t\t\trow.clientIp,\n\t\t\t\t],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tconsole.error(\n\t\t\t\t'[admin] could not write the admin log:',\n\t\t\t\terr instanceof Error ? err.message : err,\n\t\t\t);\n\t\t}\n\t\treturn res;\n\t};\n}\n\nexport const MAX_PAGE = 200;\n\n// Newest first, keyset on (at, id). `before` is the last entry's cursor.\nexport async function readAdminLog(\n\tstore: IStoreAdapter,\n\topts: { limit?: number; before?: string } = {},\n): Promise<IAdminLogPage> {\n\tconst limit = Math.min(Math.max(opts.limit ?? 50, 1), MAX_PAGE);\n\tconst cursor = opts.before ? decodeCursor(opts.before) : null;\n\tconst rows = await store.query<IAdminLogEntry>(\n\t\t`SELECT id, at, actor, method, path, route, module, status,\n\t\t duration_ms AS \"durationMs\", request_id AS \"requestId\", client_ip AS \"clientIp\"\n\t\t FROM fonderie_admin_log\n\t\t WHERE ($1::timestamptz IS NULL OR (at, id) < ($1::timestamptz, $2::uuid))\n\t\t ORDER BY at DESC, id DESC\n\t\t LIMIT $3`,\n\t\t[cursor?.at ?? null, cursor?.id ?? null, limit + 1],\n\t);\n\tconst page = rows.slice(0, limit);\n\tconst last = page[page.length - 1];\n\treturn {\n\t\tentries: page,\n\t\tnext: rows.length > limit && last ? encodeCursor(last) : null,\n\t};\n}\n\nfunction encodeCursor(e: IAdminLogEntry): string {\n\treturn Buffer.from(`${new Date(e.at).toISOString()}|${e.id}`, 'utf8').toString('base64url');\n}\n\nfunction decodeCursor(c: string): { at: string; id: string } | null {\n\tconst [at, id] = Buffer.from(c, 'base64url').toString('utf8').split('|');\n\treturn at && id ? { at, id } : null;\n}\n","import type { IFonderieApp, IReadinessProblem } from '@fonderie/core';\n\nimport type { IAdminManifest, IAdminModuleEntry } from './types';\n\nexport function buildManifest(\n\tapp: IFonderieApp,\n\tadmin: { version: string; log: boolean },\n): IAdminManifest {\n\tconst report = app.securityReport();\n\tconst byModule = new Map<string, IReadinessProblem[]>();\n\tfor (const p of report.readiness.problems) {\n\t\tconst list = byModule.get(p.module) ?? [];\n\t\tlist.push(p);\n\t\tbyModule.set(p.module, list);\n\t}\n\tconst describing = new Set(app.adminDescriptions().map((d) => d.module));\n\tconst modules: IAdminModuleEntry[] = report.modules.map(({ name, version }) => {\n\t\tconst problems = byModule.get(name) ?? [];\n\t\treturn {\n\t\t\tname,\n\t\t\tversion: version ?? null,\n\t\t\treadiness: { ok: !problems.some((p) => p.severity === 'error'), problems },\n\t\t\tdescribesAdmin: describing.has(name),\n\t\t};\n\t});\n\treturn {\n\t\tgeneratedAt: report.generatedAt,\n\t\tenv: report.env,\n\t\tadmin,\n\t\tmodules,\n\t\treadiness: report.readiness,\n\t\troutes: app.routes(),\n\t};\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,3 @@
1
+ declare const getMigrationsPath: () => string;
2
+
3
+ export { getMigrationsPath };
@@ -0,0 +1,7 @@
1
+ // src/migrations/index.ts
2
+ import { createMigrationsPath } from "@fonderie/store";
3
+ var getMigrationsPath = () => createMigrationsPath(import.meta.url);
4
+ export {
5
+ getMigrationsPath
6
+ };
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/migrations/index.ts"],"sourcesContent":["import { createMigrationsPath } from '@fonderie/store';\n\nexport const getMigrationsPath = (): string => createMigrationsPath(import.meta.url);\n"],"mappings":";AAAA,SAAS,4BAA4B;AAE9B,IAAM,oBAAoB,MAAc,qBAAqB,YAAY,GAAG;","names":[]}
@@ -0,0 +1,18 @@
1
+ -- fonderie_admin_log: every request served by the admin surface, including
2
+ -- the ones the token guard refused. Append-only; the operator reads it.
3
+ CREATE TABLE IF NOT EXISTS fonderie_admin_log (
4
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5
+ at TIMESTAMPTZ NOT NULL DEFAULT now(),
6
+ actor TEXT NOT NULL, -- X-Actor header, or 'admin-token'
7
+ method TEXT NOT NULL,
8
+ path TEXT NOT NULL, -- request pathname, no query string
9
+ route TEXT NOT NULL, -- the pattern, relative to basePath: /_admin/secrets/:key/reveal
10
+ module TEXT NOT NULL, -- who described the route
11
+ status INTEGER NOT NULL,
12
+ duration_ms INTEGER NOT NULL,
13
+ request_id TEXT,
14
+ client_ip TEXT
15
+ );
16
+
17
+ CREATE INDEX IF NOT EXISTS idx_fonderie_admin_log_at
18
+ ON fonderie_admin_log (at DESC, id DESC);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/admin",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "fonderie": { "stability": "experimental" },
5
5
  "description": "The operator's surface for a Fonderie deployment. Owns one reserved path (default /_admin) behind one admin token, and answers what the founder cannot see after deploy: which bricks are installed, at what version, whether they are ready, and every route that is exposed. The run-time counterpart to the SDK's brain — read by the human through a panel and by a model through curl.",
6
6
  "keywords": [
@@ -22,13 +22,17 @@
22
22
  "types": "./dist/index.d.ts",
23
23
  "import": "./dist/index.js",
24
24
  "require": "./dist/index.cjs"
25
+ },
26
+ "./migrations": {
27
+ "types": "./dist/migrations/index.d.ts",
28
+ "import": "./dist/migrations/index.js"
25
29
  }
26
30
  },
27
31
  "main": "./dist/index.cjs",
28
32
  "module": "./dist/index.js",
29
33
  "types": "./dist/index.d.ts",
30
34
  "scripts": {
31
- "build": "tsup",
35
+ "build": "tsup && tsup --config tsup.migrations.ts",
32
36
  "dev": "tsup --watch",
33
37
  "typecheck": "tsc --noEmit",
34
38
  "test": "tsx --test src/__tests__/*.test.ts",
@@ -37,10 +41,17 @@
37
41
  "check": "biome check --write src"
38
42
  },
39
43
  "peerDependencies": {
40
- "@fonderie/core": "^0.18.0"
44
+ "@fonderie/core": "^0.19.0",
45
+ "@fonderie/store": "^0.5.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@fonderie/store": {
49
+ "optional": true
50
+ }
41
51
  },
42
52
  "devDependencies": {
43
53
  "@fonderie/core": "../core",
54
+ "@fonderie/store": "../store",
44
55
  "@types/node": "^26.5.1",
45
56
  "tsup": "^8.5.1",
46
57
  "tsx": "^4.23.13",