@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 +62 -0
- package/brain/outcomes.md +24 -1
- package/brain/signatures.md +66 -1
- package/dist/index.cjs +193 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -2
- package/dist/index.d.ts +61 -2
- package/dist/index.js +184 -4
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_admin_log.sql +18 -0
- package/package.json +14 -3
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
|
|
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) }), )` |
|
package/brain/signatures.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/index.cjs.map
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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":";AACA,SAAS,MAAM,sBAAsB;AACrC,SAAS,mBAAmB,0BAA0B;;;ACQ/C,SAAS,cAAc,KAAmB,KAAmC;AACnF,QAAM,OAAO,oBAAI,IAAoB;AACrC,QAAM,MAAqB,CAAC;AAC5B,QAAM,MAAM,CAAC,QAAgB,UAA6B;AACzD,UAAM,QAAQ,KAAK,IAAI,MAAM,IAAI;AACjC,QAAI;AACH,YAAM,IAAI;AAAA,QACT,cAAc,MAAM,wBAAwB,MAAM,IAAI,MAAM,KAAK;AAAA,MAClE;AACD,SAAK,IAAI,MAAM,MAAM,MAAM;AAC3B,QAAI,KAAK,EAAE,GAAG,OAAO,OAAO,CAAC;AAAA,EAC9B;AACA,aAAW,EAAE,QAAQ,YAAY,KAAK,IAAI,kBAAkB,GAAG;AAC9D,eAAW,KAAK,YAAY,UAAU,CAAC,EAAG,KAAI,QAAQ,CAAC;AAAA,EACxD;AACA,aAAW,KAAK,IAAK,KAAI,eAAe,CAAC;AACzC,SAAO;AACR;AAEA,eAAe,OAAO,OAAoB,WAA+C;AACxF,QAAM,UAAU,KAAK,IAAI;AACzB,MAAI;AACJ,QAAM,UAAU,IAAI,QAA2B,CAAC,YAAY;AAC3D,YAAQ;AAAA,MACP,MAAM,QAAQ,EAAE,IAAI,OAAO,UAAU,CAAC,mBAAmB,SAAS,KAAK,EAAE,CAAC;AAAA,MAC1E;AAAA,IACD;AAAA,EACD,CAAC;AACD,MAAI;AACJ,MAAI;AACH,aAAS,MAAM,QAAQ,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC;AAAA,EACnD,SAAS,KAAK;AACb,aAAS;AAAA,MACR,IAAI;AAAA,MACJ,UAAU,CAAC,gBAAgB,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC,EAAE;AAAA,IAC9E;AAAA,EACD,UAAE;AACD,QAAI,MAAO,cAAa,KAAK;AAAA,EAC9B;AACA,SAAO,EAAE,MAAM,MAAM,MAAM,QAAQ,MAAM,QAAQ,GAAG,QAAQ,YAAY,KAAK,IAAI,IAAI,QAAQ;AAC9F;AAEA,eAAsB,UACrB,QACA,WAC8B;AAC9B,QAAM,UAAU,MAAM,QAAQ,IAAI,OAAO,IAAI,CAAC,MAAM,OAAO,GAAG,SAAS,CAAC,CAAC;AACzE,SAAO,EAAE,cAAa,oBAAI,KAAK,GAAE,YAAY,GAAG,IAAI,QAAQ,MAAM,CAAC,MAAM,EAAE,EAAE,GAAG,QAAQ,QAAQ;AACjG;AAIO,SAAS,UAAU,KAAmB,QAA6C;AACzF,QAAM,QAAkC,CAAC;AACzC,aAAW,KAAK,IAAI,yBAAyB,EAAE,UAAU;AACxD,UAAM,KAAK;AAAA,MACV,QAAQ,EAAE;AAAA,MACV,UAAU,EAAE,aAAa,UAAU,UAAU;AAAA,MAC7C,SAAS,EAAE;AAAA,IACZ,CAAC;AAAA,EACF;AACA,aAAW,KAAK,OAAO,QAAQ;AAC9B,QAAI,EAAE,QAAS;AACf,UAAM,WAAW,EAAE,KAAK,WAAW;AACnC,QAAI,CAAC,EAAE,MAAM,EAAE,SAAS,WAAW;AAClC,YAAM,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,SAAS,SAAS,CAAC;AAC3D,eAAW,KAAK,EAAE,SAAU,OAAM,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,SAAS,EAAE,CAAC;AAAA,EAChF;AACA,SAAO,EAAE,aAAa,OAAO,aAAa,IAAI,CAAC,MAAM,KAAK,CAAC,MAAM,EAAE,aAAa,OAAO,GAAG,MAAM;AACjG;;;AC1EO,IAAM,gBAAgB;AAItB,SAAS,SAAS,OAAsB,OAAe,QAA4B;AACzF,SAAO,OAAO,KAAuB,SAAS;AAC7C,UAAM,UAAU,KAAK,IAAI;AACzB,UAAM,MAAM,MAAM,KAAK;AACvB,UAAM,MAAM;AAAA,MACX,OAAO,IAAI,QAAQ,QAAQ,IAAI,SAAS,KAAK;AAAA,MAC7C,QAAQ,IAAI,QAAQ;AAAA,MACpB,MAAM,IAAI,IAAI,IAAI,QAAQ,GAAG,EAAE;AAAA,MAC/B;AAAA,MACA;AAAA,MACA,QAAQ,IAAI;AAAA,MACZ,YAAY,KAAK,IAAI,IAAI;AAAA,MACzB,WAAW,OAAO,IAAI,KAAK,WAAW,MAAM,WAAW,IAAI,KAAK,WAAW,IAAI;AAAA,MAC/E,UAAU,IAAI,KAAK,YAAY;AAAA,IAChC;AACA,QAAI;AACH,YAAM,MAAM;AAAA,QACX;AAAA;AAAA;AAAA,QAGA;AAAA,UACC,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,UACJ,IAAI;AAAA,QACL;AAAA,MACD;AAAA,IACD,SAAS,KAAK;AACb,cAAQ;AAAA,QACP;AAAA,QACA,eAAe,QAAQ,IAAI,UAAU;AAAA,MACtC;AAAA,IACD;AACA,WAAO;AAAA,EACR;AACD;AAEO,IAAM,WAAW;AAGxB,eAAsB,aACrB,OACA,OAA4C,CAAC,GACpB;AACzB,QAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,KAAK,SAAS,IAAI,CAAC,GAAG,QAAQ;AAC9D,QAAM,SAAS,KAAK,SAAS,aAAa,KAAK,MAAM,IAAI;AACzD,QAAM,OAAO,MAAM,MAAM;AAAA,IACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMA,CAAC,QAAQ,MAAM,MAAM,QAAQ,MAAM,MAAM,QAAQ,CAAC;AAAA,EACnD;AACA,QAAM,OAAO,KAAK,MAAM,GAAG,KAAK;AAChC,QAAM,OAAO,KAAK,KAAK,SAAS,CAAC;AACjC,SAAO;AAAA,IACN,SAAS;AAAA,IACT,MAAM,KAAK,SAAS,SAAS,OAAO,aAAa,IAAI,IAAI;AAAA,EAC1D;AACD;AAEA,SAAS,aAAa,GAA2B;AAChD,SAAO,OAAO,KAAK,GAAG,IAAI,KAAK,EAAE,EAAE,EAAE,YAAY,CAAC,IAAI,EAAE,EAAE,IAAI,MAAM,EAAE,SAAS,WAAW;AAC3F;AAEA,SAAS,aAAa,GAA8C;AACnE,QAAM,CAAC,IAAI,EAAE,IAAI,OAAO,KAAK,GAAG,WAAW,EAAE,SAAS,MAAM,EAAE,MAAM,GAAG;AACvE,SAAO,MAAM,KAAK,EAAE,IAAI,GAAG,IAAI;AAChC;;;AChFO,SAAS,cACf,KACA,OACiB;AACjB,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;;;AHxBO,IAAM,qBAAqB;AAC3B,IAAM,2BAA2B;AAGjC,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,QAAQ,KAAK,QAAQ;AAE3B,UAAM,SAAS,cAAc,KAAK,KAAK,QAAQ,UAAU,CAAC,CAAC;AAC3D,UAAM,YAAY,KAAK,QAAQ,kBAAkB;AACjD,UAAM,SAAS,MAAM,UAAU,QAAQ,SAAS;AAEhD,UAAM,MAA2C;AAAA,MAChD;AAAA,QACC;AAAA,QACA;AAAA,QACA,YACC;AAAA,UACC,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA,UAAU,KAAK,MAAM,OAAO,CAAC;AAAA,QAC9B;AAAA,MACF;AAAA,MACA;AAAA,QACC;AAAA,QACA;AAAA,QACA,YACC;AAAA,UACC,KAAK;AAAA,UACL;AAAA,UACA;AAAA,UACA,cAAc,KAAK,EAAE,SAAS,KAAK,SAAS,KAAK,QAAQ,KAAK,EAAE,CAAC;AAAA,QAClE;AAAA,MACF;AAAA,MACA;AAAA,QACC;AAAA,QACA;AAAA,QACA,YACC,eAAe,KAAK,IAAI,gBAAgB,yBAAyB,MAAM,OAAO,CAAC;AAAA,MACjF;AAAA,IACD;AACA,QAAI,OAAO;AACV,UAAI,KAAK;AAAA,QACR;AAAA,QACA;AAAA,QACA,OAAO,QAAQ;AACd,gBAAM,IAAI,IAAI,IAAI,IAAI,QAAQ,GAAG,EAAE;AACnC,gBAAM,QAAQ,OAAO,EAAE,IAAI,OAAO,CAAC,KAAK;AACxC,gBAAM,SAAS,EAAE,IAAI,QAAQ,KAAK;AAClC,gBAAM,OAAO,MAAM,aAAa,OAAO;AAAA,YACtC,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;AAAA,YACzB,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,UAC5B,CAAC;AACD,iBAAO,eAAe,KAAK,IAAI,aAAa,kBAAkB,IAAI;AAAA,QACnE;AAAA,MACD,CAAC;AAAA,IACF;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;AAEvB,YAAM,QAAQ,QACX,CAAC,SAAS,OAAO,MAAM,MAAM,GAAG,OAAO,GAAG,QAAQ,IAClD,CAAC,OAAO,GAAG,QAAQ;AACtB,UAAI,SAAS,QAAQ,MAAM,GAAG,KAAK;AAAA,IACpC;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":[]}
|
|
@@ -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.
|
|
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.
|
|
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",
|