enpilink 1.0.3 → 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.
@@ -1,18 +1,30 @@
1
+ import { type CaptureGate } from "./capture-gate.js";
1
2
  import type { McpMiddlewareEntry, McpMiddlewareFn } from "./middleware.js";
2
3
  import { type OtelSink } from "./otel.js";
3
4
  import type { StorageAdapter } from "./storage/types.js";
4
5
  /**
5
- * Analytics + log capture (M2). Opt-in, env-gated, zero overhead when off.
6
+ * Analytics + log capture (M2) now with a LIVE runtime toggle (bugfix).
6
7
  *
7
- * When enabled, a single {@link StorageAdapter} is resolved + `init()`ed at
8
- * server startup and shared in-process: the analytics middleware writes
9
- * `tool_call` events to it, the log sink mirrors server logs to it, and (M3)
10
- * the observability API reads from the very same instance via the
11
- * `server.storage` getter or {@link getActiveStorage}.
8
+ * A single {@link StorageAdapter} is shared in-process: the analytics
9
+ * middleware writes `tool_call` events to it, the log sink mirrors server logs
10
+ * to it, and the observability/config APIs read from the very same instance via
11
+ * the `server.storage` getter or {@link getActiveStorage}.
12
12
  *
13
- * Gating: OFF unless `ENPILINK_ANALYTICS` is `1` or `true` (case-insensitive).
14
- * When OFF, no adapter is resolved or initialized (so no `enpilink.db` is
15
- * created), no middleware is registered, and there is zero network activity.
13
+ * Storage activation (so the Configuration + observability UI always has a
14
+ * backing store) vs. capture gating (whether tool calls are recorded) are now
15
+ * DECOUPLED:
16
+ *
17
+ * - **Storage** is activated whenever the admin/config UI is reachable: always
18
+ * in dev (default `memory`, honoring `ENPILINK_STORAGE`/`ENPILINK_DB_PATH`),
19
+ * and in prod-admin (handled separately in `admin.ts`). It reuses any already
20
+ * active adapter — never double-inits.
21
+ * - **Capture** is governed at runtime by the resolved `analytics.enabled`
22
+ * config value (env > file > db > default) via the {@link CaptureGate}, so
23
+ * toggling it in the UI takes effect WITHOUT a restart. `ENPILINK_ANALYTICS`
24
+ * still works as an env override (env > db).
25
+ *
26
+ * The default `memory` adapter creates NO `enpilink.db` file and does zero
27
+ * network; OTel stays independently gated and off by default.
16
28
  */
17
29
  /** Options for {@link installAnalytics}. */
