@schlessera/brain-ui-server 0.15.0 → 0.17.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 (160) hide show
  1. package/README.md +4 -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 +92 -28
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts +9 -0
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +80 -18
  9. package/dist/app.js.map +1 -1
  10. package/dist/config/env-core.d.ts +73 -0
  11. package/dist/config/env-core.d.ts.map +1 -0
  12. package/dist/config/env-core.js +62 -0
  13. package/dist/config/env-core.js.map +1 -0
  14. package/dist/config/env.d.ts +27 -5
  15. package/dist/config/env.d.ts.map +1 -1
  16. package/dist/config/env.js +90 -10
  17. package/dist/config/env.js.map +1 -1
  18. package/dist/cron/scheduler.d.ts +20 -0
  19. package/dist/cron/scheduler.d.ts.map +1 -1
  20. package/dist/cron/scheduler.js +43 -8
  21. package/dist/cron/scheduler.js.map +1 -1
  22. package/dist/db/client.d.ts +10 -1
  23. package/dist/db/client.d.ts.map +1 -1
  24. package/dist/db/client.js +5 -14
  25. package/dist/db/client.js.map +1 -1
  26. package/dist/db/settings.d.ts +3 -2
  27. package/dist/db/settings.d.ts.map +1 -1
  28. package/dist/db/settings.js +8 -4
  29. package/dist/db/settings.js.map +1 -1
  30. package/dist/index.d.ts +6 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +10 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/middleware/auth.d.ts +8 -2
  35. package/dist/middleware/auth.d.ts.map +1 -1
  36. package/dist/middleware/auth.js +74 -10
  37. package/dist/middleware/auth.js.map +1 -1
  38. package/dist/middleware/passkeys.d.ts +10 -0
  39. package/dist/middleware/passkeys.d.ts.map +1 -1
  40. package/dist/middleware/passkeys.js +19 -3
  41. package/dist/middleware/passkeys.js.map +1 -1
  42. package/dist/observability/index.d.ts +73 -0
  43. package/dist/observability/index.d.ts.map +1 -0
  44. package/dist/observability/index.js +74 -0
  45. package/dist/observability/index.js.map +1 -0
  46. package/dist/observability/loggers.d.ts +62 -0
  47. package/dist/observability/loggers.d.ts.map +1 -0
  48. package/dist/observability/loggers.js +109 -0
  49. package/dist/observability/loggers.js.map +1 -0
  50. package/dist/observability/meter.d.ts +39 -0
  51. package/dist/observability/meter.d.ts.map +1 -0
  52. package/dist/observability/meter.js +95 -0
  53. package/dist/observability/meter.js.map +1 -0
  54. package/dist/observability/types.d.ts +67 -0
  55. package/dist/observability/types.d.ts.map +1 -0
  56. package/dist/observability/types.js +44 -0
  57. package/dist/observability/types.js.map +1 -0
  58. package/dist/routes/brain.d.ts.map +1 -1
  59. package/dist/routes/brain.js +9 -1
  60. package/dist/routes/brain.js.map +1 -1
  61. package/dist/routes/files.d.ts +2 -0
  62. package/dist/routes/files.d.ts.map +1 -1
  63. package/dist/routes/files.js +7 -7
  64. package/dist/routes/files.js.map +1 -1
  65. package/dist/routes/graph.d.ts +2 -0
  66. package/dist/routes/graph.d.ts.map +1 -1
  67. package/dist/routes/graph.js +7 -7
  68. package/dist/routes/graph.js.map +1 -1
  69. package/dist/routes/health.d.ts +7 -14
  70. package/dist/routes/health.d.ts.map +1 -1
  71. package/dist/routes/health.js +25 -6
  72. package/dist/routes/health.js.map +1 -1
  73. package/dist/routes/models.d.ts +3 -0
  74. package/dist/routes/models.d.ts.map +1 -1
  75. package/dist/routes/models.js +5 -1
  76. package/dist/routes/models.js.map +1 -1
  77. package/dist/routes/render.d.ts +2 -1
  78. package/dist/routes/render.d.ts.map +1 -1
  79. package/dist/routes/render.js +2 -2
  80. package/dist/routes/render.js.map +1 -1
  81. package/dist/routes/share.d.ts +3 -0
  82. package/dist/routes/share.d.ts.map +1 -1
  83. package/dist/routes/share.js +5 -5
  84. package/dist/routes/share.js.map +1 -1
  85. package/dist/share/staging.d.ts +3 -2
  86. package/dist/share/staging.d.ts.map +1 -1
  87. package/dist/share/staging.js +12 -4
  88. package/dist/share/staging.js.map +1 -1
  89. package/dist/voice/keyterm-builder.d.ts +4 -1
  90. package/dist/voice/keyterm-builder.d.ts.map +1 -1
  91. package/dist/voice/keyterm-builder.js +15 -8
  92. package/dist/voice/keyterm-builder.js.map +1 -1
  93. package/dist/ws/bridge.d.ts.map +1 -1
  94. package/dist/ws/bridge.js +23 -3
  95. package/dist/ws/bridge.js.map +1 -1
  96. package/dist/ws/clients.d.ts +10 -3
  97. package/dist/ws/clients.d.ts.map +1 -1
  98. package/dist/ws/clients.js +16 -4
  99. package/dist/ws/clients.js.map +1 -1
  100. package/dist/ws/connection.d.ts +15 -0
  101. package/dist/ws/connection.d.ts.map +1 -1
  102. package/dist/ws/connection.js +66 -10
  103. package/dist/ws/connection.js.map +1 -1
  104. package/dist/ws/dispatch.d.ts +22 -1
  105. package/dist/ws/dispatch.d.ts.map +1 -1
  106. package/dist/ws/dispatch.js +36 -16
  107. package/dist/ws/dispatch.js.map +1 -1
  108. package/dist/ws/host.d.ts +73 -0
  109. package/dist/ws/host.d.ts.map +1 -1
  110. package/dist/ws/host.js +127 -1
  111. package/dist/ws/host.js.map +1 -1
  112. package/dist/ws/rate-limit.d.ts +58 -0
  113. package/dist/ws/rate-limit.d.ts.map +1 -0
  114. package/dist/ws/rate-limit.js +62 -0
  115. package/dist/ws/rate-limit.js.map +1 -0
  116. package/dist/ws/run-session.d.ts +3 -0
  117. package/dist/ws/run-session.d.ts.map +1 -1
  118. package/dist/ws/run-session.js +49 -9
  119. package/dist/ws/run-session.js.map +1 -1
  120. package/dist/ws/session-catalog.d.ts +2 -1
  121. package/dist/ws/session-catalog.d.ts.map +1 -1
  122. package/dist/ws/session-catalog.js +20 -3
  123. package/dist/ws/session-catalog.js.map +1 -1
  124. package/dist/ws/turns.d.ts +21 -0
  125. package/dist/ws/turns.d.ts.map +1 -1
  126. package/dist/ws/turns.js +11 -1
  127. package/dist/ws/turns.js.map +1 -1
  128. package/package.json +9 -6
  129. package/src/agent/backend.ts +131 -35
  130. package/src/app.ts +97 -18
  131. package/src/config/env-core.ts +93 -0
  132. package/src/config/env.ts +116 -15
  133. package/src/cron/scheduler.ts +61 -16
  134. package/src/db/client.ts +16 -5
  135. package/src/db/settings.ts +9 -4
  136. package/src/index.ts +43 -2
  137. package/src/middleware/auth.ts +80 -15
  138. package/src/middleware/passkeys.ts +29 -5
  139. package/src/observability/index.ts +150 -0
  140. package/src/observability/loggers.ts +174 -0
  141. package/src/observability/meter.ts +190 -0
  142. package/src/observability/types.ts +101 -0
  143. package/src/routes/brain.ts +10 -1
  144. package/src/routes/files.ts +9 -8
  145. package/src/routes/graph.ts +9 -8
  146. package/src/routes/health.ts +29 -6
  147. package/src/routes/models.ts +8 -5
  148. package/src/routes/render.ts +3 -2
  149. package/src/routes/share.ts +17 -10
  150. package/src/share/staging.ts +18 -4
  151. package/src/voice/keyterm-builder.ts +19 -10
  152. package/src/ws/bridge.ts +22 -6
  153. package/src/ws/clients.ts +24 -6
  154. package/src/ws/connection.ts +73 -13
  155. package/src/ws/dispatch.ts +47 -16
  156. package/src/ws/host.ts +164 -1
  157. package/src/ws/rate-limit.ts +86 -0
  158. package/src/ws/run-session.ts +57 -14
  159. package/src/ws/session-catalog.ts +21 -3
  160. package/src/ws/turns.ts +27 -3
