@schlessera/brain-ui-server 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +2 -0
  2. package/dist/agent/backend.d.ts +31 -10
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +86 -26
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +51 -17
  8. package/dist/app.js.map +1 -1
  9. package/dist/config/env-core.d.ts +73 -0
  10. package/dist/config/env-core.d.ts.map +1 -0
  11. package/dist/config/env-core.js +62 -0
  12. package/dist/config/env-core.js.map +1 -0
  13. package/dist/config/env.d.ts +24 -5
  14. package/dist/config/env.d.ts.map +1 -1
  15. package/dist/config/env.js +55 -11
  16. package/dist/config/env.js.map +1 -1
  17. package/dist/cron/scheduler.d.ts +17 -0
  18. package/dist/cron/scheduler.d.ts.map +1 -1
  19. package/dist/cron/scheduler.js +33 -6
  20. package/dist/cron/scheduler.js.map +1 -1
  21. package/dist/db/settings.d.ts +1 -1
  22. package/dist/db/settings.d.ts.map +1 -1
  23. package/dist/db/settings.js +2 -2
  24. package/dist/db/settings.js.map +1 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/middleware/auth.d.ts +5 -0
  30. package/dist/middleware/auth.d.ts.map +1 -1
  31. package/dist/middleware/auth.js +46 -1
  32. package/dist/middleware/auth.js.map +1 -1
  33. package/dist/routes/health.d.ts +4 -14
  34. package/dist/routes/health.d.ts.map +1 -1
  35. package/dist/routes/health.js +22 -6
  36. package/dist/routes/health.js.map +1 -1
  37. package/dist/routes/share.d.ts.map +1 -1
  38. package/dist/routes/share.js +2 -2
  39. package/dist/routes/share.js.map +1 -1
  40. package/dist/voice/keyterm-builder.js +2 -2
  41. package/dist/voice/keyterm-builder.js.map +1 -1
  42. package/dist/ws/bridge.d.ts.map +1 -1
  43. package/dist/ws/bridge.js +36 -4
  44. package/dist/ws/bridge.js.map +1 -1
  45. package/dist/ws/clients.d.ts +10 -3
  46. package/dist/ws/clients.d.ts.map +1 -1
  47. package/dist/ws/clients.js +16 -4
  48. package/dist/ws/clients.js.map +1 -1
  49. package/dist/ws/connection.d.ts +1 -1
  50. package/dist/ws/connection.d.ts.map +1 -1
  51. package/dist/ws/connection.js +43 -6
  52. package/dist/ws/connection.js.map +1 -1
  53. package/dist/ws/dispatch.d.ts.map +1 -1
  54. package/dist/ws/dispatch.js +3 -1
  55. package/dist/ws/dispatch.js.map +1 -1
  56. package/dist/ws/host.d.ts +36 -0
  57. package/dist/ws/host.d.ts.map +1 -1
  58. package/dist/ws/host.js +93 -4
  59. package/dist/ws/host.js.map +1 -1
  60. package/dist/ws/run-session.d.ts.map +1 -1
  61. package/dist/ws/run-session.js +31 -5
  62. package/dist/ws/run-session.js.map +1 -1
  63. package/dist/ws/session-catalog.d.ts +2 -1
  64. package/dist/ws/session-catalog.d.ts.map +1 -1
  65. package/dist/ws/session-catalog.js +20 -3
  66. package/dist/ws/session-catalog.js.map +1 -1
  67. package/dist/ws/turns.d.ts +30 -2
  68. package/dist/ws/turns.d.ts.map +1 -1
  69. package/dist/ws/turns.js +13 -0
  70. package/dist/ws/turns.js.map +1 -1
  71. package/package.json +7 -6
  72. package/src/agent/backend.ts +123 -29
  73. package/src/app.ts +59 -17
  74. package/src/config/env-core.ts +93 -0
  75. package/src/config/env.ts +80 -17
  76. package/src/cron/scheduler.ts +48 -14
  77. package/src/db/settings.ts +2 -2
  78. package/src/index.ts +6 -0
  79. package/src/middleware/auth.ts +50 -1
  80. package/src/routes/health.ts +22 -6
  81. package/src/routes/share.ts +11 -7
  82. package/src/voice/keyterm-builder.ts +2 -2
  83. package/src/ws/bridge.ts +36 -7
  84. package/src/ws/clients.ts +24 -6
  85. package/src/ws/connection.ts +59 -6
  86. package/src/ws/dispatch.ts +3 -1
  87. package/src/ws/host.ts +115 -6
  88. package/src/ws/run-session.ts +36 -8
  89. package/src/ws/session-catalog.ts +21 -3
  90. package/src/ws/turns.ts +37 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schlessera/brain-ui-server",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "brain-kit chat-UI server: Hono app factory, WebSocket turn coordinator, auth (password/passkeys/tailscale/proxy), session catalog, and brain/files/voice routes",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -23,7 +23,7 @@
