enpilink 1.0.2 → 1.0.4
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/dist/server/analytics.d.ts +61 -21
- package/dist/server/analytics.js +107 -52
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/analytics.test.js +63 -12
- package/dist/server/analytics.test.js.map +1 -1
- package/dist/server/capture-gate.d.ts +47 -0
- package/dist/server/capture-gate.js +39 -0
- package/dist/server/capture-gate.js.map +1 -0
- package/dist/server/capture-gate.test.d.ts +1 -0
- package/dist/server/capture-gate.test.js +124 -0
- package/dist/server/capture-gate.test.js.map +1 -0
- package/dist/server/config/config.test.js +201 -8
- package/dist/server/config/config.test.js.map +1 -1
- package/dist/server/config/index.d.ts +3 -2
- package/dist/server/config/index.js +3 -2
- package/dist/server/config/index.js.map +1 -1
- package/dist/server/config/presets.d.ts +36 -0
- package/dist/server/config/presets.js +46 -0
- package/dist/server/config/presets.js.map +1 -0
- package/dist/server/config/resolve.d.ts +42 -3
- package/dist/server/config/resolve.js +88 -8
- package/dist/server/config/resolve.js.map +1 -1
- package/dist/server/config/router.d.ts +22 -14
- package/dist/server/config/router.js +163 -51
- package/dist/server/config/router.js.map +1 -1
- package/dist/server/config/schema.d.ts +39 -1
- package/dist/server/config/schema.js +121 -0
- package/dist/server/config/schema.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/storage/memory.d.ts +1 -0
- package/dist/server/storage/memory.js +14 -0
- package/dist/server/storage/memory.js.map +1 -1
- package/dist/server/storage/memory.test.js +17 -0
- package/dist/server/storage/memory.test.js.map +1 -1
- package/dist/server/storage/postgres.d.ts +1 -0
- package/dist/server/storage/postgres.js +12 -0
- package/dist/server/storage/postgres.js.map +1 -1
- package/dist/server/storage/postgres.test.js +17 -0
- package/dist/server/storage/postgres.test.js.map +1 -1
- package/dist/server/storage/sqlite.d.ts +1 -0
- package/dist/server/storage/sqlite.js +21 -0
- package/dist/server/storage/sqlite.js.map +1 -1
- package/dist/server/storage/sqlite.test.js +17 -0
- package/dist/server/storage/sqlite.test.js.map +1 -1
- package/dist/server/storage/types.d.ts +6 -0
- package/dist/server/storage/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
import { type Router } from "express";
|
|
2
2
|
import type { StorageAdapter } from "../storage/types.js";
|
|
3
3
|
/**
|
|
4
|
-
* Config admin API
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Config admin API. Pure core — reads the SAME active {@link StorageAdapter}
|
|
5
|
+
* the analytics middleware writes to, via {@link getActiveStorage}. Does NOT
|
|
6
|
+
* depend on `@enpilink/console`.
|
|
7
7
|
*
|
|
8
|
-
* Mounted dev-only (
|
|
9
|
-
*
|
|
10
|
-
* auth) is M5.
|
|
8
|
+
* Mounted dev-only (unauth, localhost) and in the prod admin plane (behind
|
|
9
|
+
* bearer auth) at `/__enpilink/config`.
|
|
11
10
|
*
|
|
12
11
|
* Routes:
|
|
13
|
-
* - `GET
|
|
14
|
-
* secret/envLocked flags).
|
|
15
|
-
* - `PUT
|
|
16
|
-
*
|
|
17
|
-
* - `
|
|
12
|
+
* - `GET /__enpilink/config` — all settings (rich metadata +
|
|
13
|
+
* source + secret/envLocked/modified/restartRequired flags).
|
|
14
|
+
* - `PUT /__enpilink/config/:key` — set a RUNTIME or RESTART-tier
|
|
15
|
+
* key. Rejects secret / `admin` / env-locked / unknown keys with a clear 4xx.
|
|
16
|
+
* - `DELETE /__enpilink/config/:key` — reset a key to its default
|
|
17
|
+
* (clears the DB override). Same guardrails as PUT.
|
|
18
|
+
* - `GET /__enpilink/config/presets` — list presets + the values each
|
|
19
|
+
* would set.
|
|
20
|
+
* - `POST /__enpilink/config/preset/:name` — apply a preset (validate +
|
|
21
|
+
* persist + audit each runtime key; skip env-locked).
|
|
22
|
+
* - `GET /__enpilink/config/audit` — recent config-change history.
|
|
18
23
|
*
|
|
19
|
-
* Disabled-safe:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
24
|
+
* Disabled-safe: with no active storage, reads fall back to env/file/defaults
|
|
25
|
+
* and NEVER 500. Writes require a storage adapter (409 when none).
|
|
26
|
+
*
|
|
27
|
+
* SECURITY: `admin`, `adminAuthToken`, unknown keys, and env-locked keys can
|
|
28
|
+
* NEVER be written here. `adminAuthToken` is never persisted nor returned in
|
|
29
|
+
* plaintext.
|
|
22
30
|
*/
|
|
23
31
|
export declare function createConfigRouter(getStorage?: () => StorageAdapter | null): Router;
|
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
import express, {} from "express";
|
|
2
|
+
import { refreshCaptureGate } from "../capture-gate.js";
|
|
2
3
|
import { getActiveStorage } from "../log-sink.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { getPreset, PRESETS } from "./presets.js";
|
|
5
|
+
import { resolveConfig, validateConfigWrite, } from "./resolve.js";
|
|
6
|
+
import { isBootstrapKey, isKnownKey, isRestartKey, isRuntimeKey, isSecretKey, } from "./schema.js";
|
|
5
7
|
/**
|
|
6
|
-
* Config admin API
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Config admin API. Pure core — reads the SAME active {@link StorageAdapter}
|
|
9
|
+
* the analytics middleware writes to, via {@link getActiveStorage}. Does NOT
|
|
10
|
+
* depend on `@enpilink/console`.
|
|
9
11
|
*
|
|
10
|
-
* Mounted dev-only (
|
|
11
|
-
*
|
|
12
|
-
* auth) is M5.
|
|
12
|
+
* Mounted dev-only (unauth, localhost) and in the prod admin plane (behind
|
|
13
|
+
* bearer auth) at `/__enpilink/config`.
|
|
13
14
|
*
|
|
14
15
|
* Routes:
|
|
15
|
-
* - `GET
|
|
16
|
-
* secret/envLocked flags).
|
|
17
|
-
* - `PUT
|
|
18
|
-
*
|
|
19
|
-
* - `
|
|
16
|
+
* - `GET /__enpilink/config` — all settings (rich metadata +
|
|
17
|
+
* source + secret/envLocked/modified/restartRequired flags).
|
|
18
|
+
* - `PUT /__enpilink/config/:key` — set a RUNTIME or RESTART-tier
|
|
19
|
+
* key. Rejects secret / `admin` / env-locked / unknown keys with a clear 4xx.
|
|
20
|
+
* - `DELETE /__enpilink/config/:key` — reset a key to its default
|
|
21
|
+
* (clears the DB override). Same guardrails as PUT.
|
|
22
|
+
* - `GET /__enpilink/config/presets` — list presets + the values each
|
|
23
|
+
* would set.
|
|
24
|
+
* - `POST /__enpilink/config/preset/:name` — apply a preset (validate +
|
|
25
|
+
* persist + audit each runtime key; skip env-locked).
|
|
26
|
+
* - `GET /__enpilink/config/audit` — recent config-change history.
|
|
20
27
|
*
|
|
21
|
-
* Disabled-safe:
|
|
22
|
-
*
|
|
23
|
-
*
|
|
28
|
+
* Disabled-safe: with no active storage, reads fall back to env/file/defaults
|
|
29
|
+
* and NEVER 500. Writes require a storage adapter (409 when none).
|
|
30
|
+
*
|
|
31
|
+
* SECURITY: `admin`, `adminAuthToken`, unknown keys, and env-locked keys can
|
|
32
|
+
* NEVER be written here. `adminAuthToken` is never persisted nor returned in
|
|
33
|
+
* plaintext.
|
|
24
34
|
*/
|
|
25
35
|
export function createConfigRouter(getStorage = getActiveStorage) {
|
|
26
36
|
const router = express.Router();
|
|
@@ -32,11 +42,14 @@ export function createConfigRouter(getStorage = getActiveStorage) {
|
|
|
32
42
|
res.json({ settings: resolved.settings });
|
|
33
43
|
}
|
|
34
44
|
catch {
|
|
35
|
-
// Last-resort: resolve with no storage so reads never fail.
|
|
36
45
|
const resolved = await resolveConfig(null);
|
|
37
46
|
res.json({ settings: resolved.settings });
|
|
38
47
|
}
|
|
39
48
|
});
|
|
49
|
+
// GET /config/presets — list presets + the values each would set.
|
|
50
|
+
router.get(`${base}/presets`, (_req, res) => {
|
|
51
|
+
res.json({ presets: Object.values(PRESETS) });
|
|
52
|
+
});
|
|
40
53
|
// GET /config/audit — change history (most recent first). Never 500.
|
|
41
54
|
router.get(`${base}/audit`, async (_req, res) => {
|
|
42
55
|
const storage = getStorage();
|
|
@@ -52,65 +65,164 @@ export function createConfigRouter(getStorage = getActiveStorage) {
|
|
|
52
65
|
res.json({ enabled: false, audit: [] });
|
|
53
66
|
}
|
|
54
67
|
});
|
|
55
|
-
//
|
|
56
|
-
router.
|
|
57
|
-
const
|
|
58
|
-
if (!
|
|
59
|
-
res.status(404).json({ error: `Unknown
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (isSecretKey(key)) {
|
|
63
|
-
res.status(403).json({
|
|
64
|
-
error: `"${key}" is a secret and is set via environment only`,
|
|
65
|
-
});
|
|
68
|
+
// POST /config/preset/:name — apply a runtime-only preset.
|
|
69
|
+
router.post(`${base}/preset/:name`, async (req, res) => {
|
|
70
|
+
const preset = getPreset(req.params.name);
|
|
71
|
+
if (!preset) {
|
|
72
|
+
res.status(404).json({ error: `Unknown preset "${req.params.name}"` });
|
|
66
73
|
return;
|
|
67
74
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
const storage = getStorage();
|
|
76
|
+
if (!storage) {
|
|
77
|
+
res.status(409).json({
|
|
78
|
+
error: "No active storage; cannot apply preset",
|
|
71
79
|
});
|
|
72
80
|
return;
|
|
73
81
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
const resolved = await resolveConfig(storage);
|
|
83
|
+
const byKey = new Map(resolved.settings.map((s) => [s.key, s]));
|
|
84
|
+
const actor = actorOf(req);
|
|
85
|
+
const applied = [];
|
|
86
|
+
const skipped = [];
|
|
87
|
+
for (const [key, value] of Object.entries(preset.values)) {
|
|
88
|
+
// Presets only ever touch runtime keys; double-check the guardrail.
|
|
89
|
+
if (!isRuntimeKey(key)) {
|
|
90
|
+
skipped.push({ key, reason: "not a runtime key" });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const setting = byKey.get(key);
|
|
94
|
+
if (setting?.envLocked) {
|
|
95
|
+
skipped.push({ key, reason: `pinned via ${setting.source}` });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const check = validateConfigWrite(key, value);
|
|
99
|
+
if (!check.ok) {
|
|
100
|
+
skipped.push({ key, reason: check.error });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await storage.setConfig(key, check.value, actor);
|
|
105
|
+
applied.push({ key, value: check.value });
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
skipped.push({
|
|
109
|
+
key,
|
|
110
|
+
reason: err instanceof Error ? err.message : "write failed",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
77
113
|
}
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
114
|
+
// A preset may have changed analytics.enabled/sampleRate — refresh the live
|
|
115
|
+
// capture gate so it takes effect without a restart.
|
|
116
|
+
await refreshCaptureGate();
|
|
117
|
+
res.json({ ok: true, preset: preset.name, applied, skipped });
|
|
118
|
+
});
|
|
119
|
+
// PUT /config/:key — set a runtime or restart-tier key.
|
|
120
|
+
router.put(`${base}/:key`, async (req, res) => {
|
|
121
|
+
const guard = await writeGuard(req.params.key, getStorage);
|
|
122
|
+
if (!guard.ok) {
|
|
123
|
+
res.status(guard.status).json({ error: guard.error });
|
|
85
124
|
return;
|
|
86
125
|
}
|
|
126
|
+
const key = guard.key;
|
|
87
127
|
const body = req.body;
|
|
88
|
-
const
|
|
89
|
-
const check = validateRuntimeWrite(key, rawValue);
|
|
128
|
+
const check = validateConfigWrite(key, body?.value);
|
|
90
129
|
if (!check.ok) {
|
|
91
130
|
res.status(400).json({ error: check.error });
|
|
92
131
|
return;
|
|
93
132
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
133
|
+
try {
|
|
134
|
+
await guard.storage.setConfig(key, check.value, actorOf(req));
|
|
135
|
+
// Refresh the live capture gate so a toggle of analytics.enabled /
|
|
136
|
+
// analytics.sampleRate takes effect immediately (no restart).
|
|
137
|
+
await refreshCaptureGate();
|
|
138
|
+
res.json({
|
|
139
|
+
ok: true,
|
|
140
|
+
key,
|
|
141
|
+
value: check.value,
|
|
142
|
+
restartRequired: isRestartKey(key),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
res.status(500).json({
|
|
147
|
+
error: err instanceof Error ? err.message : "Failed to write config",
|
|
98
148
|
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// DELETE /config/:key — reset to default (clear the DB override).
|
|
152
|
+
router.delete(`${base}/:key`, async (req, res) => {
|
|
153
|
+
const guard = await writeGuard(req.params.key, getStorage);
|
|
154
|
+
if (!guard.ok) {
|
|
155
|
+
res.status(guard.status).json({ error: guard.error });
|
|
99
156
|
return;
|
|
100
157
|
}
|
|
101
158
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
159
|
+
await guard.storage.clearConfig(guard.key, actorOf(req));
|
|
160
|
+
// Resetting analytics.enabled / sampleRate to default also re-gates
|
|
161
|
+
// capture live.
|
|
162
|
+
await refreshCaptureGate();
|
|
163
|
+
res.json({
|
|
164
|
+
ok: true,
|
|
165
|
+
key: guard.key,
|
|
166
|
+
reset: true,
|
|
167
|
+
restartRequired: isRestartKey(guard.key),
|
|
168
|
+
});
|
|
105
169
|
}
|
|
106
170
|
catch (err) {
|
|
107
171
|
res.status(500).json({
|
|
108
|
-
error: err instanceof Error ? err.message : "Failed to
|
|
172
|
+
error: err instanceof Error ? err.message : "Failed to reset config",
|
|
109
173
|
});
|
|
110
174
|
}
|
|
111
175
|
});
|
|
112
176
|
return router;
|
|
113
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Shared guardrail for PUT + DELETE. Rejects unknown / secret / `admin` /
|
|
180
|
+
* env-locked keys and requires an active storage adapter. Only runtime and
|
|
181
|
+
* non-env-locked restart-tier keys pass.
|
|
182
|
+
*/
|
|
183
|
+
async function writeGuard(key, getStorage) {
|
|
184
|
+
if (!isKnownKey(key)) {
|
|
185
|
+
return { ok: false, status: 404, error: `Unknown config key "${key}"` };
|
|
186
|
+
}
|
|
187
|
+
if (isSecretKey(key)) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
status: 403,
|
|
191
|
+
error: `"${key}" is a secret and is set via environment only`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// Bootstrap keys are writable ONLY if they are restart-tier (port/storage/
|
|
195
|
+
// dbPath). `admin` (and any other bootstrap key) is env-only / read-only.
|
|
196
|
+
if (isBootstrapKey(key) && !isRestartKey(key)) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
status: 403,
|
|
200
|
+
error: `"${key}" is environment-only and is read-only here`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (!isRuntimeKey(key) && !isRestartKey(key)) {
|
|
204
|
+
return { ok: false, status: 403, error: `"${key}" is not editable` };
|
|
205
|
+
}
|
|
206
|
+
// Reject if the key is currently pinned (env-locked) by env/file.
|
|
207
|
+
const resolved = await resolveConfig(getStorage());
|
|
208
|
+
const setting = resolved.settings.find((s) => s.key === key);
|
|
209
|
+
if (setting?.envLocked) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
status: 409,
|
|
213
|
+
error: `"${key}" is pinned via ${setting.source} and cannot be changed here`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const storage = getStorage();
|
|
217
|
+
if (!storage) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
status: 409,
|
|
221
|
+
error: "No active storage; cannot persist config",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { ok: true, key: key, storage };
|
|
225
|
+
}
|
|
114
226
|
/** Best-effort actor attribution for audit rows (no auth in dev → "dev"). */
|
|
115
227
|
function actorOf(req) {
|
|
116
228
|
const header = req.header("x-enpilink-actor");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.js","sourceRoot":"","sources":["../../../src/server/config/router.ts"],"names":[],"mappings":"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","sourcesContent":["import express, { type Router } from \"express\";\nimport { getActiveStorage } from \"../log-sink.js\";\nimport type { ConfigAuditEntry, StorageAdapter } from \"../storage/types.js\";\nimport { resolveConfig, validateRuntimeWrite } from \"./resolve.js\";\nimport {\n isBootstrapKey,\n isKnownKey,\n isRuntimeKey,\n isSecretKey,\n} from \"./schema.js\";\n\n/**\n * Config admin API (M4). Pure core — reads the SAME active\n * {@link StorageAdapter} the analytics middleware writes to, via\n * {@link getActiveStorage}. Does NOT depend on `@enpilink/console`.\n *\n * Mounted dev-only (under the `NODE_ENV !== \"production\"` block in\n * `express.ts`) at `/__enpilink/config`. Prod admin mounting (behind bearer\n * auth) is M5.\n *\n * Routes:\n * - `GET /__enpilink/config` — all settings (value-or-masked + source +\n * secret/envLocked flags).\n * - `PUT /__enpilink/config/:key` — set a RUNTIME, non-secret key. Rejects\n * bootstrap / secret / env-locked / unknown keys with a clear 4xx.\n * - `GET /__enpilink/config/audit` — recent config-change history.\n *\n * Disabled-safe: when there is no active storage, reads fall back to env/file/\n * defaults (runtime keys show their defaults) and NEVER 500. Writes require a\n * storage adapter (409 when none) — there is nowhere to persist otherwise.\n */\nexport function createConfigRouter(\n getStorage: () => StorageAdapter | null = getActiveStorage,\n): Router {\n const router = express.Router();\n const base = \"/__enpilink/config\";\n\n // GET /config — full resolved settings. Secrets masked; never 500.\n router.get(base, async (_req, res) => {\n try {\n const resolved = await resolveConfig(getStorage());\n res.json({ settings: resolved.settings });\n } catch {\n // Last-resort: resolve with no storage so reads never fail.\n const resolved = await resolveConfig(null);\n res.json({ settings: resolved.settings });\n }\n });\n\n // GET /config/audit — change history (most recent first). Never 500.\n router.get(`${base}/audit`, async (_req, res) => {\n const storage = getStorage();\n if (!storage) {\n res.json({ enabled: false, audit: [] as ConfigAuditEntry[] });\n return;\n }\n try {\n const audit = await storage.getConfigAudit();\n res.json({ enabled: true, audit });\n } catch {\n res.json({ enabled: false, audit: [] as ConfigAuditEntry[] });\n }\n });\n\n // PUT /config/:key — set a runtime, non-secret key. Reject everything else.\n router.put(`${base}/:key`, async (req, res) => {\n const key = req.params.key;\n\n if (!isKnownKey(key)) {\n res.status(404).json({ error: `Unknown config key \"${key}\"` });\n return;\n }\n if (isSecretKey(key)) {\n res.status(403).json({\n error: `\"${key}\" is a secret and is set via environment only`,\n });\n return;\n }\n if (isBootstrapKey(key)) {\n res.status(403).json({\n error: `\"${key}\" is a bootstrap setting (env/file only) and is read-only here`,\n });\n return;\n }\n if (!isRuntimeKey(key)) {\n res.status(403).json({ error: `\"${key}\" is not editable` });\n return;\n }\n\n // Reject if this runtime key is currently pinned (env-locked) by env/file.\n const resolved = await resolveConfig(getStorage());\n const setting = resolved.settings.find((s) => s.key === key);\n if (setting?.envLocked) {\n res.status(409).json({\n error: `\"${key}\" is pinned via ${setting.source} and cannot be changed here`,\n });\n return;\n }\n\n const body = req.body as { value?: unknown } | undefined;\n const rawValue = body?.value;\n const check = validateRuntimeWrite(key, rawValue);\n if (!check.ok) {\n res.status(400).json({ error: check.error });\n return;\n }\n\n const storage = getStorage();\n if (!storage) {\n res.status(409).json({\n error: \"No active storage; cannot persist runtime config\",\n });\n return;\n }\n\n try {\n const actor = actorOf(req);\n await storage.setConfig(key, check.value, actor);\n res.json({ ok: true, key, value: check.value });\n } catch (err) {\n res.status(500).json({\n error: err instanceof Error ? err.message : \"Failed to write config\",\n });\n }\n });\n\n return router;\n}\n\n/** Best-effort actor attribution for audit rows (no auth in dev → \"dev\"). */\nfunction actorOf(req: express.Request): string {\n const header = req.header(\"x-enpilink-actor\");\n return typeof header === \"string\" && header.length > 0 ? header : \"dev\";\n}\n"]}
|
|
1
|
+
{"version":3,"file":"router.js","sourceRoot":"","sources":["../../../src/server/config/router.ts"],"names":[],"mappings":"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","sourcesContent":["import express, { type Router } from \"express\";\nimport { refreshCaptureGate } from \"../capture-gate.js\";\nimport { getActiveStorage } from \"../log-sink.js\";\nimport type { ConfigAuditEntry, StorageAdapter } from \"../storage/types.js\";\nimport { getPreset, PRESETS } from \"./presets.js\";\nimport {\n type ResolvedSetting,\n resolveConfig,\n validateConfigWrite,\n} from \"./resolve.js\";\nimport {\n type ConfigKey,\n isBootstrapKey,\n isKnownKey,\n isRestartKey,\n isRuntimeKey,\n isSecretKey,\n} from \"./schema.js\";\n\n/**\n * Config admin API. Pure core — reads the SAME active {@link StorageAdapter}\n * the analytics middleware writes to, via {@link getActiveStorage}. Does NOT\n * depend on `@enpilink/console`.\n *\n * Mounted dev-only (unauth, localhost) and in the prod admin plane (behind\n * bearer auth) at `/__enpilink/config`.\n *\n * Routes:\n * - `GET /__enpilink/config` — all settings (rich metadata +\n * source + secret/envLocked/modified/restartRequired flags).\n * - `PUT /__enpilink/config/:key` — set a RUNTIME or RESTART-tier\n * key. Rejects secret / `admin` / env-locked / unknown keys with a clear 4xx.\n * - `DELETE /__enpilink/config/:key` — reset a key to its default\n * (clears the DB override). Same guardrails as PUT.\n * - `GET /__enpilink/config/presets` — list presets + the values each\n * would set.\n * - `POST /__enpilink/config/preset/:name` — apply a preset (validate +\n * persist + audit each runtime key; skip env-locked).\n * - `GET /__enpilink/config/audit` — recent config-change history.\n *\n * Disabled-safe: with no active storage, reads fall back to env/file/defaults\n * and NEVER 500. Writes require a storage adapter (409 when none).\n *\n * SECURITY: `admin`, `adminAuthToken`, unknown keys, and env-locked keys can\n * NEVER be written here. `adminAuthToken` is never persisted nor returned in\n * plaintext.\n */\nexport function createConfigRouter(\n getStorage: () => StorageAdapter | null = getActiveStorage,\n): Router {\n const router = express.Router();\n const base = \"/__enpilink/config\";\n\n // GET /config — full resolved settings. Secrets masked; never 500.\n router.get(base, async (_req, res) => {\n try {\n const resolved = await resolveConfig(getStorage());\n res.json({ settings: resolved.settings });\n } catch {\n const resolved = await resolveConfig(null);\n res.json({ settings: resolved.settings });\n }\n });\n\n // GET /config/presets — list presets + the values each would set.\n router.get(`${base}/presets`, (_req, res) => {\n res.json({ presets: Object.values(PRESETS) });\n });\n\n // GET /config/audit — change history (most recent first). Never 500.\n router.get(`${base}/audit`, async (_req, res) => {\n const storage = getStorage();\n if (!storage) {\n res.json({ enabled: false, audit: [] as ConfigAuditEntry[] });\n return;\n }\n try {\n const audit = await storage.getConfigAudit();\n res.json({ enabled: true, audit });\n } catch {\n res.json({ enabled: false, audit: [] as ConfigAuditEntry[] });\n }\n });\n\n // POST /config/preset/:name — apply a runtime-only preset.\n router.post(`${base}/preset/:name`, async (req, res) => {\n const preset = getPreset(req.params.name);\n if (!preset) {\n res.status(404).json({ error: `Unknown preset \"${req.params.name}\"` });\n return;\n }\n const storage = getStorage();\n if (!storage) {\n res.status(409).json({\n error: \"No active storage; cannot apply preset\",\n });\n return;\n }\n\n const resolved = await resolveConfig(storage);\n const byKey = new Map(resolved.settings.map((s) => [s.key, s]));\n const actor = actorOf(req);\n\n const applied: { key: string; value: unknown }[] = [];\n const skipped: { key: string; reason: string }[] = [];\n\n for (const [key, value] of Object.entries(preset.values)) {\n // Presets only ever touch runtime keys; double-check the guardrail.\n if (!isRuntimeKey(key)) {\n skipped.push({ key, reason: \"not a runtime key\" });\n continue;\n }\n const setting = byKey.get(key as ConfigKey);\n if (setting?.envLocked) {\n skipped.push({ key, reason: `pinned via ${setting.source}` });\n continue;\n }\n const check = validateConfigWrite(key, value);\n if (!check.ok) {\n skipped.push({ key, reason: check.error });\n continue;\n }\n try {\n await storage.setConfig(key, check.value, actor);\n applied.push({ key, value: check.value });\n } catch (err) {\n skipped.push({\n key,\n reason: err instanceof Error ? err.message : \"write failed\",\n });\n }\n }\n\n // A preset may have changed analytics.enabled/sampleRate — refresh the live\n // capture gate so it takes effect without a restart.\n await refreshCaptureGate();\n res.json({ ok: true, preset: preset.name, applied, skipped });\n });\n\n // PUT /config/:key — set a runtime or restart-tier key.\n router.put(`${base}/:key`, async (req, res) => {\n const guard = await writeGuard(req.params.key, getStorage);\n if (!guard.ok) {\n res.status(guard.status).json({ error: guard.error });\n return;\n }\n const key = guard.key;\n\n const body = req.body as { value?: unknown } | undefined;\n const check = validateConfigWrite(key, body?.value);\n if (!check.ok) {\n res.status(400).json({ error: check.error });\n return;\n }\n\n try {\n await guard.storage.setConfig(key, check.value, actorOf(req));\n // Refresh the live capture gate so a toggle of analytics.enabled /\n // analytics.sampleRate takes effect immediately (no restart).\n await refreshCaptureGate();\n res.json({\n ok: true,\n key,\n value: check.value,\n restartRequired: isRestartKey(key),\n });\n } catch (err) {\n res.status(500).json({\n error: err instanceof Error ? err.message : \"Failed to write config\",\n });\n }\n });\n\n // DELETE /config/:key — reset to default (clear the DB override).\n router.delete(`${base}/:key`, async (req, res) => {\n const guard = await writeGuard(req.params.key, getStorage);\n if (!guard.ok) {\n res.status(guard.status).json({ error: guard.error });\n return;\n }\n try {\n await guard.storage.clearConfig(guard.key, actorOf(req));\n // Resetting analytics.enabled / sampleRate to default also re-gates\n // capture live.\n await refreshCaptureGate();\n res.json({\n ok: true,\n key: guard.key,\n reset: true,\n restartRequired: isRestartKey(guard.key),\n });\n } catch (err) {\n res.status(500).json({\n error: err instanceof Error ? err.message : \"Failed to reset config\",\n });\n }\n });\n\n return router;\n}\n\ntype WriteGuardResult =\n | { ok: true; key: ConfigKey; storage: StorageAdapter }\n | { ok: false; status: number; error: string };\n\n/**\n * Shared guardrail for PUT + DELETE. Rejects unknown / secret / `admin` /\n * env-locked keys and requires an active storage adapter. Only runtime and\n * non-env-locked restart-tier keys pass.\n */\nasync function writeGuard(\n key: string,\n getStorage: () => StorageAdapter | null,\n): Promise<WriteGuardResult> {\n if (!isKnownKey(key)) {\n return { ok: false, status: 404, error: `Unknown config key \"${key}\"` };\n }\n if (isSecretKey(key)) {\n return {\n ok: false,\n status: 403,\n error: `\"${key}\" is a secret and is set via environment only`,\n };\n }\n // Bootstrap keys are writable ONLY if they are restart-tier (port/storage/\n // dbPath). `admin` (and any other bootstrap key) is env-only / read-only.\n if (isBootstrapKey(key) && !isRestartKey(key)) {\n return {\n ok: false,\n status: 403,\n error: `\"${key}\" is environment-only and is read-only here`,\n };\n }\n if (!isRuntimeKey(key) && !isRestartKey(key)) {\n return { ok: false, status: 403, error: `\"${key}\" is not editable` };\n }\n\n // Reject if the key is currently pinned (env-locked) by env/file.\n const resolved = await resolveConfig(getStorage());\n const setting = resolved.settings.find((s: ResolvedSetting) => s.key === key);\n if (setting?.envLocked) {\n return {\n ok: false,\n status: 409,\n error: `\"${key}\" is pinned via ${setting.source} and cannot be changed here`,\n };\n }\n\n const storage = getStorage();\n if (!storage) {\n return {\n ok: false,\n status: 409,\n error: \"No active storage; cannot persist config\",\n };\n }\n return { ok: true, key: key as ConfigKey, storage };\n}\n\n/** Best-effort actor attribution for audit rows (no auth in dev → \"dev\"). */\nfunction actorOf(req: express.Request): string {\n const header = req.header(\"x-enpilink-actor\");\n return typeof header === \"string\" && header.length > 0 ? header : \"dev\";\n}\n"]}
|
|
@@ -43,7 +43,16 @@ export type Config = z.infer<typeof configSchema>;
|
|
|
43
43
|
export type ConfigKey = keyof Config;
|
|
44
44
|
export type BootstrapKey = keyof BootstrapConfig;
|
|
45
45
|
export type RuntimeKey = keyof RuntimeConfig;
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Editability classification, surfaced to the admin UI:
|
|
48
|
+
* - `runtime` — editable live; takes effect immediately.
|
|
49
|
+
* - `restart` — DB-editable but only takes effect after a process restart
|
|
50
|
+
* (the non-secret bootstrap keys `port`/`storage`/`dbPath`).
|
|
51
|
+
* - `readonly` — env-only; never web-editable (the `admin` gate + the
|
|
52
|
+
* `adminAuthToken` secret).
|
|
53
|
+
*/
|
|
54
|
+
export type Editable = "runtime" | "restart" | "readonly";
|
|
55
|
+
/** Per-key metadata describing tier / secret / env-lock + UI presentation. */
|
|
47
56
|
export interface KeyMeta {
|
|
48
57
|
key: ConfigKey;
|
|
49
58
|
/** `bootstrap` (env/file only) or `runtime` (DB-editable). */
|
|
@@ -52,6 +61,18 @@ export interface KeyMeta {
|
|
|
52
61
|
secret: boolean;
|
|
53
62
|
/** The env var that drives this key (for the "set via env" hint). */
|
|
54
63
|
env: string;
|
|
64
|
+
/** Human-friendly label (primary heading in the UI). */
|
|
65
|
+
label: string;
|
|
66
|
+
/** One-line plain-language description of what the setting does. */
|
|
67
|
+
description: string;
|
|
68
|
+
/** Functional category used to group settings in the UI. */
|
|
69
|
+
group: string;
|
|
70
|
+
/** Optional unit hint (e.g. `"ms"`, `"events"`, `"0–1 ratio"`). */
|
|
71
|
+
unit?: string;
|
|
72
|
+
/** The schema default value for this key. */
|
|
73
|
+
default: unknown;
|
|
74
|
+
/** How this key may be edited from the admin UI. */
|
|
75
|
+
editable: Editable;
|
|
55
76
|
}
|
|
56
77
|
/** Bootstrap keys (env/file only). */
|
|
57
78
|
export declare const BOOTSTRAP_KEYS: readonly ["storage", "dbPath", "port", "admin", "adminAuthToken"];
|
|
@@ -59,6 +80,12 @@ export declare const BOOTSTRAP_KEYS: readonly ["storage", "dbPath", "port", "adm
|
|
|
59
80
|
export declare const RUNTIME_KEYS: readonly ["analytics.enabled", "analytics.sampleRate", "retention.events", "retention.logs", "flags.liveLogs", "display.bucketMs"];
|
|
60
81
|
/** Secret keys: env-only, masked + never persisted/returned in plaintext. */
|
|
61
82
|
export declare const SECRET_KEYS: readonly ["adminAuthToken"];
|
|
83
|
+
/**
|
|
84
|
+
* Restart-tier keys: non-secret bootstrap keys that ARE DB-editable but only
|
|
85
|
+
* take effect after a process restart. Resolution still honours env>file>db so
|
|
86
|
+
* an env/file pin locks them (read-only).
|
|
87
|
+
*/
|
|
88
|
+
export declare const RESTART_KEYS: readonly ["port", "storage", "dbPath"];
|
|
62
89
|
/**
|
|
63
90
|
* Env var mapping per key. Bootstrap keys map to dedicated env vars that match
|
|
64
91
|
* the framework's existing env surface; runtime keys use an
|
|
@@ -70,6 +97,17 @@ export declare function isSecretKey(key: string): boolean;
|
|
|
70
97
|
export declare function isRuntimeKey(key: string): key is RuntimeKey;
|
|
71
98
|
export declare function isBootstrapKey(key: string): key is BootstrapKey;
|
|
72
99
|
export declare function isKnownKey(key: string): key is ConfigKey;
|
|
100
|
+
/** A restart-tier key: DB-editable but only effective after a restart. */
|
|
101
|
+
export declare function isRestartKey(key: string): key is BootstrapKey;
|
|
102
|
+
/**
|
|
103
|
+
* The editability classification for a key:
|
|
104
|
+
* - secret/admin → `readonly` (env-only, never web-editable)
|
|
105
|
+
* - other bootstrap keys (port/storage/dbPath) → `restart`
|
|
106
|
+
* - runtime keys → `runtime`
|
|
107
|
+
*/
|
|
108
|
+
export declare function editableOf(key: ConfigKey): Editable;
|
|
109
|
+
/** The schema default value for a key (undefined for optional secrets). */
|
|
110
|
+
export declare function defaultForKey(key: ConfigKey): unknown;
|
|
73
111
|
/** Metadata for every known key. */
|
|
74
112
|
export declare function keyMeta(key: ConfigKey): KeyMeta;
|
|
75
113
|
/** All keys with metadata. */
|
|
@@ -83,6 +83,84 @@ export const runtimeSchema = z.object({
|
|
|
83
83
|
"display.bucketMs": z.number().int().positive().default(60_000),
|
|
84
84
|
});
|
|
85
85
|
export const configSchema = bootstrapSchema.merge(runtimeSchema);
|
|
86
|
+
const KEY_DESCRIPTORS = {
|
|
87
|
+
// --- Server (restart-tier bootstrap) ---
|
|
88
|
+
port: {
|
|
89
|
+
label: "Server port",
|
|
90
|
+
description: "The network port the server listens on. Changing this needs a restart to take effect.",
|
|
91
|
+
group: "Server",
|
|
92
|
+
editable: "restart",
|
|
93
|
+
},
|
|
94
|
+
storage: {
|
|
95
|
+
label: "Storage engine",
|
|
96
|
+
description: "Where analytics, logs, and settings are persisted: in-memory (resets on restart), sqlite (a local file), or postgres. Takes effect after a restart.",
|
|
97
|
+
group: "Storage",
|
|
98
|
+
editable: "restart",
|
|
99
|
+
},
|
|
100
|
+
dbPath: {
|
|
101
|
+
label: "SQLite database file",
|
|
102
|
+
description: "Path to the SQLite database file (only used when the storage engine is sqlite). Takes effect after a restart.",
|
|
103
|
+
group: "Storage",
|
|
104
|
+
editable: "restart",
|
|
105
|
+
},
|
|
106
|
+
// --- Security (read-only, env-only) ---
|
|
107
|
+
admin: {
|
|
108
|
+
label: "Production admin plane",
|
|
109
|
+
description: "Enables the admin dashboard in production. For safety this can only be turned on via environment variable, never from the web UI.",
|
|
110
|
+
group: "Security",
|
|
111
|
+
editable: "readonly",
|
|
112
|
+
},
|
|
113
|
+
adminAuthToken: {
|
|
114
|
+
label: "Admin auth token",
|
|
115
|
+
description: "Secret bearer token guarding the production admin plane. Set via environment only; never stored or shown in plaintext.",
|
|
116
|
+
group: "Security",
|
|
117
|
+
editable: "readonly",
|
|
118
|
+
},
|
|
119
|
+
// --- Analytics (runtime) ---
|
|
120
|
+
"analytics.enabled": {
|
|
121
|
+
label: "Analytics enabled",
|
|
122
|
+
description: "Record tool-call events and server logs so the dashboard can show usage and latency.",
|
|
123
|
+
group: "Analytics",
|
|
124
|
+
editable: "runtime",
|
|
125
|
+
},
|
|
126
|
+
"analytics.sampleRate": {
|
|
127
|
+
label: "Sampling rate",
|
|
128
|
+
description: "Fraction of requests to record. 1 records everything; lower values reduce overhead and storage on busy servers.",
|
|
129
|
+
group: "Analytics",
|
|
130
|
+
unit: "0–1 ratio",
|
|
131
|
+
editable: "runtime",
|
|
132
|
+
},
|
|
133
|
+
// --- Retention (runtime) ---
|
|
134
|
+
"retention.events": {
|
|
135
|
+
label: "Event retention",
|
|
136
|
+
description: "Maximum number of tool-call events kept. Oldest events are dropped once the cap is reached.",
|
|
137
|
+
group: "Retention",
|
|
138
|
+
unit: "events",
|
|
139
|
+
editable: "runtime",
|
|
140
|
+
},
|
|
141
|
+
"retention.logs": {
|
|
142
|
+
label: "Log retention",
|
|
143
|
+
description: "Maximum number of captured log lines kept. Oldest logs are dropped once the cap is reached.",
|
|
144
|
+
group: "Retention",
|
|
145
|
+
unit: "logs",
|
|
146
|
+
editable: "runtime",
|
|
147
|
+
},
|
|
148
|
+
// --- Features (runtime) ---
|
|
149
|
+
"flags.liveLogs": {
|
|
150
|
+
label: "Live log stream",
|
|
151
|
+
description: "Show the real-time server log stream in the dashboard's live-logs panel.",
|
|
152
|
+
group: "Features",
|
|
153
|
+
editable: "runtime",
|
|
154
|
+
},
|
|
155
|
+
// --- Display (runtime) ---
|
|
156
|
+
"display.bucketMs": {
|
|
157
|
+
label: "Chart time bucket",
|
|
158
|
+
description: "Default width of each time bucket in the dashboard's volume/latency charts.",
|
|
159
|
+
group: "Display",
|
|
160
|
+
unit: "ms",
|
|
161
|
+
editable: "runtime",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
86
164
|
/** Bootstrap keys (env/file only). */
|
|
87
165
|
export const BOOTSTRAP_KEYS = [
|
|
88
166
|
"storage",
|
|
@@ -104,6 +182,16 @@ export const RUNTIME_KEYS = [
|
|
|
104
182
|
export const SECRET_KEYS = [
|
|
105
183
|
"adminAuthToken",
|
|
106
184
|
];
|
|
185
|
+
/**
|
|
186
|
+
* Restart-tier keys: non-secret bootstrap keys that ARE DB-editable but only
|
|
187
|
+
* take effect after a process restart. Resolution still honours env>file>db so
|
|
188
|
+
* an env/file pin locks them (read-only).
|
|
189
|
+
*/
|
|
190
|
+
export const RESTART_KEYS = [
|
|
191
|
+
"port",
|
|
192
|
+
"storage",
|
|
193
|
+
"dbPath",
|
|
194
|
+
];
|
|
107
195
|
/**
|
|
108
196
|
* Env var mapping per key. Bootstrap keys map to dedicated env vars that match
|
|
109
197
|
* the framework's existing env surface; runtime keys use an
|
|
@@ -126,6 +214,7 @@ export const ENV_VARS = {
|
|
|
126
214
|
const SECRET_SET = new Set(SECRET_KEYS);
|
|
127
215
|
const RUNTIME_SET = new Set(RUNTIME_KEYS);
|
|
128
216
|
const BOOTSTRAP_SET = new Set(BOOTSTRAP_KEYS);
|
|
217
|
+
const RESTART_SET = new Set(RESTART_KEYS);
|
|
129
218
|
export function isSecretKey(key) {
|
|
130
219
|
return SECRET_SET.has(key);
|
|
131
220
|
}
|
|
@@ -138,13 +227,45 @@ export function isBootstrapKey(key) {
|
|
|
138
227
|
export function isKnownKey(key) {
|
|
139
228
|
return RUNTIME_SET.has(key) || BOOTSTRAP_SET.has(key);
|
|
140
229
|
}
|
|
230
|
+
/** A restart-tier key: DB-editable but only effective after a restart. */
|
|
231
|
+
export function isRestartKey(key) {
|
|
232
|
+
return RESTART_SET.has(key);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The editability classification for a key:
|
|
236
|
+
* - secret/admin → `readonly` (env-only, never web-editable)
|
|
237
|
+
* - other bootstrap keys (port/storage/dbPath) → `restart`
|
|
238
|
+
* - runtime keys → `runtime`
|
|
239
|
+
*/
|
|
240
|
+
export function editableOf(key) {
|
|
241
|
+
if (isRestartKey(key)) {
|
|
242
|
+
return "restart";
|
|
243
|
+
}
|
|
244
|
+
if (isBootstrapKey(key)) {
|
|
245
|
+
return "readonly";
|
|
246
|
+
}
|
|
247
|
+
return "runtime";
|
|
248
|
+
}
|
|
249
|
+
/** Default-typed sample used to read each key's schema default. */
|
|
250
|
+
const DEFAULTS = configSchema.parse({});
|
|
251
|
+
/** The schema default value for a key (undefined for optional secrets). */
|
|
252
|
+
export function defaultForKey(key) {
|
|
253
|
+
return DEFAULTS[key];
|
|
254
|
+
}
|
|
141
255
|
/** Metadata for every known key. */
|
|
142
256
|
export function keyMeta(key) {
|
|
257
|
+
const d = KEY_DESCRIPTORS[key];
|
|
143
258
|
return {
|
|
144
259
|
key,
|
|
145
260
|
tier: isBootstrapKey(key) ? "bootstrap" : "runtime",
|
|
146
261
|
secret: isSecretKey(key),
|
|
147
262
|
env: ENV_VARS[key],
|
|
263
|
+
label: d.label,
|
|
264
|
+
description: d.description,
|
|
265
|
+
group: d.group,
|
|
266
|
+
unit: d.unit,
|
|
267
|
+
default: defaultForKey(key),
|
|
268
|
+
editable: d.editable,
|
|
148
269
|
};
|
|
149
270
|
}
|
|
150
271
|
/** All keys with metadata. */
|