package/src/config/env.ts CHANGED
@@ -15,19 +15,31 @@
15
15
 
16
16
  import { join } from "path";
17
17
 
18
+ import { SEVERITIES, type Severity } from "../observability/types.js";
19
+ import { envFlag } from "./env-core.js";
20
+
18
21
  // --- descriptor -------------------------------------------------------------
19
22
 
23
+ /**
24
+ * The shared descriptor contract (sync-enforced copy in ./env-core.ts),
25
+ * under the name this package has always exported. This package's entries
26
+ * use the `string` arm of `required` for conditionally-required variables
27
+ * (e.g. "AUTH_MODE=password") and `null` for "no default".
28
+ */
29
+ /**
30
+ * One environment variable the server reads.
31
+ *
32
+ * Deliberately LOCAL and narrower than env-core's EnvVarSpec: this is the
33
+ * package's published descriptor shape, and widening it to the shared
34
+ * union would be a breaking change for typed consumers of ENV_VARS.
35
+ */
20
36
  export interface EnvVarDescriptor {
21
- /** The environment variable, exactly as read. */
37
+ /** Variable name as it appears in the environment. */
22
38
  name: string;
23
39
  /** What it controls. */
24
40
  description: string;
25
41
  /** Human-readable default applied when unset, or null when there is none. */
26
42
  default: string | null;
27
- /**
28
- * `false` when optional; otherwise a human-readable statement of the
29
- * condition under which boot fails without it.
30
- */
31
43
  required: false | string;
32
44
  }