23
23
  "access": "public"
24
24
  },
25
25
  "engines": {
26
- "bun": ">=1.3.0"
26
+ "bun": ">=1.3.5"
27
27
  },
28
28
  "sideEffects": false,
29
29
  "type": "module",
@@ -32,7 +32,8 @@
32
32
  "bun": "./src/index.ts",
33
33
  "types": "./dist/index.d.ts",
34
34
  "default": "./dist/index.js"
35
- }
35
+ },
36
+ "./package.json": "./package.json"
36
37
  },
37
38
  "files": [
38
39
  "src",
@@ -41,14 +42,14 @@
41
42
  "README.md"
42
43
  ],
43
44
  "scripts": {
44
- "test": "bun test tests",
45
+ "test": "bun test tests --timeout 30000",
45
46
  "prepublishOnly": "bun ../../scripts/check-dist.ts"
46
47
  },
47
48
  "dependencies": {
48
49
  "@opentelemetry/api": "^1.9.1",
49
50
  "@opentelemetry/api-logs": "0.221.0",
50
- "@schlessera/brain-render-template": "0.16.0",
51
- "@schlessera/brain-ui-sdk": "0.16.0",
51
+ "@schlessera/brain-render-template": "0.18.0",
52
+ "@schlessera/brain-ui-sdk": "0.18.0",
52
53
  "@simplewebauthn/server": "^13.3.2",
53
54
  "hono": "^4",
54
55
  "ignore": "^7.0.5",
@@ -14,7 +14,7 @@ import type {
14
14
  * tests install fakes by constructing a registry, not by mutating a module.
15
15
  *
16
16
  * BOTH backend packages are optional peers loaded lazily (the same
17
- * `createRequire` path): a deployment installs the one its AGENT_BACKEND
17
+ * dynamic-import path): a deployment installs the one its AGENT_BACKEND
18
18
  * names, and the other never has to be present — at runtime AND at
19
19
  * type-check time (see the structural mirrors below).
20
20
  *
@@ -48,6 +48,33 @@ import type {
48
48
  * incompatible drift slide.
49
49
  */
50
50
 
51
+ /**
52
+ * Mirror of the backends' `BackendLogFn` — the minimal log seam a backend
53
+ * accepts. A callback rather than a Logger so the backend packages carry no
54
+ * telemetry dependency; {@link toBackendLog} adapts the registry's Logger.
55
+ */
56
+ export type BackendLogFn = (
57
+ level: "debug" | "info" | "warn" | "error",
58
+ message: string,
59
+ attrs?: Record<string, string | number | boolean>
60
+ ) => void;
61
+
62
+ const LEVEL_SEVERITY = {
63
+ debug: "DEBUG",
64
+ info: "INFO",
65
+ warn: "WARN",
66
+ error: "ERROR",
67
+ } as const;
68
+
69
+ function toBackendLog(log: Logger): BackendLogFn {
70
+ return (level, message, attrs) =>
71
+ log.emit({
72
+ severityText: LEVEL_SEVERITY[level],
73
+ body: message,
74
+ ...(attrs ? { attributes: attrs } : {}),
75
+ });
76
+ }
77
+
51
78
  /** Mirror of the Claude package's `InferenceProfileInput` (declarative shape). */
52
79
  export interface ClaudeProfileInput {
53
80
  id: string;
@@ -95,6 +122,8 @@ export interface ClaudeBackendModule {
95
122
  brainPath: string;
96
123
  claudeCodePath?: string;
97
124
  profiles?: ClaudeProfile[] | (() => ClaudeProfile[]);
125
+ confirmBashPatterns?: readonly string[];
126
+ log?: BackendLogFn;
98
127
  }) => AgentBackend;
99
128
  createModelSource: (options: {
100
129
  brainPath: string;
@@ -146,6 +175,12 @@ export interface BackendRegistryOptions {
146
175
  * sessions pinned to it. Defaults to "nothing hidden".
147
176
  */
148
177
  getHiddenModelIds?: () => string[];
178
+ /**
179
+ * Where the registry and its backends report. Adapted to the backends'
180
+ * minimal callback ({@link BackendLogFn}) before crossing the package
181
+ * boundary. Absent means silence.
182
+ */
183
+ log?: Logger;
149
184
  }
150
185
 
151
186
  export interface BackendRegistry {
@@ -258,6 +293,59 @@ function missingBackendError(primary: "claude" | "pi"): Error {
258
293
  * The `resolve` parameter exists for tests (simulating an absent package);
259
294
  * production callers pass nothing.
260
295
  */
296
+ /** The specifier an ERR_MODULE_NOT_FOUND failed on, or null for any other error. */
297
+ function moduleNotFoundSpecifier(error: unknown): string | null {
298
+ if (typeof error !== "object" || error === null) return null;
299
+ const { code, specifier, message } = error as {
300
+ code?: unknown;
301
+ specifier?: unknown;
302
+ message?: unknown;
303
+ };
304
+ if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") return null;
305
+ // Bun's ResolveMessage (not an Error instance) carries the failing
306
+ // specifier as a property; Node quotes it in the message instead.
307
+ if (typeof specifier === "string") return specifier;
308
+ if (typeof message === "string") {
309
+ const quoted = /Cannot find (?:package|module) '([^']+)'/.exec(message);
310
+ if (quoted) return quoted[1];
311
+ }
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * Load an optional backend package, mapping ONLY "the backend package itself
317
+ * is not installed" to the actionable install hint. Everything else — the
318
+ * backend missing one of its OWN transitive deps, a syntax error, a throwing
319
+ * top-level — is a different failure whose original error IS the diagnostic,
320
+ * so it is rethrown untouched. The distinction rides on ERR_MODULE_NOT_FOUND
321
+ * naming the specifier it failed on: a transitive miss names the transitive
322
+ * dep, not the backend, and must not read as "backend not installed".
323
+ *
324
+ * `await import()` rather than `createRequire()(...)`: the backend packages
325
+ * are ESM, and a CJS require of them under plain Node dies with
326
+ * ERR_REQUIRE_ESM — which the old blanket catch then reported as "not
327
+ * installed" on a machine where the package was sitting right there.
328
+ *
329
+ * The `importer` parameter exists for tests (simulating absent or broken
330
+ * packages); production callers pass nothing.
331
+ */
332
+ export async function loadBackendModule(
333
+ // "claude" | "pi" spelled out, NOT keyof typeof BACKEND_SPECIFIERS: the
334
+ // keyof form drags the table's literal string types — the backend
335
+ // specifiers — into the emitted .d.ts, which the declaration-surface gate
336
+ // rightly refuses.
337
+ key: "claude" | "pi",
338
+ importer: (specifier: string) => Promise<unknown> = (specifier) => import(specifier)
339
+ ): Promise<unknown> {
340
+ const specifier = BACKEND_SPECIFIERS[key];
341
+ try {
342
+ return await importer(specifier);
343
+ } catch (error) {
344
+ if (moduleNotFoundSpecifier(error) === specifier) throw missingBackendError(key);
345
+ throw error;
346
+ }
347
+ }
348
+
261
349
  export function assertBackendResolvable(
262
350
  agent: AgentConfig,
263
351
  resolve: (specifier: string) => void = (specifier) => {
@@ -279,6 +367,7 @@ export function createBackendRegistry(
279
367
  ): BackendRegistry {
280
368
  const { brainPath, agent } = options;
281
369
  const getHidden = options.getHiddenModelIds ?? (() => []);
370
+ const backendLog = options.log ? toBackendLog(options.log) : undefined;
282
371
 
283
372
  let cachedRegistry: Promise<RegistrySnapshot> | null = null;
284
373
  let modelSource: ClaudeModelSource | null = null;
@@ -392,18 +481,12 @@ export function createBackendRegistry(
392
481
  /**
393
482
  * NEITHER backend package is a hard dependency — a deployment installs the
394
483
  * one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
395
- * the same `createRequire` path so the server still boots without the unused
396
- * one; when the configured backend's package is absent, fail with an
397
- * actionable message.
484
+ * {@link loadBackendModule} so the server still boots without the unused
485
+ * one; only "the package is absent" maps to the actionable install hint,
486
+ * any other load failure surfaces as itself.
398
487
  */
399
- function buildClaudeBackend(): AgentBackend {
400
- const require = createRequire(import.meta.url);
401
- let claude: ClaudeBackendModule;
402
- try {
403
- claude = require("@schlessera/brain-backend-claude");
404
- } catch {
405
- throw missingBackendError("claude");
406
- }
488
+ async function buildClaudeBackend(): Promise<AgentBackend> {
489
+ const claude = (await loadBackendModule("claude")) as ClaudeBackendModule;
407
490
  if (typeof claude.createClaudeBackend !== "function") {
408
491
  throw new Error(
409
492
  '"@schlessera/brain-backend-claude" does not export createClaudeBackend.'
@@ -423,6 +506,12 @@ export function createBackendRegistry(
423
506
  return claude.createClaudeBackend({
424
507
  brainPath,
425
508
  claudeCodePath: agent.claudeCodePath,
509
+ ...(backendLog ? { log: backendLog } : {}),
510
+ // Omitted entirely when unconfigured, so the backend's own defaults
511
+ // apply; an explicit [] passes through and disables confirmation.
512
+ ...(agent.confirmBashPatterns !== null
513
+ ? { confirmBashPatterns: agent.confirmBashPatterns }
514
+ : {}),
426
515
  // A function, not an array: discovery refreshes in the background and the
427
516
  // new roster has to be visible without restarting the process.
428
517
  profiles: () =>
@@ -430,31 +519,30 @@ export function createBackendRegistry(
430
519
  });
431
520
  }
432
521
 
433
- function buildPiBackend(): AgentBackend {
434
- const require = createRequire(import.meta.url);
435
- let mod: { createPiBackend?: (opts: { brainPath: string }) => AgentBackend };
436
- try {
437
- mod = require("@schlessera/brain-backend-pi");
438
- } catch {
439
- throw missingBackendError("pi");
440
- }
522
+ async function buildPiBackend(): Promise<AgentBackend> {
523
+ const mod = (await loadBackendModule("pi")) as {
524
+ createPiBackend?: (opts: { brainPath: string; log?: BackendLogFn }) => AgentBackend;
525
+ };
441
526
  if (typeof mod.createPiBackend !== "function") {
442
527
  throw new Error(
443
528
  '"@schlessera/brain-backend-pi" does not export createPiBackend.'
444
529
  );
445
530
  }
446
- return mod.createPiBackend({ brainPath });
531
+ return mod.createPiBackend({
532
+ brainPath,
533
+ ...(backendLog ? { log: backendLog } : {}),
534
+ });
447
535
  }
448
536
 
449
537
  async function buildRegistry(): Promise<RegistrySnapshot> {
450
538
  const primary = agent.backend || "claude";
451
539
 
452
540
  if (primary === "pi") {
453
- const pi = buildPiBackend();
541
+ const pi = await buildPiBackend();
454
542
  return buildSnapshot([pi], pi.id);
455
543
  }
456
544
 
457
- const backends = [buildClaudeBackend()];
545
+ const backends = [await buildClaudeBackend()];
458
546
 
459
547
  // AGENT_BACKEND must name a configured backend. The in-process options are
460
548
  // "claude" (default) and "pi" (handled above). An unrecognized value is a
@@ -471,10 +559,15 @@ export function createBackendRegistry(
471
559
  return cachedRegistry;
472
560
  }
473
561
 
474
- return makeRegistry(getRegistry, getHidden, async () => {
475
- await getRegistry();
476
- return modelSource;
477
- });
562
+ return makeRegistry(
563
+ getRegistry,
564
+ getHidden,
565
+ async () => {
566
+ await getRegistry();
567
+ return modelSource;
568
+ },
569
+ options.log
570
+ );
478
571
  }
479
572
 
480
573
  /**
@@ -486,13 +579,14 @@ export function createBackendRegistry(
486
579
  export function createStaticBackendRegistry(
487
580
  backends: AgentBackend[],
488
581
  defaultBackendId = backends[0]?.id ?? "",
489
- options: { getHiddenModelIds?: () => string[] } = {}
582
+ options: { getHiddenModelIds?: () => string[]; log?: Logger } = {}
490
583
  ): BackendRegistry {
491
584
  const snapshot = buildSnapshot(backends, defaultBackendId);
492
585
  return makeRegistry(
493
586
  async () => snapshot,
494
587
  options.getHiddenModelIds ?? (() => []),
495
- async () => null
588
+ async () => null,
589
+ options.log
496
590
  );
497
591
  }
498
592
 
package/src/app.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Context } from "hono";
3
3
  import type { Database } from "bun:sqlite";
4
- import { logger } from "hono/logger";
5
4
  import { cors } from "hono/cors";
6
5
  import { serveStatic } from "hono/bun";
7
6
  import { join } from "path";
8
7
  import { resolveServerConfig, type ServerConfig } from "./config/env.js";
9
- import { healthRoutes, createStatusRoutes } from "./routes/health.js";
8
+ import { createHealthRoutes, createStatusRoutes } from "./routes/health.js";
10
9
  import { createBrainRoutes } from "./routes/brain.js";
11
10
  import { createSessionRoutes } from "./routes/sessions.js";
12
11
  import { createVoiceRoutes } from "./routes/voice.js";
@@ -168,7 +167,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
168
167
  // Per-instance state: the app's own database, the brain CLI wrapper, the
169
168
  // backend registry, and the WebSocket host. No module-level singletons —
170
169
  // two apps with different configuration coexist in one process.
171
- const db = createUiDb(config.dbPath, { log: observability.logger("db") });
170
+ const dbLog = observability.logger("db");
171
+ const db = createUiDb(config.dbPath, { log: dbLog });
172
172
  const brain = createBrainClient({ brainPath: config.brainPath });
173
173
  const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
174
174
  const registry =
@@ -176,36 +176,63 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
176
176
  createBackendRegistry({
177
177
  brainPath: config.brainPath,
178
178
  agent: config.agent,
179
- getHiddenModelIds: () => getHiddenModelIds(db),
179
+ getHiddenModelIds: () => getHiddenModelIds(db, dbLog),
180
+ log: observability.logger("agent"),
180
181
  });
181
182
  const host = new WsHost({
182
183
  registry,
183
184
  observability,
184
- catalog: createSessionCatalog(() => db),
185
+ catalog: createSessionCatalog(() => db, dbLog),
185
186
  ...(options.appName ? { appName: options.appName } : {}),
186
- ...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
187
+ // Explicit option wins; then the env-resolved config; then the host default.
188
+ ...(options.turnTimeoutMs ?? config.turnTimeoutMs
189
+ ? { turnTimeoutMs: (options.turnTimeoutMs ?? config.turnTimeoutMs)! }
190
+ : {}),
187
191
  maxConcurrentSessions: () => config.maxConcurrentSessions,
188
192
  wsRate: config.wsRate,
189
193
  });
190
194
  const wsUpgrade = createWsUpgrade(host);
195
+ // One instrument for every way a login can fail — passkey ceremonies and
196
+ // password logins land in the same series, split by attributes.
197
+ const authFailures = observability.meter("auth").createCounter("auth.failures", {
198
+ description: "Failed authentication ceremonies, by reason",
199
+ });
191
200
  const passkeyCtx: PasskeyContext = {
192
201
  db,
193
202
  webauthn: config.webauthn,
194
203
  auth,
195
204
  allowedOrigins: config.allowedOrigins,
196
205
  log: observability.logger("passkeys"),
197
- failures: observability.meter("auth").createCounter("auth.failures", {
198
- description: "Failed authentication ceremonies, by reason",
199
- }),
206
+ failures: authFailures,
200
207
  };
201
208
  const keyterms: KeytermSettings = {
202
209
  brainPath: config.brainPath,
203
210
  cacheDir: config.voice.cacheDir,
204
211
  limit: config.voice.keytermLimit,
212
+ log: observability.logger("voice"),
205
213
  };
206
214
 
207
- // Middleware
208
- app.use("*", logger());
215
+ // Request logging through the observability layer, so BRAIN_UI_LOG_LEVEL
216
+ // governs it like every other emission (hono's logger() wrote raw console
217
+ // lines no threshold or consumer swap could touch). Path only — never the
218
+ // query string or body. /api/health is skipped: the Docker healthcheck
219
+ // polls it and would drown everything else out.
220
+ const httpLog = observability.logger("http");
221
+ app.use("*", async (c, next) => {
222
+ if (c.req.path === "/api/health") return next();
223
+ const startedAt = Date.now();
224
+ await next();
225
+ httpLog.emit({
226
+ severityText: "INFO",
227
+ body: "request",
228
+ attributes: {
229
+ method: c.req.method,
230
+ path: c.req.path,
231
+ status: c.res.status,
232
+ "duration.ms": Date.now() - startedAt,
233
+ },
234
+ });
235
+ });
209
236
 
210
237
  // CORS is only needed for a SPLIT topology where the client is served from a
211
238
  // different origin than the API. ALLOWED_ORIGINS is a comma-separated
@@ -228,7 +255,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
228
255
  // probe and the login/logout routes are reachable without a session.
229
256
  // /api/status is intentionally NOT here — it leaks the git SHA, cron errors,
230
257
  // and a session oracle, so it lives behind the guard below.
231
- app.route("/api", healthRoutes);
258
+ app.route("/api", createHealthRoutes({ db }));
232
259
  // Not under /api, and not behind the guard: this is where a system share
233
260
  // lands when no service worker was around to intercept it. See the route.
234
261
  app.route("/", shareTargetFallbackRoutes);
@@ -236,6 +263,8 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
236
263
  "/api",
237
264
  authRoutes(authMode, auth, {
238
265
  passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
266
+ log: authLog,
267
+ failures: authFailures,
239
268
  })
240
269
  );
241
270
  app.route("/api", passkeyPublicRoutes(authMode, passkeyCtx));
@@ -265,15 +294,28 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
265
294
  );
266
295
  app.route("/api", createSessionRoutes({ registry, db }));
267
296
  app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
268
- app.route("/api", createFilesRoutes({ brainRoot: config.brainPath }));
269
297
  app.route(
270
298
  "/api",
271
- createShareRoutes({ brainRoot: config.brainPath, allowedOrigins })
299
+ createFilesRoutes({ brainRoot: config.brainPath, log: observability.logger("files") })
300
+ );
301
+ app.route(
302
+ "/api",
303
+ createShareRoutes({
304
+ brainRoot: config.brainPath,
305
+ allowedOrigins,
306
+ log: observability.logger("share"),
307
+ })
272
308
  );
273
- app.route("/api", createRenderRoutes(options.renderer));
309
+ app.route("/api", createRenderRoutes(options.renderer, observability.logger("render")));
274
310
  app.route("/api", createProviderRoutes({ registry }));
275
- app.route("/api", createModelRoutes({ registry, db }));
276
- app.route("/api", createGraphRoutes({ brainRoot: config.brainPath }));
311
+ app.route(
312
+ "/api",
313
+ createModelRoutes({ registry, db, log: observability.logger("models") })
314
+ );
315
+ app.route(
316
+ "/api",
317
+ createGraphRoutes({ brainRoot: config.brainPath, log: observability.logger("graph") })
318
+ );
277
319
 
278
320
  // WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
279
321
  // upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared core of every package's environment chokepoint — a SYNC-ENFORCED
3
+ * COPY, not a package.
4
+ *
5
+ * This file exists byte-identically as `src/config/env-core.ts` in every
6
+ * package that has an env chokepoint (`src/config/env.ts`), and the root test
7
+ * `tests/env-core-sync.test.ts` fails the build when any copy differs. To
8
+ * change it: edit ONE copy, copy the file verbatim into every package that
9
+ * has one (each `src/config/env-core.ts`), and rerun the sync test.
10
+ *
11
+ * Why a copy and not a `@schlessera/brain-env` package: the repo adds a seam
12
+ * only where a second implementation is plausible (docs/extending/README.md),
13
+ * and a new package for four fields and three small functions would churn the
14
+ * release manifest and every dependent's dependency list. Byte-equality
15
+ * enforced by a test gives the same single-source-of-truth guarantee without
16
+ * the edge. (`scripts/env-docs.ts` imports the canonical copy in
17
+ * `packages/core` directly — it is a root script, not published code.)
18
+ *
19
+ * What belongs here: the descriptor contract the env-docs generator and the
20
+ * env-parity gate consume, the dynamic-read escape hatch, and the boolean
21
+ * parsing helpers. What does NOT belong here: `resolveEnv` — each package's
22
+ * resolver reads its own variables into its own shape and stays in that
23
+ * package's `env.ts`.
24
+ */
25
+
26
+ /** One environment variable a package reads. */
27
+ export interface EnvVarSpec {
28
+ /** Variable name as it appears in the environment. */
29
+ name: string;
30
+ /** What it controls. */
31
+ description: string;
32
+ /**
33
+ * Behaviour when the variable is unset. Omitted or null when there is no
34
+ * meaningful default to state.
35
+ */
36
+ default?: string | null;
37
+ /**
38
+ * `true` when the package cannot do its job at all without it, `false`
39
+ * when optional; a string states the CONDITION under which it becomes
40
+ * required (e.g. "AUTH_MODE=password") — flattening that to a boolean
41
+ * would lose the only part a reader needs.
42
+ */
43
+ required: boolean | string;
44
+ }
45
+
46
+ /** A family of reads whose variable NAME is data, not code. */
47
+ export interface DynamicEnvReadSpec {
48
+ /** Where the variable name comes from. */
49
+ source: string;
50
+ /** What the value is used for. */
51
+ description: string;
52
+ }
53
+
54
+ /**
55
+ * Call-time read of a single variable whose name is data (see the package's
56
+ * `DYNAMIC_ENV_READS`) or whose presence gates a feature. Never cache the
57
+ * result at module scope.
58
+ */
59
+ export function readEnvVar(
60
+ name: string,
61
+ env: NodeJS.ProcessEnv = process.env
62
+ ): string | undefined {
63
+ return env[name];
64
+ }
65
+
66
+ const TRUTHY_TOKENS: readonly string[] = ["1", "true", "on", "yes"];
67
+ const FALSY_TOKENS: readonly string[] = ["0", "false", "off", "no"];
68
+
69
+ /**
70
+ * The one way a token-valued boolean environment variable is parsed.
71
+ *
72
+ * Truthy: 1 / true / on / yes. Falsy: 0 / false / off / no. Matching is
73
+ * case-insensitive after trimming. Unset, empty, or any unrecognised token
74
+ * returns `defaultValue` — a typo in a flag must never silently flip a
75
+ * behaviour; it falls back to the documented default instead.
76
+ */
77
+ export function envFlag(value: string | undefined, defaultValue: boolean): boolean {
78
+ if (value === undefined) return defaultValue;
79
+ const token = value.trim().toLowerCase();
80
+ if (TRUTHY_TOKENS.includes(token)) return true;
81
+ if (FALSY_TOKENS.includes(token)) return false;
82
+ return defaultValue;
83
+ }
84
+
85
+ /**
86
+ * Presence-based boolean: set to ANY non-empty value means on. This is the
87
+ * `NO_COLOR` convention (https://no-color.org) — `NO_COLOR=0` still disables
88
+ * color — so it must NOT go through `envFlag`. Use only for variables whose
89
+ * documented contract is presence, not a token.
90
+ */
91
+ export function envPresent(value: string | undefined): boolean {
92
+ return value !== undefined && value !== "";
93
+ }