@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.
- package/README.md +2 -0
- package/dist/agent/backend.d.ts +31 -10
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +86 -26
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +51 -17
- package/dist/app.js.map +1 -1
- package/dist/config/env-core.d.ts +73 -0
- package/dist/config/env-core.d.ts.map +1 -0
- package/dist/config/env-core.js +62 -0
- package/dist/config/env-core.js.map +1 -0
- package/dist/config/env.d.ts +24 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +55 -11
- package/dist/config/env.js.map +1 -1
- package/dist/cron/scheduler.d.ts +17 -0
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +33 -6
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/settings.d.ts +1 -1
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +2 -2
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +5 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +46 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/health.d.ts +4 -14
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +22 -6
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +2 -2
- package/dist/routes/share.js.map +1 -1
- package/dist/voice/keyterm-builder.js +2 -2
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +36 -4
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +10 -3
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +16 -4
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +43 -6
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +3 -1
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +36 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +93 -4
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +31 -5
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/session-catalog.d.ts +2 -1
- package/dist/ws/session-catalog.d.ts.map +1 -1
- package/dist/ws/session-catalog.js +20 -3
- package/dist/ws/session-catalog.js.map +1 -1
- package/dist/ws/turns.d.ts +30 -2
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +13 -0
- package/dist/ws/turns.js.map +1 -1
- package/package.json +7 -6
- package/src/agent/backend.ts +123 -29
- package/src/app.ts +59 -17
- package/src/config/env-core.ts +93 -0
- package/src/config/env.ts +80 -17
- package/src/cron/scheduler.ts +48 -14
- package/src/db/settings.ts +2 -2
- package/src/index.ts +6 -0
- package/src/middleware/auth.ts +50 -1
- package/src/routes/health.ts +22 -6
- package/src/routes/share.ts +11 -7
- package/src/voice/keyterm-builder.ts +2 -2
- package/src/ws/bridge.ts +36 -7
- package/src/ws/clients.ts +24 -6
- package/src/ws/connection.ts +59 -6
- package/src/ws/dispatch.ts +3 -1
- package/src/ws/host.ts +115 -6
- package/src/ws/run-session.ts +36 -8
- package/src/ws/session-catalog.ts +21 -3
- 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.
|
|
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.
|
|
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.
|
|
51
|
-
"@schlessera/brain-ui-sdk": "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",
|
package/src/agent/backend.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
396
|
-
* one;
|
|
397
|
-
*
|
|
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
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
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({
|
|
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(
|
|
475
|
-
|
|
476
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
208
|
-
|
|
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",
|
|
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
|
-
|
|
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(
|
|
276
|
-
|
|
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
|
+
}
|