33
45
 
@@ -63,6 +75,44 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
63
75
  default: "(empty)",
64
76
  required: false,
65
77
  },
78
+ {
79
+ name: "BRAIN_UI_CONFIRM_BASH",
80
+ description:
81
+ "JSON array of regex sources; a Bash command matching any of them raises " +
82
+ "a confirmation card before it runs. Unset uses the shipped defaults " +
83
+ "(brain archive, rm -r, git push --force, git reset --hard, git clean -f, " +
84
+ "git checkout -- ). An empty array [] disables the confirmation. Not a " +
85
+ "security boundary — an agent with Bash can reach the same effect another " +
86
+ "way; it stops a destructive command you did not intend, not one that is " +
87
+ "trying to get past you.",
88
+ default: "the shipped pattern set",
89
+ required: false,
90
+ },
91
+ {
92
+ name: "BRAIN_UI_WS_RATE",
93
+ description:
94
+ "Sustained inbound WebSocket frames per second per connection. 0 " +
95
+ "disables metering entirely.",
96
+ default: "20",
97
+ required: false,
98
+ },
99
+ {
100
+ name: "BRAIN_UI_WS_BURST",
101
+ description:
102
+ "Inbound WebSocket frames absorbable in one burst before the sustained " +
103
+ "rate applies. Opening the app legitimately fires several at once.",
104
+ default: "60",
105
+ required: false,
106
+ },
107
+ {
108
+ name: "BRAIN_UI_LOG_LEVEL",
109
+ description:
110
+ "Minimum severity the console log consumer emits: TRACE, DEBUG, INFO, " +
111
+ "WARN, ERROR or FATAL. Case-insensitive; an unrecognised value falls " +
112
+ "back to the default rather than silencing the server.",
113
+ default: "INFO",
114
+ required: false,
115
+ },
66
116
  {
67
117
  name: "SOURCE_COMMIT",
68
118
  description: "Git SHA reported by /api/status (baked at image build time).",
@@ -279,6 +329,11 @@ export interface WebAuthnConfig {
279
329
  export interface AgentConfig {
280
330
  /** Trimmed, lowercased AGENT_BACKEND; null when unset (defaults to claude). */
281
331
  backend: string | null;
332
+ /**
333
+ * Bash-confirmation regex sources; null means "use the backend's defaults".
334
+ * An empty array is a deliberate opt-out and is passed through as such.
335
+ */
336
+ confirmBashPatterns: string[] | null;
282
337
  claudeCodePath: string;
283
338
  defaultModel: string;
284
339
  /** Raw BRAIN_UI_CLAUDE_PROFILES JSON, parsed lazily by the registry. */
@@ -305,6 +360,10 @@ export interface ServerConfig {
305
360
  sourceCommit: string;
306
361
  allowedOrigins: string[];
307
362
  maxConcurrentSessions: number;
363
+ /** Threshold for the console log consumer (BRAIN_UI_LOG_LEVEL). */
364
+ logLevel: Severity;
365
+ /** Inbound WebSocket frame metering, per connection. */
366
+ wsRate: { ratePerSecond: number; burst: number };
308
367
  auth: AuthConfig;
309
368
  webauthn: WebAuthnConfig;
310
369
  agent: AgentConfig;
@@ -322,8 +381,47 @@ function list(raw: string | undefined): string[] {
322
381
  .filter(Boolean);
323
382
  }
324
383
 
325
- function flag(raw: string | undefined): boolean {
326
- return raw === "1";
384
+ /**
385
+ * A log threshold, defaulting to INFO.
386
+ *
387
+ * An unrecognised value falls back rather than throwing: a typo in a log level
388
+ * must never be the reason a server refuses to boot, and silently emitting
389
+ * nothing would be worse than emitting too much.
390
+ */
391
+ /** A non-negative number, falling back rather than throwing on nonsense. */
392
+ function positiveNumber(raw: string | undefined, fallback: number): number {
393
+ if (raw === undefined || raw.trim() === "") return fallback;
394
+ const n = Number(raw);
395
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
396
+ }
397
+
398
+ /**
399
+ * Parse BRAIN_UI_CONFIRM_BASH into pattern sources.
400
+ *
401
+ * Unset or unparseable → null, meaning the backend's shipped defaults. An
402
+ * explicit `[]` is honoured as "no confirmation": disabling the seatbelt is a
403
+ * choice a deployment is allowed to make, and silently re-enabling it would be
404
+ * worse than obeying. Malformed JSON falls back to the defaults rather than
405
+ * throwing — a typo here must not stop the server booting, and the safe
406
+ * direction to fail is "more confirmation", not less.
407
+ */
408
+ function parseConfirmBash(raw: string | undefined): string[] | null {
409
+ const text = raw?.trim();
410
+ if (!text) return null;
411
+ try {
412
+ const parsed = JSON.parse(text);
413
+ if (!Array.isArray(parsed)) return null;
414
+ return parsed.filter((p): p is string => typeof p === "string");
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+
420
+ function parseSeverity(raw: string | undefined): Severity {
421
+ const upper = raw?.trim().toUpperCase();
422
+ return (SEVERITIES as readonly string[]).includes(upper ?? "")
423
+ ? (upper as Severity)
424
+ : "INFO";
327
425
  }
328
426
 
329
427
  const AUTH_MODES: readonly AuthModeName[] = ["password", "tailscale", "proxy", "none"];
@@ -338,10 +436,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
338
436
  const rawAuthMode = env.AUTH_MODE?.trim().toLowerCase() || null;
339
437
  const validMode = AUTH_MODES.find((mode) => mode === rawAuthMode) ?? null;
340
438
 
341
- const rawDiscovery = env.BRAIN_UI_MODEL_DISCOVERY?.trim().toLowerCase();
342
- const modelDiscovery = rawDiscovery
343
- ? !(rawDiscovery === "0" || rawDiscovery === "off" || rawDiscovery === "false")
344
- : env.NODE_ENV !== "test";
439
+ const modelDiscovery = envFlag(env.BRAIN_UI_MODEL_DISCOVERY, env.NODE_ENV !== "test");
345
440
 
346
441
  const rawTtl = Number(env.BRAIN_UI_MODEL_TTL_HOURS);
347
442
  const ttlHours = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : 24;
@@ -358,11 +453,11 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
358
453
  invalidMode: validMode ? null : rawAuthMode,
359
454
  passwordHash: env.BRAIN_UI_PASSWORD_HASH || null,
360
455
  cookieSecret: env.COOKIE_SECRET || null,
361
- trustProxy: flag(env.TRUST_PROXY),
456
+ trustProxy: envFlag(env.TRUST_PROXY, false),
362
457
  trustProxyHops: Math.max(1, Number(env.TRUST_PROXY_HOPS) || 1),
363
458
  proxyAuthHeader: (env.PROXY_AUTH_HEADER || "x-forwarded-user").toLowerCase(),
364
- dangerouslyDisableAuth: flag(env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH),
365
- allowPassword: flag(env.BRAIN_UI_ALLOW_PASSWORD),
459
+ dangerouslyDisableAuth: envFlag(env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH, false),
460
+ allowPassword: envFlag(env.BRAIN_UI_ALLOW_PASSWORD, false),
366
461
  },
367
462
  webauthn: {
368
463
  rpName: env.WEBAUTHN_RP_NAME || "Brain UI",
@@ -370,16 +465,22 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
370
465
  userId: env.WEBAUTHN_USER_ID || "brain-ui-owner",
371
466
  rpId: env.WEBAUTHN_RP_ID || null,
372
467
  origins: list(env.WEBAUTHN_ORIGINS),
373
- allowLoopbackOrigin: flag(env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN),
468
+ allowLoopbackOrigin: envFlag(env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN, false),
374
469
  },
375
470
  agent: {
376
471
  backend: env.AGENT_BACKEND?.trim().toLowerCase() || null,
472
+ confirmBashPatterns: parseConfirmBash(env.BRAIN_UI_CONFIRM_BASH),
377
473
  claudeCodePath: env.CLAUDE_CODE_PATH || "/usr/local/bin/claude",
378
474
  defaultModel: env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6",
379
475
  profilesJson: env.BRAIN_UI_CLAUDE_PROFILES?.trim() || null,
380
476
  modelDiscovery,
381
477
  modelTtlMs: ttlHours * 60 * 60 * 1000,
382
478
  },
479
+ logLevel: parseSeverity(env.BRAIN_UI_LOG_LEVEL),
480
+ wsRate: {
481
+ ratePerSecond: positiveNumber(env.BRAIN_UI_WS_RATE, 20),
482
+ burst: positiveNumber(env.BRAIN_UI_WS_BURST, 60),
483
+ },
383
484
  voice: {
384
485
  provider: env.VOICE_PROVIDER?.trim().toLowerCase() || null,
385
486
  deepgramApiKey: env.DEEPGRAM_API_KEY || null,
@@ -1,10 +1,12 @@
1
1
  import type { Database } from "bun:sqlite";
2
+ import type { Logger } from "@opentelemetry/api-logs";
2
3
  import type { BrainClient } from "../brain/client.js";
3
4
 
4
5
  // Scheduling is owned by the container crontab (config/crontab) — sync at
5
6
  // 02:00, validate at 03:00, job scrape at 04:00. This module only provides
6
7
  // manual triggers (triggerJob) and run history for the /api/status endpoint.
7
- // Runs started by the crontab do not appear here; they log to syslog.
8
+ // Runs started by the crontab report themselves via recordCronRun below;
9
+ // their stdout/stderr still goes to syslog.
8
10
 
9
11
  export interface CronScheduler {
10
12
  getCronStatus(): Array<{
@@ -22,11 +24,53 @@ interface CronJob {
22
24
  handler: () => Promise<void>;
23
25
  }
24
26
 
27
+ /** One in-flight job run being recorded into `cron_runs`. */
28
+ export interface CronRunRecord {
29
+ /** Mark the run finished: success without an argument, error with one. */
30
+ finish(error?: string): void;
31
+ }
32
+
33
+ /**
34
+ * Record a job run in the `cron_runs` history that `/api/status` serves
35
+ * (`cronJobs`, via {@link CronScheduler.getCronStatus}).
36
+ *
37
+ * The scheduler in this module only writes the table for its own manual
38
+ * triggers; in the shipped deployment the jobs are executed by the container
39
+ * crontab, whose runs were invisible to `/api/status`. An external scheduler's
40
+ * wrapper calls this before the job and `finish()` after, writing exactly the
41
+ * rows the in-process runner writes. A run whose process died before
42
+ * `finish()` stays in status "running" — itself a signal.
43
+ */
44
+ export function recordCronRun(db: Database, jobName: string): CronRunRecord {
45
+ const startedAt = Date.now();
46
+ db.prepare(
47
+ "INSERT INTO cron_runs (job_name, started_at, status) VALUES (?, ?, 'running')"
48
+ ).run(jobName, startedAt);
49
+
50
+ return {
51
+ finish(error?: string) {
52
+ const finishedAt = Date.now();
53
+ const durationMs = finishedAt - startedAt;
54
+ if (error === undefined) {
55
+ db.prepare(
56
+ "UPDATE cron_runs SET status = 'success', finished_at = ?, duration_ms = ? WHERE job_name = ? AND started_at = ?"
57
+ ).run(finishedAt, durationMs, jobName, startedAt);
58
+ } else {
59
+ db.prepare(
60
+ "UPDATE cron_runs SET status = 'error', finished_at = ?, duration_ms = ?, error_message = ? WHERE job_name = ? AND started_at = ?"
61
+ ).run(finishedAt, durationMs, error, jobName, startedAt);
62
+ }
63
+ },
64
+ };
65
+ }
66
+
25
67
  export function createCronScheduler(deps: {
26
68
  db: Database;
27
69
  brain: BrainClient;
70
+ /** Where run outcomes are reported; absent means silence. */
71
+ log?: Logger;
28
72
  }): CronScheduler {
29
- const { db, brain } = deps;
73
+ const { db, brain, log } = deps;
30
74
 
31
75
  const jobs: CronJob[] = [
32
76
  {
@@ -46,25 +90,26 @@ export function createCronScheduler(deps: {
46
90
 
47
91
  async function runJob(job: CronJob) {
48
92
  const startedAt = Date.now();
49
-
50
- db.prepare(
51
- "INSERT INTO cron_runs (job_name, started_at, status) VALUES (?, ?, 'running')"
52
- ).run(job.name, startedAt);
93
+ // Same recorder the external-scheduler path uses, so the two can never
94
+ // drift in what a cron_runs row looks like.
95
+ const record = recordCronRun(db, job.name);
53
96
 
54
97
  try {
55
98
  await job.handler();
56
- const durationMs = Date.now() - startedAt;
57
- db.prepare(
58
- "UPDATE cron_runs SET status = 'success', finished_at = ?, duration_ms = ? WHERE job_name = ? AND started_at = ?"
59
- ).run(Date.now(), durationMs, job.name, startedAt);
60
- console.log(`[cron] ${job.name} completed in ${durationMs}ms`);
99
+ record.finish();
100
+ log?.emit({
101
+ severityText: "INFO",
102
+ body: "job completed",
103
+ attributes: { job: job.name, "duration.ms": Date.now() - startedAt },
104
+ });
61
105
  } catch (err) {
62
- const durationMs = Date.now() - startedAt;
63
106
  const message = err instanceof Error ? err.message : String(err);
64
- db.prepare(
65
- "UPDATE cron_runs SET status = 'error', finished_at = ?, duration_ms = ?, error_message = ? WHERE job_name = ? AND started_at = ?"
66
- ).run(Date.now(), durationMs, message, job.name, startedAt);
67
- console.error(`[cron] ${job.name} failed: ${message}`);
107
+ record.finish(message);
108
+ log?.emit({
109
+ severityText: "ERROR",
110
+ body: "job failed",
111
+ attributes: { job: job.name, error: message },
112
+ });
68
113
  }
69
114
  }
70
115
 
package/src/db/client.ts CHANGED
@@ -2,6 +2,8 @@ import { Database } from "bun:sqlite";
2
2
  import { readdirSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
4
 
5
+ import type { Logger } from "@opentelemetry/api-logs";
6
+
5
7
  /**
6
8
  * The UI's OWN SQLite database (sessions, passkeys, settings, cron runs) —
7
9
  * distinct from the brain database, which is opened read-only via
@@ -11,15 +13,24 @@ import { join } from "path";
11
13
  * it to every consumer, so two apps with different configuration can coexist
12
14
  * in one process and a test gets an isolated database by construction.
13
15
  */
14
- export function createUiDb(dbPath: string): Database {
16
+ export interface CreateUiDbOptions {
17
+ /**
18
+ * Where migration progress is reported. Optional so a test can open a
19
+ * database without wiring observability; absent means silence, never
20
+ * console output.
21
+ */
22
+ log?: Logger;
23
+ }
24
+
25
+ export function createUiDb(dbPath: string, options: CreateUiDbOptions = {}): Database {
15
26
  const db = new Database(dbPath, { create: true });
16
27
  db.exec("PRAGMA journal_mode = WAL");
17
28
  db.exec("PRAGMA foreign_keys = ON");
18
- runMigrations(db);
29
+ runMigrations(db, options.log);
19
30
  return db;
20
31
  }
21
32
 
22
- function runMigrations(database: Database) {
33
+ function runMigrations(database: Database, log?: Logger) {
23
34
  // Ensure _migrations table exists
24
35
  database.exec(`
25
36
  CREATE TABLE IF NOT EXISTS _migrations (
@@ -39,7 +50,7 @@ function runMigrations(database: Database) {
39
50
  .filter((f) => f.endsWith(".sql"))
40
51
  .sort();
41
52
  } catch {
42
- console.warn("[db] No migrations directory found, skipping migrations");
53
+ log?.emit({ severityText: "WARN", body: "no migrations directory; skipping migrations" });
43
54
  return;
44
55
  }
45
56
 
@@ -53,7 +64,7 @@ function runMigrations(database: Database) {
53
64
  for (const file of files) {
54
65
  if (applied.has(file)) continue;
55
66
 
56
- console.log(`[db] Applying migration: ${file}`);
67
+ log?.emit({ severityText: "INFO", body: "applying migration", attributes: { file } });
57
68
  const sql = readFileSync(join(migrationsDir, file), "utf-8");
58
69
 
59
70
  database.transaction(() => {
@@ -6,11 +6,12 @@
6
6
  // corrupt row degrades to the caller's fallback rather than throwing, because
7
7
  // a bad preference must never take a route down.
8
8
 
9
+ import type { Logger } from "@opentelemetry/api-logs";
9
10
  import type { Database } from "bun:sqlite";
10
11
 
11
12
  const HIDDEN_MODELS_KEY = "models.hidden";
12
13
 
13
- export function getSetting<T>(db: Database, key: string, fallback: T): T {
14
+ export function getSetting<T>(db: Database, key: string, fallback: T, log?: Logger): T {
14
15
  const row = db
15
16
  .query("SELECT value FROM settings WHERE key = ?")
16
17
  .get(key) as { value: string } | null;
@@ -18,7 +19,11 @@ export function getSetting<T>(db: Database, key: string, fallback: T): T {
18
19
  try {
19
20
  return JSON.parse(row.value) as T;
20
21
  } catch {
21
- console.warn(`[settings] Corrupt JSON for "${key}"; using fallback`);
22
+ log?.emit({
23
+ severityText: "WARN",
24
+ body: "corrupt JSON in settings; using fallback",
25
+ attributes: { key },
26
+ });
22
27
  return fallback;
23
28
  }
24
29
  }
@@ -31,8 +36,8 @@ export function setSetting(db: Database, key: string, value: unknown): void {
31
36
  }
32
37
 
33
38
  /** Profile ids the user keeps out of the model picker. */
34
- export function getHiddenModelIds(db: Database): string[] {
35
- const value = getSetting<unknown>(db, HIDDEN_MODELS_KEY, []);
39
+ export function getHiddenModelIds(db: Database, log?: Logger): string[] {
40
+ const value = getSetting<unknown>(db, HIDDEN_MODELS_KEY, [], log);
36
41
  if (!Array.isArray(value)) return [];
37
42
  return value.filter((id): id is string => typeof id === "string");
38
43
  }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  assertBackendResolvable,
35
35
  createBackendRegistry,
36
36
  createStaticBackendRegistry,
37
+ type BackendLogFn,
37
38
  type BackendRegistry,
38
39
  type ModelDiscoverySource,
39
40
  type ModelDiscoveryState,
@@ -58,8 +59,12 @@ export {
58
59
 
59
60
  // WebSocket internals for embedders and tests.
60
61
  export { WsHost, type WsHostOptions } from "./ws/host.js";
61
- export { createWsUpgrade, websocket } from "./ws/connection.js";
62
- export { handleClientMessage } from "./ws/dispatch.js";
62
+ export { createWsUpgrade, createWsHandlers, websocket } from "./ws/connection.js";
63
+ export {
64
+ handleClientMessage,
65
+ turnIdMatches,
66
+ type ConnectionState,
67
+ } from "./ws/dispatch.js";
63
68
  export { resolveTurnTarget } from "./ws/routing.js";
64
69
  export {
65
70
  createSessionCatalog,
@@ -70,10 +75,46 @@ export {
70
75
  // their own routes on top.
71
76
  export { createBrainClient, type BrainClient } from "./brain/client.js";
72
77
 
78
+ // Cron run history. Scheduling belongs to the deployment (container crontab);
79
+ // an external scheduler's wrapper records each run here so /api/status's
80
+ // `cronJobs` reflects what actually ran.
81
+ export { recordCronRun, type CronRunRecord } from "./cron/scheduler.js";
82
+
73
83
  // Share staging: a deployment can sweep expired staging dirs at boot; the
74
84
  // intake route also sweeps opportunistically on every share.
75
85
  export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
76
86
 
87
+ // Observability: the producing side is the OpenTelemetry API, the consuming
88
+ // side is ours. Swap the consumer to change where a deployment reports; a test
89
+ // swaps in the recording one and asserts on what the server actually said.
90
+ export {
91
+ createObservability,
92
+ createRecordingObservability,
93
+ createSilentObservability,
94
+ createConsoleLoggerProvider,
95
+ createRecordingLoggerProvider,
96
+ createSilentLoggerProvider,
97
+ createInMemoryMeterProvider,
98
+ SEVERITIES,
99
+ severityRank,
100
+ seriesKey,
101
+ } from "./observability/index.js";
102
+ export type {
103
+ Observability,
104
+ ObservabilityOptions,
105
+ RecordingObservability,
106
+ RecordingLoggerProvider,
107
+ InMemoryMeterProvider,
108
+ LogReader,
109
+ LogWriter,
110
+ MetricsReader,
111
+ Severity,
112
+ CapturedLog,
113
+ LogQuery,
114
+ MetricPoint,
115
+ MetricSnapshot,
116
+ } from "./observability/index.js";
117
+
77
118
  // Voice keyterm cache rebuild (used by deployments after `brain sync`).
78
119
  export {
79
120
  buildKeyterms,
@@ -1,3 +1,5 @@
1
+ import type { Counter } from "@opentelemetry/api";
2
+ import type { Logger } from "@opentelemetry/api-logs";
1
3
  import { Hono } from "hono";
2
4
  import type { Context, MiddlewareHandler } from "hono";
3
5
  import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
@@ -48,12 +50,14 @@ const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
48
50
  // attacker could weaponize to deny the owner access.
49
51
  export const GLOBAL_LOGIN_RATE_LIMIT = 20; // attempts per window, all IPs combined
50
52
 
51
- export function resolveAuthMode(auth: AuthRuntime): AuthMode {
53
+ export function resolveAuthMode(auth: AuthRuntime, log?: Logger): AuthMode {
52
54
  if (auth.mode) return auth.mode;
53
55
  if (auth.invalidMode) {
54
- console.warn(
55
- `[auth] Unknown AUTH_MODE="${auth.invalidMode}"; auto-detecting instead.`
56
- );
56
+ log?.emit({
57
+ severityText: "WARN",
58
+ body: "unknown AUTH_MODE; auto-detecting instead",
59
+ attributes: { requested: auth.invalidMode },
60
+ });
57
61
  }
58
62
  if (auth.passwordHash) return "password";
59
63
  return "tailscale";
@@ -63,7 +67,7 @@ export function resolveAuthMode(auth: AuthRuntime): AuthMode {
63
67
  * Validate the auth configuration at startup. Throws (refusing to boot) on an
64
68
  * unsafe or unusable configuration.
65
69
  */
66
- export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
70
+ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime, log?: Logger): void {
67
71
  const host = auth.host;
68
72
  const loopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
69
73
 
@@ -87,11 +91,17 @@ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
87
91
  // exposed to the network".
88
92
  if (mode === "none" && !loopback) {
89
93
  if (auth.dangerouslyDisableAuth) {
90
- console.warn(
91
- "[auth] AUTH_MODE=none on a non-loopback host, allowed by " +
94
+ // ERROR, not WARN: this is a safety that has been deliberately switched
95
+ // off, and the severity must survive any sane BRAIN_UI_LOG_LEVEL. A log
96
+ // threshold must never be the reason nobody saw this.
97
+ log?.emit({
98
+ severityText: "ERROR",
99
+ body:
100
+ "AUTH_MODE=none on a non-loopback host, allowed by " +
92
101
  "BRAIN_UI_DANGEROUSLY_DISABLE_AUTH=1 — every network peer has full " +
93
- "agent access. Do not run this on anything but a trusted network."
94
- );
102
+ "agent access. Do not run this on anything but a trusted network.",
103
+ attributes: { "auth.mode": "none", host },
104
+ });
95
105
  } else {
96
106
  throw new Error(
97
107
  "AUTH_MODE=none refuses to start unless HOST is loopback " +
@@ -112,13 +122,20 @@ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
112
122
  "any client-supplied copy. Set TRUST_PROXY=1 once that holds."
113
123
  );
114
124
  }
115
- console.log(
116
- `[auth] mode: proxy (trusting header "${auth.proxyAuthHeader}"; ensure your ` +
117
- "upstream proxy sets it and strips any client-supplied copy)"
118
- );
125
+ log?.emit({
126
+ severityText: "INFO",
127
+ body:
128
+ "auth mode resolved: proxy — ensure the upstream proxy sets this header " +
129
+ "and strips any client-supplied copy",
130
+ attributes: { "auth.mode": "proxy", header: auth.proxyAuthHeader },
131
+ });
119
132
  return;
120
133
  }
121
- console.log(`[auth] mode: ${mode}`);
134
+ log?.emit({
135
+ severityText: "INFO",
136
+ body: "auth mode resolved",
137
+ attributes: { "auth.mode": mode },
138
+ });
122
139
  }
123
140
 
124
141
  /** Middleware guarding /api/* according to the resolved mode. */
@@ -273,9 +290,14 @@ export function authRoutes(
273
290
  * of WebAuthn concerns.
274
291
  */
275
292
  passwordDisabled?: (c: Context) => boolean;
293
+ /** Where login outcomes are reported; absent means silence. */
294
+ log?: Logger;
295
+ /** Failed-login counter — the same `auth.failures` instrument passkeys use. */
296
+ failures?: Counter;
276
297
  } = {}
277
298
  ): Hono {
278
299
  const app = new Hono();
300
+ const { log, failures } = deps;
279
301
 
280
302
  app.post("/auth/login", async (c) => {
281
303
  if (mode !== "password") {
@@ -286,6 +308,12 @@ export function authRoutes(
286
308
  const perIpOk = consumeLoginToken(`ip:${key}`, LOGIN_RATE_LIMIT);
287
309
  const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
288
310
  if (!perIpOk || !globalOk) {
311
+ failures?.add(1, { reason: "rate_limited", method: "password" });
312
+ log?.emit({
313
+ severityText: "WARN",
314
+ body: "login rate limited",
315
+ attributes: { ip: key, limit: perIpOk ? "global" : "ip" },
316
+ });
289
317
  return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
290
318
  }
291
319
 
@@ -306,15 +334,52 @@ export function authRoutes(
306
334
  }
307
335
  const password = typeof body.password === "string" ? body.password : "";
308
336
  if (!password || !hash || !secret) {
337
+ failures?.add(1, { reason: "invalid_password", method: "password" });
338
+ log?.emit({
339
+ severityText: "WARN",
340
+ body: "login failed",
341
+ attributes: { ip: key },
342
+ });
309
343
  return c.json({ error: "Invalid credentials" }, 401);
310
344
  }
311
345
 
312
- const ok = await Bun.password.verify(password, hash).catch(() => false);
346
+ // A verify that THROWS is not a wrong password — it is a hash Bun cannot
347
+ // parse (a corrupt BRAIN_UI_PASSWORD_HASH locks the owner out of every
348
+ // login), and collapsing it into "invalid credentials" hid exactly that.
349
+ let ok = false;
350
+ let verifyError: unknown = null;
351
+ try {
352
+ ok = await Bun.password.verify(password, hash);
353
+ } catch (err) {
354
+ verifyError = err;
355
+ }
356
+ if (verifyError) {
357
+ failures?.add(1, { reason: "verify_error", method: "password" });
358
+ log?.emit({
359
+ severityText: "ERROR",
360
+ body: "password verification errored — check BRAIN_UI_PASSWORD_HASH",
361
+ attributes: {
362
+ error: verifyError instanceof Error ? verifyError.message : String(verifyError),
363
+ },
364
+ });
365
+ return c.json({ error: "Invalid credentials" }, 401);
366
+ }
313
367
  if (!ok) {
368
+ failures?.add(1, { reason: "invalid_password", method: "password" });
369
+ log?.emit({
370
+ severityText: "WARN",
371
+ body: "login failed",
372
+ attributes: { ip: key },
373
+ });
314
374
  return c.json({ error: "Invalid credentials" }, 401);
315
375
  }
316
376
 
317
377
  await issueSessionCookie(c, auth);
378
+ log?.emit({
379
+ severityText: "INFO",
380
+ body: "login succeeded",
381
+ attributes: { ip: key },
382
+ });
318
383
  return c.json({ ok: true });
319
384
  });
320
385