18
30
  export interface InstallAnalyticsOptions {
@@ -33,25 +45,53 @@ export declare function analyticsEnabled(): boolean;
33
45
  * NEVER touches disk and is never on by default.
34
46
  */
35
47
  export declare function mockEnabled(): boolean;
48
+ /** Options controlling the live capture gate (injectable for tests). */
49
+ export interface AnalyticsMiddlewareOptions {
50
+ /** Read the live capture gate. Defaults to the shared {@link getCaptureGate}. */
51
+ gate?: () => CaptureGate;
52
+ /** RNG for sampling `[0, 1)`. Defaults to `Math.random`. */
53
+ rng?: () => number;
54
+ }
36
55
  /**
37
56
  * Build the analytics middleware entry. Times each request around `next()`,
38
- * records a `tool_call`-typed event (capturing the tool name for `tools/call`),
39
- * and ALWAYS swallows storage errors so a storage failure can never break or
40
- * slow a tool call. Recording is fire-and-forget (non-blocking).
57
+ * and ONLY when the live capture gate says so — records a `tool_call`-typed
58
+ * event (capturing the tool name for `tools/call`). Always swallows storage
59
+ * errors so a storage failure can never break or slow a tool call. Recording is
60
+ * fire-and-forget (non-blocking).
61
+ *
62
+ * The gate is a cheap, synchronous in-memory snapshot of the resolved
63
+ * `analytics.enabled` / `analytics.sampleRate` config (see `capture-gate.ts`),
64
+ * so toggling analytics in the UI takes effect live without a restart and the
65
+ * hot path does no DB read. `next()` is ALWAYS awaited so the tool call runs
66
+ * identically whether capture is on or off; only the record/export at the end is
67
+ * gated.
41
68
  */
42
- export declare function createAnalyticsMiddleware(storage: StorageAdapter, now?: () => number, otel?: OtelSink | null): McpMiddlewareFn;
69
+ export declare function createAnalyticsMiddleware(storage: StorageAdapter, now?: () => number, otel?: OtelSink | null, opts?: AnalyticsMiddlewareOptions): McpMiddlewareFn;
43
70
  /**
44
- * Install analytics on a server, ONLY when enabled (`ENPILINK_ANALYTICS`).
71
+ * Install analytics on a server.
45
72
  *
46
- * When enabled: resolves a {@link StorageAdapter} via `resolveStorageAdapter()`
47
- * (`ENPILINK_STORAGE` / `ENPILINK_DB_PATH`), `init()`s it, registers it as the
48
- * active storage for the log sink + `getActiveStorage()`, and returns the
49
- * built analytics middleware entry to splice into the chain.
73
+ * STORAGE activation (decoupled from capture):
74
+ * - Reuse any already-active adapter ({@link getActiveStorage}) never
75
+ * double-init.
76
+ * - In `--mock` mode, force a fresh in-memory adapter and seed it.
77
+ * - Otherwise, in DEV activate the configured adapter (default `memory`,
78
+ * honoring `ENPILINK_STORAGE` / `ENPILINK_DB_PATH`) so the Configuration +
79
+ * observability UI always has a backing store — removing the "no active
80
+ * storage" 409 on the first config write. `memory` creates NO file and does
81
+ * zero network.
82
+ * - In PRODUCTION WITHOUT mock, activate NOTHING here (the config UI isn't
83
+ * reachable; prod-admin activates its own store in `admin.ts`). This preserves
84
+ * the "no db / no network when off in prod" guarantee.
50
85
  *
51
- * When disabled: resolves/initializes NOTHING and returns `null` zero
52
- * overhead, zero network, no `enpilink.db` created.
86
+ * CAPTURE gating: the returned middleware records events only when the live
87
+ * {@link CaptureGate} (resolved `analytics.enabled`/`analytics.sampleRate`, env
88
+ * > file > db > default) allows it — so the toggle is live (no restart). The
89
+ * gate is resolved once here and refreshed on config writes by the router. In
90
+ * `--mock` mode capture is force-enabled for the session.
53
91
  *
54
- * @returns the active storage + middleware entry, or `null` when disabled.
92
+ * @returns the active storage + middleware entry + otel sink, or `null` when no
93
+ * storage was activated (prod without mock/admin) — zero overhead, no file, no
94
+ * network, no middleware.
55
95
  */
56
96
  export declare function installAnalytics(opts?: InstallAnalyticsOptions): Promise<{
57
97
  storage: StorageAdapter;
@@ -1,4 +1,5 @@
1
- import { setActiveStorage } from "./log-sink.js";
1
+ import { getCaptureGate, refreshCaptureGate, setCaptureGate, } from "./capture-gate.js";
2
+ import { getActiveStorage, setActiveStorage } from "./log-sink.js";
2
3
  import { seedMockData } from "./mock-seed.js";
3
4
  import { initOtel } from "./otel.js";
4
5
  import { resolveStorageAdapter } from "./storage/index.js";
@@ -11,6 +12,10 @@ import { MemoryStorageAdapter } from "./storage/memory.js";
11
12
  function resolveSessionStorage(mock) {
12
13
  return mock ? new MemoryStorageAdapter() : resolveStorageAdapter();
13
14
  }
15
+ /** Whether the process is running in production (no dev admin plane). */
16
+ function isProduction() {
17
+ return process.env.NODE_ENV === "production";
18
+ }
14
19
  /** Truthy values that enable analytics via {@link analyticsEnabled}. */
15
20
  const TRUTHY = new Set(["1", "true", "yes", "on"]);
16
21
  /**
@@ -50,11 +55,21 @@ function toolNameOf(method, params) {
50
55
  }
51
56
  /**
52
57
  * Build the analytics middleware entry. Times each request around `next()`,
53
- * records a `tool_call`-typed event (capturing the tool name for `tools/call`),
54
- * and ALWAYS swallows storage errors so a storage failure can never break or
55
- * slow a tool call. Recording is fire-and-forget (non-blocking).
58
+ * and ONLY when the live capture gate says so — records a `tool_call`-typed
59
+ * event (capturing the tool name for `tools/call`). Always swallows storage
60
+ * errors so a storage failure can never break or slow a tool call. Recording is
61
+ * fire-and-forget (non-blocking).
62
+ *
63
+ * The gate is a cheap, synchronous in-memory snapshot of the resolved
64
+ * `analytics.enabled` / `analytics.sampleRate` config (see `capture-gate.ts`),
65
+ * so toggling analytics in the UI takes effect live without a restart and the
66
+ * hot path does no DB read. `next()` is ALWAYS awaited so the tool call runs
67
+ * identically whether capture is on or off; only the record/export at the end is
68
+ * gated.
56
69
  */
57
- export function createAnalyticsMiddleware(storage, now = Date.now, otel = null) {
70
+ export function createAnalyticsMiddleware(storage, now = Date.now, otel = null, opts = {}) {
71
+ const readGate = opts.gate ?? getCaptureGate;
72
+ const rng = opts.rng ?? Math.random;
58
73
  return async (request, _extra, next) => {
59
74
  const start = now();
60
75
  const tool = toolNameOf(request.method, request.params);
@@ -77,25 +92,31 @@ export function createAnalyticsMiddleware(storage, now = Date.now, otel = null)
77
92
  throw err;
78
93
  }
79
94
  finally {
80
- const ms = now() - start;
81
- const event = {
82
- ts: start,
83
- type: "tool_call",
84
- tool,
85
- method: request.method,
86
- ms,
87
- ok,
88
- error,
89
- };
90
- // Fire-and-forget; never block or throw into the request path.
91
- void recordSafely(storage, event);
92
- // Optional OTel export — guarded, synchronous, error-swallowing.
93
- if (otel) {
94
- try {
95
- otel.record(event);
96
- }
97
- catch {
98
- // OTel export must never break or slow a tool call.
95
+ // Live gate: skip recording entirely when analytics is off, or when this
96
+ // call falls outside the sample rate. Cheap + synchronous + never throws.
97
+ const { enabled, sampleRate } = readGate();
98
+ const sampled = sampleRate >= 1 || (sampleRate > 0 && rng() < sampleRate);
99
+ if (enabled && sampled) {
100
+ const ms = now() - start;
101
+ const event = {
102
+ ts: start,
103
+ type: "tool_call",
104
+ tool,
105
+ method: request.method,
106
+ ms,
107
+ ok,
108
+ error,
109
+ };
110
+ // Fire-and-forget; never block or throw into the request path.
111
+ void recordSafely(storage, event);
112
+ // Optional OTel export — guarded, synchronous, error-swallowing.
113
+ if (otel) {
114
+ try {
115
+ otel.record(event);
116
+ }
117
+ catch {
118
+ // OTel export must never break or slow a tool call.
119
+ }
99
120
  }
100
121
  }
101
122
  }
@@ -111,40 +132,65 @@ async function recordSafely(storage, event) {
111
132
  }
112
133
  }
113
134
  /**
114
- * Install analytics on a server, ONLY when enabled (`ENPILINK_ANALYTICS`).
135
+ * Install analytics on a server.
115
136
  *
116
- * When enabled: resolves a {@link StorageAdapter} via `resolveStorageAdapter()`
117
- * (`ENPILINK_STORAGE` / `ENPILINK_DB_PATH`), `init()`s it, registers it as the
118
- * active storage for the log sink + `getActiveStorage()`, and returns the
119
- * built analytics middleware entry to splice into the chain.
137
+ * STORAGE activation (decoupled from capture):
138
+ * - Reuse any already-active adapter ({@link getActiveStorage}) never
139
+ * double-init.
140
+ * - In `--mock` mode, force a fresh in-memory adapter and seed it.
141
+ * - Otherwise, in DEV activate the configured adapter (default `memory`,
142
+ * honoring `ENPILINK_STORAGE` / `ENPILINK_DB_PATH`) so the Configuration +
143
+ * observability UI always has a backing store — removing the "no active
144
+ * storage" 409 on the first config write. `memory` creates NO file and does
145
+ * zero network.
146
+ * - In PRODUCTION WITHOUT mock, activate NOTHING here (the config UI isn't
147
+ * reachable; prod-admin activates its own store in `admin.ts`). This preserves
148
+ * the "no db / no network when off in prod" guarantee.
120
149
  *
121
- * When disabled: resolves/initializes NOTHING and returns `null` zero
122
- * overhead, zero network, no `enpilink.db` created.
150
+ * CAPTURE gating: the returned middleware records events only when the live
151
+ * {@link CaptureGate} (resolved `analytics.enabled`/`analytics.sampleRate`, env
152
+ * > file > db > default) allows it — so the toggle is live (no restart). The
153
+ * gate is resolved once here and refreshed on config writes by the router. In
154
+ * `--mock` mode capture is force-enabled for the session.
123
155
  *
124
- * @returns the active storage + middleware entry, or `null` when disabled.
156
+ * @returns the active storage + middleware entry + otel sink, or `null` when no
157
+ * storage was activated (prod without mock/admin) — zero overhead, no file, no
158
+ * network, no middleware.
125
159
  */
126
160
  export async function installAnalytics(opts = {}) {
127
- // `--mock` (ENPILINK_MOCK) force-enables analytics for the session even when
128
- // the env-based gating is off, so demos work without setting both flags.
161
+ // `--mock` (ENPILINK_MOCK) force-enables capture for the session and uses a
162
+ // throwaway in-memory store so demos work with no real traffic and no disk.
129
163
  const mock = mockEnabled();
130
- if (!mock && !analyticsEnabled()) {
131
- return null;
132
- }
133
- let storage;
134
- try {
135
- storage = resolveSessionStorage(mock);
136
- await storage.init();
137
- }
138
- catch (err) {
139
- // Never let an analytics/storage failure break server startup.
140
- console.error("[enpilink] analytics disabled: storage init failed:", err instanceof Error ? err.message : err);
141
- return null;
164
+ // Reuse an already-active adapter if one exists (e.g. set elsewhere); never
165
+ // double-init. Otherwise decide whether to activate one.
166
+ let storage = getActiveStorage();
167
+ let ownedHere = false;
168
+ if (!storage) {
169
+ // In prod we don't activate storage here UNLESS analytics is explicitly
170
+ // enabled via env (the historical M2 behavior — capture to a resolved
171
+ // store). Otherwise the config UI isn't reachable (prod-admin handles its
172
+ // own store), so activating nothing preserves the "no db / no network when
173
+ // off" guarantee. In dev (or mock) we always activate so the UI works.
174
+ if (!mock && isProduction() && !analyticsEnabled()) {
175
+ return null;
176
+ }
177
+ try {
178
+ storage = resolveSessionStorage(mock);
179
+ await storage.init();
180
+ ownedHere = true;
181
+ }
182
+ catch (err) {
183
+ // Never let a storage failure break server startup.
184
+ console.error("[enpilink] analytics storage init failed:", err instanceof Error ? err.message : err);
185
+ return null;
186
+ }
187
+ setActiveStorage(storage);
142
188
  }
143
- setActiveStorage(storage);
144
- // In `--mock` mode, seed the in-memory storage with a deterministic demo
189
+ // In `--mock` mode, seed the (in-memory) storage with a deterministic demo
145
190
  // dataset so the Dashboard renders full immediately (no real traffic).
146
- // Determinism: a fixed seed + a base timestamp captured once here.
147
- if (mock) {
191
+ // Determinism: a fixed seed + a base timestamp captured once here. Only seed a
192
+ // store we just created here, never an adopted/pre-existing one.
193
+ if (mock && ownedHere) {
148
194
  const now = (opts.now ?? Date.now)();
149
195
  try {
150
196
  await seedMockData(storage, { now });
@@ -153,9 +199,18 @@ export async function installAnalytics(opts = {}) {
153
199
  // A seeding failure must never break server startup.
154
200
  }
155
201
  }
202
+ // Initialize the live capture gate. `--mock` force-enables capture for the
203
+ // session (full sample); otherwise resolve from env/file/db/default so the UI
204
+ // toggle (and any env override) governs capture live.
205
+ if (mock) {
206
+ setCaptureGate({ enabled: true, sampleRate: 1 });
207
+ }
208
+ else {
209
+ await refreshCaptureGate();
210
+ }
156
211
  // Optional OTel export (M6): off by default, zero network/imports when unset.
157
- // Initialized once here and fed by the middleware alongside storage. Mock mode
158
- // never exports (it's dev-only demo data); requires the explicit env opt-in.
212
+ // Mock mode never exports (it's dev-only demo data); requires the explicit env
213
+ // opt-in.
159
214
  const otel = mock ? null : await initOtel();
160
215
  const entry = {
161
216
  // "request" matches every (non-notification) request so non-tool methods
@@ -1 +1 @@
1
- {"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"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","sourcesContent":["import { setActiveStorage } from \"./log-sink.js\";\nimport type { McpMiddlewareEntry, McpMiddlewareFn } from \"./middleware.js\";\nimport { seedMockData } from \"./mock-seed.js\";\nimport { initOtel, type OtelSink } from \"./otel.js\";\nimport { resolveStorageAdapter } from \"./storage/index.js\";\nimport { MemoryStorageAdapter } from \"./storage/memory.js\";\nimport type { StorageAdapter } from \"./storage/types.js\";\n\n/**\n * Analytics + log capture (M2). Opt-in, env-gated, zero overhead when off.\n *\n * When enabled, a single {@link StorageAdapter} is resolved + `init()`ed at\n * server startup and shared in-process: the analytics middleware writes\n * `tool_call` events to it, the log sink mirrors server logs to it, and (M3)\n * the observability API reads from the very same instance via the\n * `server.storage` getter or {@link getActiveStorage}.\n *\n * Gating: OFF unless `ENPILINK_ANALYTICS` is `1` or `true` (case-insensitive).\n * When OFF, no adapter is resolved or initialized (so no `enpilink.db` is\n * created), no middleware is registered, and there is zero network activity.\n */\n\n/** Options for {@link installAnalytics}. */\nexport interface InstallAnalyticsOptions {\n /**\n * Inject a clock for deterministic tests. Defaults to `Date.now`.\n */\n now?: () => number;\n}\n\n/**\n * Resolve a storage adapter for the session. In `--mock` mode we force an\n * in-memory adapter (the demo seed must never touch disk and must vanish on\n * exit); otherwise the configured `ENPILINK_STORAGE` adapter is used.\n */\nfunction resolveSessionStorage(mock: boolean): StorageAdapter {\n return mock ? new MemoryStorageAdapter() : resolveStorageAdapter();\n}\n\n/** Truthy values that enable analytics via {@link analyticsEnabled}. */\nconst TRUTHY = new Set([\"1\", \"true\", \"yes\", \"on\"]);\n\n/**\n * Whether analytics is enabled. OFF by default; enable with\n * `ENPILINK_ANALYTICS=1` (also accepts `true`/`yes`/`on`, case-insensitive).\n */\nexport function analyticsEnabled(): boolean {\n const raw = process.env.ENPILINK_ANALYTICS;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Whether the `--mock` demo seed is enabled (`ENPILINK_MOCK`=1/true/yes/on).\n * Mock mode is opt-in only and IMPLIES analytics-on + in-memory storage for the\n * session, so the Dashboard renders full demo data with NO real traffic. It\n * NEVER touches disk and is never on by default.\n */\nexport function mockEnabled(): boolean {\n // The demo seed is DEV-ONLY: it must never seed a real deployment. In\n // production `ENPILINK_MOCK` is ignored entirely (read the literal so the\n // guard survives DCE and is unambiguous).\n if (process.env.NODE_ENV === \"production\") {\n return false;\n }\n const raw = process.env.ENPILINK_MOCK;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Extract the tool name from a `tools/call` request's params. Returns\n * `undefined` for non-`tools/call` methods or malformed params, never throws.\n */\nfunction toolNameOf(\n method: string,\n params: Record<string, unknown>,\n): string | undefined {\n if (method !== \"tools/call\") {\n return undefined;\n }\n const name = params?.name;\n return typeof name === \"string\" ? name : undefined;\n}\n\n/**\n * Build the analytics middleware entry. Times each request around `next()`,\n * records a `tool_call`-typed event (capturing the tool name for `tools/call`),\n * and ALWAYS swallows storage errors so a storage failure can never break or\n * slow a tool call. Recording is fire-and-forget (non-blocking).\n */\nexport function createAnalyticsMiddleware(\n storage: StorageAdapter,\n now: () => number = Date.now,\n otel: OtelSink | null = null,\n): McpMiddlewareFn {\n return async (request, _extra, next) => {\n const start = now();\n const tool = toolNameOf(request.method, request.params);\n let ok = true;\n let error: string | undefined;\n\n try {\n const result = await next();\n // A tool result with `isError: true` is a soft (handled) failure.\n if (\n result &&\n typeof result === \"object\" &&\n (result as { isError?: unknown }).isError === true\n ) {\n ok = false;\n }\n return result;\n } catch (err) {\n // Record the failure, then rethrow so behavior is unchanged.\n ok = false;\n error = err instanceof Error ? err.message : String(err);\n throw err;\n } finally {\n const ms = now() - start;\n const event = {\n ts: start,\n type: \"tool_call\",\n tool,\n method: request.method,\n ms,\n ok,\n error,\n } as const;\n // Fire-and-forget; never block or throw into the request path.\n void recordSafely(storage, event);\n // Optional OTel export — guarded, synchronous, error-swallowing.\n if (otel) {\n try {\n otel.record(event);\n } catch {\n // OTel export must never break or slow a tool call.\n }\n }\n }\n };\n}\n\n/** Record an event, swallowing any storage error. */\nasync function recordSafely(\n storage: StorageAdapter,\n event: Parameters<StorageAdapter[\"recordEvent\"]>[0],\n): Promise<void> {\n try {\n await storage.recordEvent(event);\n } catch {\n // A storage failure must never break or slow a tool call.\n }\n}\n\n/**\n * Install analytics on a server, ONLY when enabled (`ENPILINK_ANALYTICS`).\n *\n * When enabled: resolves a {@link StorageAdapter} via `resolveStorageAdapter()`\n * (`ENPILINK_STORAGE` / `ENPILINK_DB_PATH`), `init()`s it, registers it as the\n * active storage for the log sink + `getActiveStorage()`, and returns the\n * built analytics middleware entry to splice into the chain.\n *\n * When disabled: resolves/initializes NOTHING and returns `null` — zero\n * overhead, zero network, no `enpilink.db` created.\n *\n * @returns the active storage + middleware entry, or `null` when disabled.\n */\nexport async function installAnalytics(\n opts: InstallAnalyticsOptions = {},\n): Promise<{\n storage: StorageAdapter;\n entry: McpMiddlewareEntry;\n otel: OtelSink | null;\n} | null> {\n // `--mock` (ENPILINK_MOCK) force-enables analytics for the session even when\n // the env-based gating is off, so demos work without setting both flags.\n const mock = mockEnabled();\n if (!mock && !analyticsEnabled()) {\n return null;\n }\n\n let storage: StorageAdapter;\n try {\n storage = resolveSessionStorage(mock);\n await storage.init();\n } catch (err) {\n // Never let an analytics/storage failure break server startup.\n console.error(\n \"[enpilink] analytics disabled: storage init failed:\",\n err instanceof Error ? err.message : err,\n );\n return null;\n }\n\n setActiveStorage(storage);\n\n // In `--mock` mode, seed the in-memory storage with a deterministic demo\n // dataset so the Dashboard renders full immediately (no real traffic).\n // Determinism: a fixed seed + a base timestamp captured once here.\n if (mock) {\n const now = (opts.now ?? Date.now)();\n try {\n await seedMockData(storage, { now });\n } catch {\n // A seeding failure must never break server startup.\n }\n }\n\n // Optional OTel export (M6): off by default, zero network/imports when unset.\n // Initialized once here and fed by the middleware alongside storage. Mock mode\n // never exports (it's dev-only demo data); requires the explicit env opt-in.\n const otel = mock ? null : await initOtel();\n\n const entry: McpMiddlewareEntry = {\n // \"request\" matches every (non-notification) request so non-tool methods\n // are counted too; the handler captures the tool name for tools/call.\n filter: \"request\",\n handler: createAnalyticsMiddleware(storage, opts.now, otel),\n };\n\n return { storage, entry, otel };\n}\n"]}
1
+ {"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n type CaptureGate,\n getCaptureGate,\n refreshCaptureGate,\n setCaptureGate,\n} from \"./capture-gate.js\";\nimport { getActiveStorage, setActiveStorage } from \"./log-sink.js\";\nimport type { McpMiddlewareEntry, McpMiddlewareFn } from \"./middleware.js\";\nimport { seedMockData } from \"./mock-seed.js\";\nimport { initOtel, type OtelSink } from \"./otel.js\";\nimport { resolveStorageAdapter } from \"./storage/index.js\";\nimport { MemoryStorageAdapter } from \"./storage/memory.js\";\nimport type { StorageAdapter } from \"./storage/types.js\";\n\n/**\n * Analytics + log capture (M2) — now with a LIVE runtime toggle (bugfix).\n *\n * A single {@link StorageAdapter} is shared in-process: the analytics\n * middleware writes `tool_call` events to it, the log sink mirrors server logs\n * to it, and the observability/config APIs read from the very same instance via\n * the `server.storage` getter or {@link getActiveStorage}.\n *\n * Storage activation (so the Configuration + observability UI always has a\n * backing store) vs. capture gating (whether tool calls are recorded) are now\n * DECOUPLED:\n *\n * - **Storage** is activated whenever the admin/config UI is reachable: always\n * in dev (default `memory`, honoring `ENPILINK_STORAGE`/`ENPILINK_DB_PATH`),\n * and in prod-admin (handled separately in `admin.ts`). It reuses any already\n * active adapter — never double-inits.\n * - **Capture** is governed at runtime by the resolved `analytics.enabled`\n * config value (env > file > db > default) via the {@link CaptureGate}, so\n * toggling it in the UI takes effect WITHOUT a restart. `ENPILINK_ANALYTICS`\n * still works as an env override (env > db).\n *\n * The default `memory` adapter creates NO `enpilink.db` file and does zero\n * network; OTel stays independently gated and off by default.\n */\n\n/** Options for {@link installAnalytics}. */\nexport interface InstallAnalyticsOptions {\n /**\n * Inject a clock for deterministic tests. Defaults to `Date.now`.\n */\n now?: () => number;\n}\n\n/**\n * Resolve a storage adapter for the session. In `--mock` mode we force an\n * in-memory adapter (the demo seed must never touch disk and must vanish on\n * exit); otherwise the configured `ENPILINK_STORAGE` adapter is used.\n */\nfunction resolveSessionStorage(mock: boolean): StorageAdapter {\n return mock ? new MemoryStorageAdapter() : resolveStorageAdapter();\n}\n\n/** Whether the process is running in production (no dev admin plane). */\nfunction isProduction(): boolean {\n return process.env.NODE_ENV === \"production\";\n}\n\n/** Truthy values that enable analytics via {@link analyticsEnabled}. */\nconst TRUTHY = new Set([\"1\", \"true\", \"yes\", \"on\"]);\n\n/**\n * Whether analytics is enabled. OFF by default; enable with\n * `ENPILINK_ANALYTICS=1` (also accepts `true`/`yes`/`on`, case-insensitive).\n */\nexport function analyticsEnabled(): boolean {\n const raw = process.env.ENPILINK_ANALYTICS;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Whether the `--mock` demo seed is enabled (`ENPILINK_MOCK`=1/true/yes/on).\n * Mock mode is opt-in only and IMPLIES analytics-on + in-memory storage for the\n * session, so the Dashboard renders full demo data with NO real traffic. It\n * NEVER touches disk and is never on by default.\n */\nexport function mockEnabled(): boolean {\n // The demo seed is DEV-ONLY: it must never seed a real deployment. In\n // production `ENPILINK_MOCK` is ignored entirely (read the literal so the\n // guard survives DCE and is unambiguous).\n if (process.env.NODE_ENV === \"production\") {\n return false;\n }\n const raw = process.env.ENPILINK_MOCK;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Extract the tool name from a `tools/call` request's params. Returns\n * `undefined` for non-`tools/call` methods or malformed params, never throws.\n */\nfunction toolNameOf(\n method: string,\n params: Record<string, unknown>,\n): string | undefined {\n if (method !== \"tools/call\") {\n return undefined;\n }\n const name = params?.name;\n return typeof name === \"string\" ? name : undefined;\n}\n\n/** Options controlling the live capture gate (injectable for tests). */\nexport interface AnalyticsMiddlewareOptions {\n /** Read the live capture gate. Defaults to the shared {@link getCaptureGate}. */\n gate?: () => CaptureGate;\n /** RNG for sampling `[0, 1)`. Defaults to `Math.random`. */\n rng?: () => number;\n}\n\n/**\n * Build the analytics middleware entry. Times each request around `next()`,\n * and — ONLY when the live capture gate says so — records a `tool_call`-typed\n * event (capturing the tool name for `tools/call`). Always swallows storage\n * errors so a storage failure can never break or slow a tool call. Recording is\n * fire-and-forget (non-blocking).\n *\n * The gate is a cheap, synchronous in-memory snapshot of the resolved\n * `analytics.enabled` / `analytics.sampleRate` config (see `capture-gate.ts`),\n * so toggling analytics in the UI takes effect live without a restart and the\n * hot path does no DB read. `next()` is ALWAYS awaited so the tool call runs\n * identically whether capture is on or off; only the record/export at the end is\n * gated.\n */\nexport function createAnalyticsMiddleware(\n storage: StorageAdapter,\n now: () => number = Date.now,\n otel: OtelSink | null = null,\n opts: AnalyticsMiddlewareOptions = {},\n): McpMiddlewareFn {\n const readGate = opts.gate ?? getCaptureGate;\n const rng = opts.rng ?? Math.random;\n return async (request, _extra, next) => {\n const start = now();\n const tool = toolNameOf(request.method, request.params);\n let ok = true;\n let error: string | undefined;\n\n try {\n const result = await next();\n // A tool result with `isError: true` is a soft (handled) failure.\n if (\n result &&\n typeof result === \"object\" &&\n (result as { isError?: unknown }).isError === true\n ) {\n ok = false;\n }\n return result;\n } catch (err) {\n // Record the failure, then rethrow so behavior is unchanged.\n ok = false;\n error = err instanceof Error ? err.message : String(err);\n throw err;\n } finally {\n // Live gate: skip recording entirely when analytics is off, or when this\n // call falls outside the sample rate. Cheap + synchronous + never throws.\n const { enabled, sampleRate } = readGate();\n const sampled = sampleRate >= 1 || (sampleRate > 0 && rng() < sampleRate);\n if (enabled && sampled) {\n const ms = now() - start;\n const event = {\n ts: start,\n type: \"tool_call\",\n tool,\n method: request.method,\n ms,\n ok,\n error,\n } as const;\n // Fire-and-forget; never block or throw into the request path.\n void recordSafely(storage, event);\n // Optional OTel export — guarded, synchronous, error-swallowing.\n if (otel) {\n try {\n otel.record(event);\n } catch {\n // OTel export must never break or slow a tool call.\n }\n }\n }\n }\n };\n}\n\n/** Record an event, swallowing any storage error. */\nasync function recordSafely(\n storage: StorageAdapter,\n event: Parameters<StorageAdapter[\"recordEvent\"]>[0],\n): Promise<void> {\n try {\n await storage.recordEvent(event);\n } catch {\n // A storage failure must never break or slow a tool call.\n }\n}\n\n/**\n * Install analytics on a server.\n *\n * STORAGE activation (decoupled from capture):\n * - Reuse any already-active adapter ({@link getActiveStorage}) — never\n * double-init.\n * - In `--mock` mode, force a fresh in-memory adapter and seed it.\n * - Otherwise, in DEV activate the configured adapter (default `memory`,\n * honoring `ENPILINK_STORAGE` / `ENPILINK_DB_PATH`) so the Configuration +\n * observability UI always has a backing store — removing the \"no active\n * storage\" 409 on the first config write. `memory` creates NO file and does\n * zero network.\n * - In PRODUCTION WITHOUT mock, activate NOTHING here (the config UI isn't\n * reachable; prod-admin activates its own store in `admin.ts`). This preserves\n * the \"no db / no network when off in prod\" guarantee.\n *\n * CAPTURE gating: the returned middleware records events only when the live\n * {@link CaptureGate} (resolved `analytics.enabled`/`analytics.sampleRate`, env\n * > file > db > default) allows it — so the toggle is live (no restart). The\n * gate is resolved once here and refreshed on config writes by the router. In\n * `--mock` mode capture is force-enabled for the session.\n *\n * @returns the active storage + middleware entry + otel sink, or `null` when no\n * storage was activated (prod without mock/admin) — zero overhead, no file, no\n * network, no middleware.\n */\nexport async function installAnalytics(\n opts: InstallAnalyticsOptions = {},\n): Promise<{\n storage: StorageAdapter;\n entry: McpMiddlewareEntry;\n otel: OtelSink | null;\n} | null> {\n // `--mock` (ENPILINK_MOCK) force-enables capture for the session and uses a\n // throwaway in-memory store so demos work with no real traffic and no disk.\n const mock = mockEnabled();\n\n // Reuse an already-active adapter if one exists (e.g. set elsewhere); never\n // double-init. Otherwise decide whether to activate one.\n let storage = getActiveStorage();\n let ownedHere = false;\n if (!storage) {\n // In prod we don't activate storage here UNLESS analytics is explicitly\n // enabled via env (the historical M2 behavior — capture to a resolved\n // store). Otherwise the config UI isn't reachable (prod-admin handles its\n // own store), so activating nothing preserves the \"no db / no network when\n // off\" guarantee. In dev (or mock) we always activate so the UI works.\n if (!mock && isProduction() && !analyticsEnabled()) {\n return null;\n }\n try {\n storage = resolveSessionStorage(mock);\n await storage.init();\n ownedHere = true;\n } catch (err) {\n // Never let a storage failure break server startup.\n console.error(\n \"[enpilink] analytics storage init failed:\",\n err instanceof Error ? err.message : err,\n );\n return null;\n }\n setActiveStorage(storage);\n }\n\n // In `--mock` mode, seed the (in-memory) storage with a deterministic demo\n // dataset so the Dashboard renders full immediately (no real traffic).\n // Determinism: a fixed seed + a base timestamp captured once here. Only seed a\n // store we just created here, never an adopted/pre-existing one.\n if (mock && ownedHere) {\n const now = (opts.now ?? Date.now)();\n try {\n await seedMockData(storage, { now });\n } catch {\n // A seeding failure must never break server startup.\n }\n }\n\n // Initialize the live capture gate. `--mock` force-enables capture for the\n // session (full sample); otherwise resolve from env/file/db/default so the UI\n // toggle (and any env override) governs capture live.\n if (mock) {\n setCaptureGate({ enabled: true, sampleRate: 1 });\n } else {\n await refreshCaptureGate();\n }\n\n // Optional OTel export (M6): off by default, zero network/imports when unset.\n // Mock mode never exports (it's dev-only demo data); requires the explicit env\n // opt-in.\n const otel = mock ? null : await initOtel();\n\n const entry: McpMiddlewareEntry = {\n // \"request\" matches every (non-notification) request so non-tool methods\n // are counted too; the handler captures the tool name for tools/call.\n filter: \"request\",\n handler: createAnalyticsMiddleware(storage, opts.now, otel),\n };\n\n return { storage, entry, otel };\n}\n"]}
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
  import { analyticsEnabled, createAnalyticsMiddleware, installAnalytics, } from "./analytics.js";
4
+ import { getCaptureGate, setCaptureGate } from "./capture-gate.js";
4
5
  import { getActiveStorage, serverLog, setActiveStorage } from "./log-sink.js";
5
6
  import { MemoryStorageAdapter } from "./storage/memory.js";
6
7
  /** Flush the fire-and-forget recordEvent/appendLog microtasks. */
@@ -38,6 +39,8 @@ describe("analyticsEnabled", () => {
38
39
  }
39
40
  });
40
41
  });
42
+ /** A gate that always records (analytics on, full sample) — for capture tests. */
43
+ const onGate = () => ({ enabled: true, sampleRate: 1 });
41
44
  describe("createAnalyticsMiddleware", () => {
42
45
  let store;
43
46
  beforeEach(async () => {
@@ -45,7 +48,9 @@ describe("createAnalyticsMiddleware", () => {
45
48
  await store.init();
46
49
  });
47
50
  it("records a tool_call event on success with tool, ms, ok", async () => {
48
- const mw = createAnalyticsMiddleware(store, fixedClock());
51
+ const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
52
+ gate: onGate,
53
+ });
49
54
  const result = { content: [{ type: "text", text: "hi" }] };
50
55
  const ret = await mw({ method: "tools/call", params: { name: "greet" } }, undefined, async () => result);
51
56
  await flush();
@@ -62,14 +67,18 @@ describe("createAnalyticsMiddleware", () => {
62
67
  expect(e?.error).toBeUndefined();
63
68
  });
64
69
  it("records ok=false when the result has isError", async () => {
65
- const mw = createAnalyticsMiddleware(store, fixedClock());
70
+ const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
71
+ gate: onGate,
72
+ });
66
73
  await mw({ method: "tools/call", params: { name: "boom" } }, undefined, async () => ({ isError: true, content: [] }));
67
74
  await flush();
68
75
  const [e] = await store.queryEvents({});
69
76
  expect(e).toMatchObject({ tool: "boom", ok: false });
70
77
  });
71
78
  it("records the event AND rethrows on a thrown error", async () => {
72
- const mw = createAnalyticsMiddleware(store, fixedClock());
79
+ const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
80
+ gate: onGate,
81
+ });
73
82
  await expect(mw({ method: "tools/call", params: { name: "kaboom" } }, undefined, async () => {
74
83
  throw new Error("nope");
75
84
  })).rejects.toThrow("nope");
@@ -78,7 +87,9 @@ describe("createAnalyticsMiddleware", () => {
78
87
  expect(e).toMatchObject({ tool: "kaboom", ok: false, error: "nope" });
79
88
  });
80
89
  it("captures the method but no tool for non-tools/call requests", async () => {
81
- const mw = createAnalyticsMiddleware(store, fixedClock());
90
+ const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
91
+ gate: onGate,
92
+ });
82
93
  await mw({ method: "tools/list", params: {} }, undefined, async () => ({
83
94
  tools: [],
84
95
  }));
@@ -93,7 +104,9 @@ describe("createAnalyticsMiddleware", () => {
93
104
  broken.recordEvent = async () => {
94
105
  throw new Error("storage down");
95
106
  };
96
- const mw = createAnalyticsMiddleware(broken, fixedClock());
107
+ const mw = createAnalyticsMiddleware(broken, fixedClock(), null, {
108
+ gate: onGate,
109
+ });
97
110
  const result = { content: [] };
98
111
  await expect(mw({ method: "tools/call", params: { name: "x" } }, undefined, async () => result)).resolves.toBe(result);
99
112
  await flush();
@@ -102,11 +115,13 @@ describe("createAnalyticsMiddleware", () => {
102
115
  describe("installAnalytics gating", () => {
103
116
  const originalAnalytics = process.env.ENPILINK_ANALYTICS;
104
117
  const originalStorage = process.env.ENPILINK_STORAGE;
118
+ const originalNodeEnv = process.env.NODE_ENV;
105
119
  beforeEach(() => {
106
120
  setActiveStorage(null);
107
121
  });
108
122
  afterEach(() => {
109
123
  setActiveStorage(null);
124
+ setCaptureGate({ enabled: false, sampleRate: 1 });
110
125
  if (originalAnalytics === undefined) {
111
126
  delete process.env.ENPILINK_ANALYTICS;
112
127
  }
@@ -119,33 +134,69 @@ describe("installAnalytics gating", () => {
119
134
  else {
120
135
  process.env.ENPILINK_STORAGE = originalStorage;
121
136
  }
137
+ if (originalNodeEnv === undefined) {
138
+ delete process.env.NODE_ENV;
139
+ }
140
+ else {
141
+ process.env.NODE_ENV = originalNodeEnv;
142
+ }
122
143
  });
123
- it("returns null and creates no adapter when disabled", async () => {
144
+ it("activates a default (memory) storage in dev even when analytics is OFF", async () => {
124
145
  delete process.env.ENPILINK_ANALYTICS;
146
+ delete process.env.ENPILINK_STORAGE; // default = memory in dev
147
+ delete process.env.NODE_ENV; // dev
125
148
  const result = await installAnalytics();
126
- expect(result).toBeNull();
127
- expect(getActiveStorage()).toBeNull();
149
+ // Storage is activated (so the config/observability UI can persist) ...
150
+ expect(result).not.toBeNull();
151
+ expect(getActiveStorage()).toBe(result?.storage);
152
+ // ... but capture stays OFF until the runtime toggle / env enables it.
153
+ expect(getCaptureGate().enabled).toBe(false);
154
+ await result?.storage.close();
128
155
  });
129
- it("does NOT create a sqlite db file when disabled", async () => {
156
+ it("does NOT create a sqlite db file in dev when storage is the default (memory)", async () => {
130
157
  delete process.env.ENPILINK_ANALYTICS;
131
- process.env.ENPILINK_STORAGE = "sqlite";
158
+ delete process.env.ENPILINK_STORAGE; // default = memory => no file
159
+ delete process.env.NODE_ENV;
132
160
  const dbPath = `${process.cwd()}/enpilink.db`;
133
161
  const preexisting = existsSync(dbPath);
134
- await installAnalytics();
162
+ const result = await installAnalytics();
135
163
  if (!preexisting) {
136
164
  expect(existsSync(dbPath)).toBe(false);
137
165
  }
166
+ await result?.storage.close();
138
167
  });
139
- it("resolves + inits + registers the active storage when enabled", async () => {
168
+ it("activates NOTHING in prod when analytics is OFF (no admin)", async () => {
169
+ delete process.env.ENPILINK_ANALYTICS;
170
+ process.env.NODE_ENV = "production";
171
+ const result = await installAnalytics();
172
+ expect(result).toBeNull();
173
+ expect(getActiveStorage()).toBeNull();
174
+ });
175
+ it("sets capture ON from env (env override) when ENPILINK_ANALYTICS=1", async () => {
140
176
  process.env.ENPILINK_ANALYTICS = "1";
141
177
  process.env.ENPILINK_STORAGE = "memory";
178
+ delete process.env.NODE_ENV;
142
179
  const result = await installAnalytics();
143
180
  expect(result).not.toBeNull();
144
181
  expect(result?.entry.filter).toBe("request");
145
182
  expect(typeof result?.entry.handler).toBe("function");
146
183
  expect(getActiveStorage()).toBe(result?.storage);
184
+ // env override -> gate enabled regardless of any DB value.
185
+ expect(getCaptureGate().enabled).toBe(true);
147
186
  await result?.storage.close();
148
187
  });
188
+ it("REUSES an already-active storage and never double-inits", async () => {
189
+ const existing = new MemoryStorageAdapter();
190
+ await existing.init();
191
+ setActiveStorage(existing);
192
+ process.env.ENPILINK_ANALYTICS = "1";
193
+ delete process.env.NODE_ENV;
194
+ const result = await installAnalytics();
195
+ // Same instance reused; no second adapter created.
196
+ expect(result?.storage).toBe(existing);
197
+ expect(getActiveStorage()).toBe(existing);
198
+ await existing.close();
199
+ });
149
200
  });
150
201
  describe("serverLog capture", () => {
151
202
  beforeEach(() => setActiveStorage(null));
@@ -1 +1 @@
1
- {"version":3,"file":"analytics.test.js","sourceRoot":"","sources":["../../src/server/analytics.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { afterEach, beforeEach, describe, expect, it } from \"vitest\";\nimport {\n analyticsEnabled,\n createAnalyticsMiddleware,\n installAnalytics,\n} from \"./analytics.js\";\nimport { getActiveStorage, serverLog, setActiveStorage } from \"./log-sink.js\";\nimport { MemoryStorageAdapter } from \"./storage/memory.js\";\nimport type { AnalyticsEvent } from \"./storage/types.js\";\n\n/** Flush the fire-and-forget recordEvent/appendLog microtasks. */\nconst flush = () => new Promise((r) => setTimeout(r, 0));\n\n/** A fixed clock: 1000 at start, +5ms on the second read. */\nfunction fixedClock(): () => number {\n const ticks = [1000, 1005];\n let i = 0;\n return () => ticks[Math.min(i++, ticks.length - 1)] as number;\n}\n\ndescribe(\"analyticsEnabled\", () => {\n const original = process.env.ENPILINK_ANALYTICS;\n afterEach(() => {\n if (original === undefined) {\n delete process.env.ENPILINK_ANALYTICS;\n } else {\n process.env.ENPILINK_ANALYTICS = original;\n }\n });\n\n it(\"is off by default\", () => {\n delete process.env.ENPILINK_ANALYTICS;\n expect(analyticsEnabled()).toBe(false);\n });\n\n it(\"accepts 1/true/yes/on (case-insensitive)\", () => {\n for (const v of [\"1\", \"true\", \"TRUE\", \"yes\", \"on\", \" True \"]) {\n process.env.ENPILINK_ANALYTICS = v;\n expect(analyticsEnabled()).toBe(true);\n }\n });\n\n it(\"rejects other values\", () => {\n for (const v of [\"0\", \"false\", \"\", \"off\", \"no\"]) {\n process.env.ENPILINK_ANALYTICS = v;\n expect(analyticsEnabled()).toBe(false);\n }\n });\n});\n\ndescribe(\"createAnalyticsMiddleware\", () => {\n let store: MemoryStorageAdapter;\n beforeEach(async () => {\n store = new MemoryStorageAdapter({ cap: 100 });\n await store.init();\n });\n\n it(\"records a tool_call event on success with tool, ms, ok\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock());\n const result = { content: [{ type: \"text\", text: \"hi\" }] };\n const ret = await mw(\n { method: \"tools/call\", params: { name: \"greet\" } },\n undefined,\n async () => result,\n );\n await flush();\n\n expect(ret).toBe(result);\n const [e] = await store.queryEvents({});\n expect(e).toMatchObject<Partial<AnalyticsEvent>>({\n type: \"tool_call\",\n tool: \"greet\",\n method: \"tools/call\",\n ms: 5,\n ok: true,\n });\n expect(e?.ts).toBe(1000);\n expect(e?.error).toBeUndefined();\n });\n\n it(\"records ok=false when the result has isError\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock());\n await mw(\n { method: \"tools/call\", params: { name: \"boom\" } },\n undefined,\n async () => ({ isError: true, content: [] }),\n );\n await flush();\n const [e] = await store.queryEvents({});\n expect(e).toMatchObject({ tool: \"boom\", ok: false });\n });\n\n it(\"records the event AND rethrows on a thrown error\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock());\n await expect(\n mw(\n { method: \"tools/call\", params: { name: \"kaboom\" } },\n undefined,\n async () => {\n throw new Error(\"nope\");\n },\n ),\n ).rejects.toThrow(\"nope\");\n await flush();\n const [e] = await store.queryEvents({});\n expect(e).toMatchObject({ tool: \"kaboom\", ok: false, error: \"nope\" });\n });\n\n it(\"captures the method but no tool for non-tools/call requests\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock());\n await mw({ method: \"tools/list\", params: {} }, undefined, async () => ({\n tools: [],\n }));\n await flush();\n const [e] = await store.queryEvents({});\n expect(e?.method).toBe(\"tools/list\");\n expect(e?.tool).toBeUndefined();\n });\n\n it(\"never breaks the call when storage.recordEvent throws\", async () => {\n const broken = new MemoryStorageAdapter();\n await broken.init();\n broken.recordEvent = async () => {\n throw new Error(\"storage down\");\n };\n const mw = createAnalyticsMiddleware(broken, fixedClock());\n const result = { content: [] };\n await expect(\n mw(\n { method: \"tools/call\", params: { name: \"x\" } },\n undefined,\n async () => result,\n ),\n ).resolves.toBe(result);\n await flush();\n });\n});\n\ndescribe(\"installAnalytics gating\", () => {\n const originalAnalytics = process.env.ENPILINK_ANALYTICS;\n const originalStorage = process.env.ENPILINK_STORAGE;\n\n beforeEach(() => {\n setActiveStorage(null);\n });\n afterEach(() => {\n setActiveStorage(null);\n if (originalAnalytics === undefined) {\n delete process.env.ENPILINK_ANALYTICS;\n } else {\n process.env.ENPILINK_ANALYTICS = originalAnalytics;\n }\n if (originalStorage === undefined) {\n delete process.env.ENPILINK_STORAGE;\n } else {\n process.env.ENPILINK_STORAGE = originalStorage;\n }\n });\n\n it(\"returns null and creates no adapter when disabled\", async () => {\n delete process.env.ENPILINK_ANALYTICS;\n const result = await installAnalytics();\n expect(result).toBeNull();\n expect(getActiveStorage()).toBeNull();\n });\n\n it(\"does NOT create a sqlite db file when disabled\", async () => {\n delete process.env.ENPILINK_ANALYTICS;\n process.env.ENPILINK_STORAGE = \"sqlite\";\n const dbPath = `${process.cwd()}/enpilink.db`;\n const preexisting = existsSync(dbPath);\n await installAnalytics();\n if (!preexisting) {\n expect(existsSync(dbPath)).toBe(false);\n }\n });\n\n it(\"resolves + inits + registers the active storage when enabled\", async () => {\n process.env.ENPILINK_ANALYTICS = \"1\";\n process.env.ENPILINK_STORAGE = \"memory\";\n const result = await installAnalytics();\n expect(result).not.toBeNull();\n expect(result?.entry.filter).toBe(\"request\");\n expect(typeof result?.entry.handler).toBe(\"function\");\n expect(getActiveStorage()).toBe(result?.storage);\n await result?.storage.close();\n });\n});\n\ndescribe(\"serverLog capture\", () => {\n beforeEach(() => setActiveStorage(null));\n afterEach(() => setActiveStorage(null));\n\n it(\"is a no-op sink (no storage) when no active storage\", () => {\n expect(() => serverLog(\"info\", \"hello\")).not.toThrow();\n });\n\n it(\"mirrors logs to the active storage when set\", async () => {\n const store = new MemoryStorageAdapter();\n await store.init();\n setActiveStorage(store);\n serverLog(\"error\", \"boom\", { code: 1 });\n await flush();\n const [l] = await store.queryLogs({});\n expect(l).toMatchObject({ level: \"error\", msg: \"boom\" });\n expect(l?.data).toEqual({ code: 1 });\n await store.close();\n });\n\n it(\"swallows storage errors\", async () => {\n const store = new MemoryStorageAdapter();\n await store.init();\n store.appendLog = async () => {\n throw new Error(\"down\");\n };\n setActiveStorage(store);\n expect(() => serverLog(\"info\", \"x\")).not.toThrow();\n await flush();\n await store.close();\n });\n});\n"]}
1
+ {"version":3,"file":"analytics.test.js","sourceRoot":"","sources":["../../src/server/analytics.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { afterEach, beforeEach, describe, expect, it } from \"vitest\";\nimport {\n analyticsEnabled,\n createAnalyticsMiddleware,\n installAnalytics,\n} from \"./analytics.js\";\nimport { getCaptureGate, setCaptureGate } from \"./capture-gate.js\";\nimport { getActiveStorage, serverLog, setActiveStorage } from \"./log-sink.js\";\nimport { MemoryStorageAdapter } from \"./storage/memory.js\";\nimport type { AnalyticsEvent } from \"./storage/types.js\";\n\n/** Flush the fire-and-forget recordEvent/appendLog microtasks. */\nconst flush = () => new Promise((r) => setTimeout(r, 0));\n\n/** A fixed clock: 1000 at start, +5ms on the second read. */\nfunction fixedClock(): () => number {\n const ticks = [1000, 1005];\n let i = 0;\n return () => ticks[Math.min(i++, ticks.length - 1)] as number;\n}\n\ndescribe(\"analyticsEnabled\", () => {\n const original = process.env.ENPILINK_ANALYTICS;\n afterEach(() => {\n if (original === undefined) {\n delete process.env.ENPILINK_ANALYTICS;\n } else {\n process.env.ENPILINK_ANALYTICS = original;\n }\n });\n\n it(\"is off by default\", () => {\n delete process.env.ENPILINK_ANALYTICS;\n expect(analyticsEnabled()).toBe(false);\n });\n\n it(\"accepts 1/true/yes/on (case-insensitive)\", () => {\n for (const v of [\"1\", \"true\", \"TRUE\", \"yes\", \"on\", \" True \"]) {\n process.env.ENPILINK_ANALYTICS = v;\n expect(analyticsEnabled()).toBe(true);\n }\n });\n\n it(\"rejects other values\", () => {\n for (const v of [\"0\", \"false\", \"\", \"off\", \"no\"]) {\n process.env.ENPILINK_ANALYTICS = v;\n expect(analyticsEnabled()).toBe(false);\n }\n });\n});\n\n/** A gate that always records (analytics on, full sample) — for capture tests. */\nconst onGate = () => ({ enabled: true, sampleRate: 1 });\n\ndescribe(\"createAnalyticsMiddleware\", () => {\n let store: MemoryStorageAdapter;\n beforeEach(async () => {\n store = new MemoryStorageAdapter({ cap: 100 });\n await store.init();\n });\n\n it(\"records a tool_call event on success with tool, ms, ok\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock(), null, {\n gate: onGate,\n });\n const result = { content: [{ type: \"text\", text: \"hi\" }] };\n const ret = await mw(\n { method: \"tools/call\", params: { name: \"greet\" } },\n undefined,\n async () => result,\n );\n await flush();\n\n expect(ret).toBe(result);\n const [e] = await store.queryEvents({});\n expect(e).toMatchObject<Partial<AnalyticsEvent>>({\n type: \"tool_call\",\n tool: \"greet\",\n method: \"tools/call\",\n ms: 5,\n ok: true,\n });\n expect(e?.ts).toBe(1000);\n expect(e?.error).toBeUndefined();\n });\n\n it(\"records ok=false when the result has isError\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock(), null, {\n gate: onGate,\n });\n await mw(\n { method: \"tools/call\", params: { name: \"boom\" } },\n undefined,\n async () => ({ isError: true, content: [] }),\n );\n await flush();\n const [e] = await store.queryEvents({});\n expect(e).toMatchObject({ tool: \"boom\", ok: false });\n });\n\n it(\"records the event AND rethrows on a thrown error\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock(), null, {\n gate: onGate,\n });\n await expect(\n mw(\n { method: \"tools/call\", params: { name: \"kaboom\" } },\n undefined,\n async () => {\n throw new Error(\"nope\");\n },\n ),\n ).rejects.toThrow(\"nope\");\n await flush();\n const [e] = await store.queryEvents({});\n expect(e).toMatchObject({ tool: \"kaboom\", ok: false, error: \"nope\" });\n });\n\n it(\"captures the method but no tool for non-tools/call requests\", async () => {\n const mw = createAnalyticsMiddleware(store, fixedClock(), null, {\n gate: onGate,\n });\n await mw({ method: \"tools/list\", params: {} }, undefined, async () => ({\n tools: [],\n }));\n await flush();\n const [e] = await store.queryEvents({});\n expect(e?.method).toBe(\"tools/list\");\n expect(e?.tool).toBeUndefined();\n });\n\n it(\"never breaks the call when storage.recordEvent throws\", async () => {\n const broken = new MemoryStorageAdapter();\n await broken.init();\n broken.recordEvent = async () => {\n throw new Error(\"storage down\");\n };\n const mw = createAnalyticsMiddleware(broken, fixedClock(), null, {\n gate: onGate,\n });\n const result = { content: [] };\n await expect(\n mw(\n { method: \"tools/call\", params: { name: \"x\" } },\n undefined,\n async () => result,\n ),\n ).resolves.toBe(result);\n await flush();\n });\n});\n\ndescribe(\"installAnalytics gating\", () => {\n const originalAnalytics = process.env.ENPILINK_ANALYTICS;\n const originalStorage = process.env.ENPILINK_STORAGE;\n const originalNodeEnv = process.env.NODE_ENV;\n\n beforeEach(() => {\n setActiveStorage(null);\n });\n afterEach(() => {\n setActiveStorage(null);\n setCaptureGate({ enabled: false, sampleRate: 1 });\n if (originalAnalytics === undefined) {\n delete process.env.ENPILINK_ANALYTICS;\n } else {\n process.env.ENPILINK_ANALYTICS = originalAnalytics;\n }\n if (originalStorage === undefined) {\n delete process.env.ENPILINK_STORAGE;\n } else {\n process.env.ENPILINK_STORAGE = originalStorage;\n }\n if (originalNodeEnv === undefined) {\n delete process.env.NODE_ENV;\n } else {\n process.env.NODE_ENV = originalNodeEnv;\n }\n });\n\n it(\"activates a default (memory) storage in dev even when analytics is OFF\", async () => {\n delete process.env.ENPILINK_ANALYTICS;\n delete process.env.ENPILINK_STORAGE; // default = memory in dev\n delete process.env.NODE_ENV; // dev\n const result = await installAnalytics();\n // Storage is activated (so the config/observability UI can persist) ...\n expect(result).not.toBeNull();\n expect(getActiveStorage()).toBe(result?.storage);\n // ... but capture stays OFF until the runtime toggle / env enables it.\n expect(getCaptureGate().enabled).toBe(false);\n await result?.storage.close();\n });\n\n it(\"does NOT create a sqlite db file in dev when storage is the default (memory)\", async () => {\n delete process.env.ENPILINK_ANALYTICS;\n delete process.env.ENPILINK_STORAGE; // default = memory => no file\n delete process.env.NODE_ENV;\n const dbPath = `${process.cwd()}/enpilink.db`;\n const preexisting = existsSync(dbPath);\n const result = await installAnalytics();\n if (!preexisting) {\n expect(existsSync(dbPath)).toBe(false);\n }\n await result?.storage.close();\n });\n\n it(\"activates NOTHING in prod when analytics is OFF (no admin)\", async () => {\n delete process.env.ENPILINK_ANALYTICS;\n process.env.NODE_ENV = \"production\";\n const result = await installAnalytics();\n expect(result).toBeNull();\n expect(getActiveStorage()).toBeNull();\n });\n\n it(\"sets capture ON from env (env override) when ENPILINK_ANALYTICS=1\", async () => {\n process.env.ENPILINK_ANALYTICS = \"1\";\n process.env.ENPILINK_STORAGE = \"memory\";\n delete process.env.NODE_ENV;\n const result = await installAnalytics();\n expect(result).not.toBeNull();\n expect(result?.entry.filter).toBe(\"request\");\n expect(typeof result?.entry.handler).toBe(\"function\");\n expect(getActiveStorage()).toBe(result?.storage);\n // env override -> gate enabled regardless of any DB value.\n expect(getCaptureGate().enabled).toBe(true);\n await result?.storage.close();\n });\n\n it(\"REUSES an already-active storage and never double-inits\", async () => {\n const existing = new MemoryStorageAdapter();\n await existing.init();\n setActiveStorage(existing);\n process.env.ENPILINK_ANALYTICS = \"1\";\n delete process.env.NODE_ENV;\n const result = await installAnalytics();\n // Same instance reused; no second adapter created.\n expect(result?.storage).toBe(existing);\n expect(getActiveStorage()).toBe(existing);\n await existing.close();\n });\n});\n\ndescribe(\"serverLog capture\", () => {\n beforeEach(() => setActiveStorage(null));\n afterEach(() => setActiveStorage(null));\n\n it(\"is a no-op sink (no storage) when no active storage\", () => {\n expect(() => serverLog(\"info\", \"hello\")).not.toThrow();\n });\n\n it(\"mirrors logs to the active storage when set\", async () => {\n const store = new MemoryStorageAdapter();\n await store.init();\n setActiveStorage(store);\n serverLog(\"error\", \"boom\", { code: 1 });\n await flush();\n const [l] = await store.queryLogs({});\n expect(l).toMatchObject({ level: \"error\", msg: \"boom\" });\n expect(l?.data).toEqual({ code: 1 });\n await store.close();\n });\n\n it(\"swallows storage errors\", async () => {\n const store = new MemoryStorageAdapter();\n await store.init();\n store.appendLog = async () => {\n throw new Error(\"down\");\n };\n setActiveStorage(store);\n expect(() => serverLog(\"info\", \"x\")).not.toThrow();\n await flush();\n await store.close();\n });\n});\n"]}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Live capture gate for analytics (bugfix milestone).
3
+ *
4
+ * The analytics capture middleware is installed whenever there is an active
5
+ * {@link StorageAdapter} (so the dashboard/observability always has a backing
6
+ * store), but whether it ACTUALLY records an event is decided per-call by this
7
+ * gate. The gate mirrors the resolved runtime config value of
8
+ * `analytics.enabled` (+ `analytics.sampleRate`) using the documented
9
+ * env > file > db > default precedence.
10
+ *
11
+ * Why a cached gate instead of resolving config on every tool call:
12
+ * - {@link resolveConfig} does an async DB read; doing that on the hot path of
13
+ * every tool call would measurably slow calls and could throw into the path.
14
+ * - Instead we resolve ONCE at install and re-resolve only when config is
15
+ * written (the config router calls {@link refreshCaptureGate} after a
16
+ * successful PUT/DELETE/preset). The middleware reads a cheap, synchronous,
17
+ * in-memory snapshot — no DB read, no async, never throws.
18
+ *
19
+ * Result: toggling `analytics.enabled` in the Configuration UI takes effect for
20
+ * subsequent tool calls WITHOUT a process restart. An env override of
21
+ * `ENPILINK_ANALYTICS` still wins (env > db) because `resolveConfig` applies the
22
+ * same precedence — so an operator who pins the env var env-locks the toggle.
23
+ *
24
+ * No backfill: enabling analytics starts capturing forward only; it does not
25
+ * retroactively synthesize events for calls made while it was off.
26
+ */
27
+ /** The cheap, synchronous snapshot read on the hot path. */
28
+ export interface CaptureGate {
29
+ /** Whether to record events at all (resolved `analytics.enabled`). */
30
+ enabled: boolean;
31
+ /** Fraction of calls to record `[0, 1]` (resolved `analytics.sampleRate`). */
32
+ sampleRate: number;
33
+ }
34
+ /** Read the current capture gate (synchronous, cheap, never throws). */
35
+ export declare function getCaptureGate(): CaptureGate;
36
+ /**
37
+ * TEST-ONLY / mock: force the gate to a known value without resolving config.
38
+ * `--mock` mode uses this to force capture on for the session.
39
+ */
40
+ export declare function setCaptureGate(next: CaptureGate): void;
41
+ /**
42
+ * Re-resolve the capture gate from the active storage + env/file. Called at
43
+ * install and after every config write. Fire-and-forget friendly: it never
44
+ * throws (a resolve failure leaves the previous gate in place). Returns the
45
+ * freshly resolved gate for callers/tests that want to await it.
46
+ */
47
+ export declare function refreshCaptureGate(): Promise<CaptureGate>;
@@ -0,0 +1,39 @@
1
+ import { resolveConfig } from "./config/index.js";
2
+ import { getActiveStorage } from "./log-sink.js";
3
+ /**
4
+ * The current gate. Defaults to OFF so that, before the first resolve (or if a
5
+ * resolve fails), capture stays off — preserving the off-by-default guarantee.
6
+ */
7
+ let gate = { enabled: false, sampleRate: 1 };
8
+ /** Read the current capture gate (synchronous, cheap, never throws). */
9
+ export function getCaptureGate() {
10
+ return gate;
11
+ }
12
+ /**
13
+ * TEST-ONLY / mock: force the gate to a known value without resolving config.
14
+ * `--mock` mode uses this to force capture on for the session.
15
+ */
16
+ export function setCaptureGate(next) {
17
+ gate = next;
18
+ }
19
+ /**
20
+ * Re-resolve the capture gate from the active storage + env/file. Called at
21
+ * install and after every config write. Fire-and-forget friendly: it never
22
+ * throws (a resolve failure leaves the previous gate in place). Returns the
23
+ * freshly resolved gate for callers/tests that want to await it.
24
+ */
25
+ export async function refreshCaptureGate() {
26
+ try {
27
+ const { values } = await resolveConfig(getActiveStorage());
28
+ gate = {
29
+ enabled: values["analytics.enabled"] === true,
30
+ sampleRate: values["analytics.sampleRate"],
31
+ };
32
+ }
33
+ catch {
34
+ // Keep the previous gate; a config-resolve failure must never break or
35
+ // change capture behavior abruptly.
36
+ }
37
+ return gate;
38
+ }
39
+ //# sourceMappingURL=capture-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture-gate.js","sourceRoot":"","sources":["../../src/server/capture-gate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAqCjD;;;GAGG;AACH,IAAI,IAAI,GAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC;AAE1D,wEAAwE;AACxE,MAAM,UAAU,cAAc;IAC5B,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,IAAiB;IAC9C,IAAI,GAAG,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,IAAI,CAAC;QACH,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,aAAa,CAAC,gBAAgB,EAAE,CAAC,CAAC;QAC3D,IAAI,GAAG;YACL,OAAO,EAAE,MAAM,CAAC,mBAAmB,CAAC,KAAK,IAAI;YAC7C,UAAU,EAAE,MAAM,CAAC,sBAAsB,CAAC;SAC3C,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,uEAAuE;QACvE,oCAAoC;IACtC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["import { resolveConfig } from \"./config/index.js\";\nimport { getActiveStorage } from \"./log-sink.js\";\n\n/**\n * Live capture gate for analytics (bugfix milestone).\n *\n * The analytics capture middleware is installed whenever there is an active\n * {@link StorageAdapter} (so the dashboard/observability always has a backing\n * store), but whether it ACTUALLY records an event is decided per-call by this\n * gate. The gate mirrors the resolved runtime config value of\n * `analytics.enabled` (+ `analytics.sampleRate`) using the documented\n * env > file > db > default precedence.\n *\n * Why a cached gate instead of resolving config on every tool call:\n * - {@link resolveConfig} does an async DB read; doing that on the hot path of\n * every tool call would measurably slow calls and could throw into the path.\n * - Instead we resolve ONCE at install and re-resolve only when config is\n * written (the config router calls {@link refreshCaptureGate} after a\n * successful PUT/DELETE/preset). The middleware reads a cheap, synchronous,\n * in-memory snapshot — no DB read, no async, never throws.\n *\n * Result: toggling `analytics.enabled` in the Configuration UI takes effect for\n * subsequent tool calls WITHOUT a process restart. An env override of\n * `ENPILINK_ANALYTICS` still wins (env > db) because `resolveConfig` applies the\n * same precedence — so an operator who pins the env var env-locks the toggle.\n *\n * No backfill: enabling analytics starts capturing forward only; it does not\n * retroactively synthesize events for calls made while it was off.\n */\n\n/** The cheap, synchronous snapshot read on the hot path. */\nexport interface CaptureGate {\n /** Whether to record events at all (resolved `analytics.enabled`). */\n enabled: boolean;\n /** Fraction of calls to record `[0, 1]` (resolved `analytics.sampleRate`). */\n sampleRate: number;\n}\n\n/**\n * The current gate. Defaults to OFF so that, before the first resolve (or if a\n * resolve fails), capture stays off — preserving the off-by-default guarantee.\n */\nlet gate: CaptureGate = { enabled: false, sampleRate: 1 };\n\n/** Read the current capture gate (synchronous, cheap, never throws). */\nexport function getCaptureGate(): CaptureGate {\n return gate;\n}\n\n/**\n * TEST-ONLY / mock: force the gate to a known value without resolving config.\n * `--mock` mode uses this to force capture on for the session.\n */\nexport function setCaptureGate(next: CaptureGate): void {\n gate = next;\n}\n\n/**\n * Re-resolve the capture gate from the active storage + env/file. Called at\n * install and after every config write. Fire-and-forget friendly: it never\n * throws (a resolve failure leaves the previous gate in place). Returns the\n * freshly resolved gate for callers/tests that want to await it.\n */\nexport async function refreshCaptureGate(): Promise<CaptureGate> {\n try {\n const { values } = await resolveConfig(getActiveStorage());\n gate = {\n enabled: values[\"analytics.enabled\"] === true,\n sampleRate: values[\"analytics.sampleRate\"],\n };\n } catch {\n // Keep the previous gate; a config-resolve failure must never break or\n // change capture behavior abruptly.\n }\n return gate;\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ import express from "express";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ import { createAnalyticsMiddleware } from "./analytics.js";
4
+ import { getCaptureGate, refreshCaptureGate, setCaptureGate, } from "./capture-gate.js";
5
+ import { createConfigRouter } from "./config/index.js";
6
+ import { setActiveStorage } from "./log-sink.js";
7
+ import { MemoryStorageAdapter } from "./storage/memory.js";
8
+ /** Flush the fire-and-forget recordEvent microtask. */
9
+ const flush = () => new Promise((r) => setTimeout(r, 0));
10
+ /** Issue a real HTTP request against an Express app on an ephemeral port. */
11
+ async function request(app, method, url, body) {
12
+ const { createServer } = await import("node:http");
13
+ const server = createServer(app);
14
+ await new Promise((r) => server.listen(0, r));
15
+ const addr = server.address();
16
+ const port = typeof addr === "object" && addr ? addr.port : 0;
17
+ try {
18
+ const res = await fetch(`http://127.0.0.1:${port}${url}`, {
19
+ method,
20
+ headers: body ? { "content-type": "application/json" } : undefined,
21
+ body: body ? JSON.stringify(body) : undefined,
22
+ });
23
+ return { status: res.status };
24
+ }
25
+ finally {
26
+ await new Promise((r) => server.close(() => r()));
27
+ }
28
+ }
29
+ /** A fixed clock so latency is deterministic. */
30
+ function fixedClock() {
31
+ const ticks = [1000, 1005];
32
+ let i = 0;
33
+ return () => ticks[Math.min(i++, ticks.length - 1)];
34
+ }
35
+ /** Call the middleware once (simulating a tool call). */
36
+ async function callTool(mw) {
37
+ await mw({ method: "tools/call", params: { name: "greet" } }, undefined, async () => ({ content: [] }));
38
+ await flush();
39
+ }
40
+ describe("live analytics toggle (capture gate)", () => {
41
+ const original = process.env.ENPILINK_ANALYTICS;
42
+ beforeEach(() => {
43
+ setActiveStorage(null);
44
+ setCaptureGate({ enabled: false, sampleRate: 1 });
45
+ delete process.env.ENPILINK_ANALYTICS;
46
+ });
47
+ afterEach(() => {
48
+ setActiveStorage(null);
49
+ setCaptureGate({ enabled: false, sampleRate: 1 });
50
+ if (original === undefined) {
51
+ delete process.env.ENPILINK_ANALYTICS;
52
+ }
53
+ else {
54
+ process.env.ENPILINK_ANALYTICS = original;
55
+ }
56
+ });
57
+ it("config write to analytics.enabled flips capture ON live (no restart)", async () => {
58
+ const storage = new MemoryStorageAdapter();
59
+ await storage.init();
60
+ setActiveStorage(storage);
61
+ // Capture middleware is installed against the live gate (the production
62
+ // wiring) — initially gate is OFF.
63
+ await refreshCaptureGate();
64
+ expect(getCaptureGate().enabled).toBe(false);
65
+ const mw = createAnalyticsMiddleware(storage, fixedClock());
66
+ // A tool call while OFF records nothing.
67
+ await callTool(mw);
68
+ expect((await storage.queryEvents({})).length).toBe(0);
69
+ // Toggle analytics.enabled ON via the config router (the UI path).
70
+ const app = express();
71
+ app.use(express.json());
72
+ app.use(createConfigRouter()); // defaults to getActiveStorage()
73
+ const put = await request(app, "PUT", "/__enpilink/config/analytics.enabled", {
74
+ value: true,
75
+ });
76
+ expect(put.status).toBe(200);
77
+ // Gate flipped live — subsequent calls are captured WITHOUT a restart.
78
+ expect(getCaptureGate().enabled).toBe(true);
79
+ await callTool(mw);
80
+ expect((await storage.queryEvents({})).length).toBe(1);
81
+ // Toggle OFF again → capture stops; the existing row is not removed.
82
+ const off = await request(app, "PUT", "/__enpilink/config/analytics.enabled", { value: false });
83
+ expect(off.status).toBe(200);
84
+ expect(getCaptureGate().enabled).toBe(false);
85
+ await callTool(mw);
86
+ expect((await storage.queryEvents({})).length).toBe(1);
87
+ await storage.close();
88
+ });
89
+ it("env override env-locks analytics.enabled (env > db)", async () => {
90
+ process.env.ENPILINK_ANALYTICS = "1";
91
+ const storage = new MemoryStorageAdapter();
92
+ await storage.init();
93
+ // Persist a DB value of false — the env override must still win.
94
+ await storage.setConfig("analytics.enabled", false);
95
+ setActiveStorage(storage);
96
+ await refreshCaptureGate();
97
+ expect(getCaptureGate().enabled).toBe(true);
98
+ // The config UI must render the key env-locked (read-only) and a PUT must
99
+ // be rejected with 409 (pinned via env).
100
+ const app = express();
101
+ app.use(express.json());
102
+ app.use(createConfigRouter());
103
+ const put = await request(app, "PUT", "/__enpilink/config/analytics.enabled", {
104
+ value: true,
105
+ });
106
+ expect(put.status).toBe(409);
107
+ await storage.close();
108
+ });
109
+ it("sampleRate=0 records nothing even when enabled", async () => {
110
+ const storage = new MemoryStorageAdapter();
111
+ await storage.init();
112
+ setActiveStorage(storage);
113
+ setCaptureGate({ enabled: true, sampleRate: 0 });
114
+ const mw = createAnalyticsMiddleware(storage, fixedClock(), null, {
115
+ // RNG never matters at sampleRate 0; assert it's not even consulted by
116
+ // forcing a value that would otherwise pass.
117
+ rng: () => 0,
118
+ });
119
+ await callTool(mw);
120
+ expect((await storage.queryEvents({})).length).toBe(0);
121
+ await storage.close();
122
+ });
123
+ });
124
+ //# sourceMappingURL=capture-gate.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture-gate.test.js","sourceRoot":"","sources":["../../src/server/capture-gate.test.ts"],"names":[],"mappings":"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","sourcesContent":["import express from \"express\";\nimport { afterEach, beforeEach, describe, expect, it } from \"vitest\";\nimport { createAnalyticsMiddleware } from \"./analytics.js\";\nimport {\n getCaptureGate,\n refreshCaptureGate,\n setCaptureGate,\n} from \"./capture-gate.js\";\nimport { createConfigRouter } from \"./config/index.js\";\nimport { setActiveStorage } from \"./log-sink.js\";\nimport { MemoryStorageAdapter } from \"./storage/memory.js\";\n\n/** Flush the fire-and-forget recordEvent microtask. */\nconst flush = () => new Promise((r) => setTimeout(r, 0));\n\n/** Issue a real HTTP request against an Express app on an ephemeral port. */\nasync function request(\n app: express.Express,\n method: string,\n url: string,\n body?: unknown,\n): Promise<{ status: number }> {\n const { createServer } = await import(\"node:http\");\n const server = createServer(app);\n await new Promise<void>((r) => server.listen(0, r));\n const addr = server.address();\n const port = typeof addr === \"object\" && addr ? addr.port : 0;\n try {\n const res = await fetch(`http://127.0.0.1:${port}${url}`, {\n method,\n headers: body ? { \"content-type\": \"application/json\" } : undefined,\n body: body ? JSON.stringify(body) : undefined,\n });\n return { status: res.status };\n } finally {\n await new Promise<void>((r) => server.close(() => r()));\n }\n}\n\n/** A fixed clock so latency is deterministic. */\nfunction fixedClock(): () => number {\n const ticks = [1000, 1005];\n let i = 0;\n return () => ticks[Math.min(i++, ticks.length - 1)] as number;\n}\n\n/** Call the middleware once (simulating a tool call). */\nasync function callTool(mw: ReturnType<typeof createAnalyticsMiddleware>) {\n await mw(\n { method: \"tools/call\", params: { name: \"greet\" } },\n undefined,\n async () => ({ content: [] }),\n );\n await flush();\n}\n\ndescribe(\"live analytics toggle (capture gate)\", () => {\n const original = process.env.ENPILINK_ANALYTICS;\n\n beforeEach(() => {\n setActiveStorage(null);\n setCaptureGate({ enabled: false, sampleRate: 1 });\n delete process.env.ENPILINK_ANALYTICS;\n });\n afterEach(() => {\n setActiveStorage(null);\n setCaptureGate({ enabled: false, sampleRate: 1 });\n if (original === undefined) {\n delete process.env.ENPILINK_ANALYTICS;\n } else {\n process.env.ENPILINK_ANALYTICS = original;\n }\n });\n\n it(\"config write to analytics.enabled flips capture ON live (no restart)\", async () => {\n const storage = new MemoryStorageAdapter();\n await storage.init();\n setActiveStorage(storage);\n // Capture middleware is installed against the live gate (the production\n // wiring) — initially gate is OFF.\n await refreshCaptureGate();\n expect(getCaptureGate().enabled).toBe(false);\n const mw = createAnalyticsMiddleware(storage, fixedClock());\n\n // A tool call while OFF records nothing.\n await callTool(mw);\n expect((await storage.queryEvents({})).length).toBe(0);\n\n // Toggle analytics.enabled ON via the config router (the UI path).\n const app = express();\n app.use(express.json());\n app.use(createConfigRouter()); // defaults to getActiveStorage()\n const put = await request(\n app,\n \"PUT\",\n \"/__enpilink/config/analytics.enabled\",\n {\n value: true,\n },\n );\n expect(put.status).toBe(200);\n\n // Gate flipped live — subsequent calls are captured WITHOUT a restart.\n expect(getCaptureGate().enabled).toBe(true);\n await callTool(mw);\n expect((await storage.queryEvents({})).length).toBe(1);\n\n // Toggle OFF again → capture stops; the existing row is not removed.\n const off = await request(\n app,\n \"PUT\",\n \"/__enpilink/config/analytics.enabled\",\n { value: false },\n );\n expect(off.status).toBe(200);\n expect(getCaptureGate().enabled).toBe(false);\n await callTool(mw);\n expect((await storage.queryEvents({})).length).toBe(1);\n\n await storage.close();\n });\n\n it(\"env override env-locks analytics.enabled (env > db)\", async () => {\n process.env.ENPILINK_ANALYTICS = \"1\";\n const storage = new MemoryStorageAdapter();\n await storage.init();\n // Persist a DB value of false — the env override must still win.\n await storage.setConfig(\"analytics.enabled\", false);\n setActiveStorage(storage);\n await refreshCaptureGate();\n expect(getCaptureGate().enabled).toBe(true);\n\n // The config UI must render the key env-locked (read-only) and a PUT must\n // be rejected with 409 (pinned via env).\n const app = express();\n app.use(express.json());\n app.use(createConfigRouter());\n const put = await request(\n app,\n \"PUT\",\n \"/__enpilink/config/analytics.enabled\",\n {\n value: true,\n },\n );\n expect(put.status).toBe(409);\n\n await storage.close();\n });\n\n it(\"sampleRate=0 records nothing even when enabled\", async () => {\n const storage = new MemoryStorageAdapter();\n await storage.init();\n setActiveStorage(storage);\n setCaptureGate({ enabled: true, sampleRate: 0 });\n const mw = createAnalyticsMiddleware(storage, fixedClock(), null, {\n // RNG never matters at sampleRate 0; assert it's not even consulted by\n // forcing a value that would otherwise pass.\n rng: () => 0,\n });\n await callTool(mw);\n expect((await storage.queryEvents({})).length).toBe(0);\n await storage.close();\n });\n});\n"]}
@@ -1,4 +1,5 @@
1
1
  import express, {} from "express";
2
+ import { refreshCaptureGate } from "../capture-gate.js";
2
3
  import { getActiveStorage } from "../log-sink.js";
3
4
  import { getPreset, PRESETS } from "./presets.js";
4
5
  import { resolveConfig, validateConfigWrite, } from "./resolve.js";
@@ -110,6 +111,9 @@ export function createConfigRouter(getStorage = getActiveStorage) {
110
111
  });
111
112
  }
112
113
  }
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();
113
117
  res.json({ ok: true, preset: preset.name, applied, skipped });
114
118
  });
115
119
  // PUT /config/:key — set a runtime or restart-tier key.
@@ -128,6 +132,9 @@ export function createConfigRouter(getStorage = getActiveStorage) {
128
132
  }
129
133
  try {
130
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();
131
138
  res.json({
132
139
  ok: true,
133
140
  key,
@@ -150,6 +157,9 @@ export function createConfigRouter(getStorage = getActiveStorage) {
150
157
  }
151
158
  try {
152
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();
153
163
  res.json({
154
164
  ok: true,
155
165
  key: guard.key,
@@ -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 { 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 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 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 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"]}
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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enpilink",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "enpilink is a framework for building ChatGPT and MCP Apps",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,7 @@
47
47
  "react": ">=18.0.0",
48
48
  "react-dom": ">=18.0.0",
49
49
  "vite": ">=7.3.1",
50
- "@enpilink/console": "1.0.3"
50
+ "@enpilink/console": "1.0.4"
51
51
  },
52
52
  "dependencies": {
53
53
  "@babel/core": "^7.29.0",