@schlessera/brain-ui-server 0.13.0 → 0.14.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 (182) hide show
  1. package/README.md +51 -12
  2. package/dist/agent/backend.d.ts +163 -37
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +281 -286
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts +46 -5
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +94 -35
  9. package/dist/app.js.map +1 -1
  10. package/dist/brain/client.d.ts +41 -30
  11. package/dist/brain/client.d.ts.map +1 -1
  12. package/dist/brain/client.js +138 -132
  13. package/dist/brain/client.js.map +1 -1
  14. package/dist/config/env.d.ts +104 -0
  15. package/dist/config/env.d.ts.map +1 -0
  16. package/dist/config/env.js +296 -0
  17. package/dist/config/env.js.map +1 -0
  18. package/dist/cron/scheduler.d.ts +16 -8
  19. package/dist/cron/scheduler.d.ts.map +1 -1
  20. package/dist/cron/scheduler.js +54 -53
  21. package/dist/cron/scheduler.js.map +1 -1
  22. package/dist/db/brain-db.d.ts +50 -0
  23. package/dist/db/brain-db.d.ts.map +1 -0
  24. package/dist/db/brain-db.js +75 -0
  25. package/dist/db/brain-db.js.map +1 -0
  26. package/dist/db/client.d.ts +8 -6
  27. package/dist/db/client.d.ts.map +1 -1
  28. package/dist/db/client.js +12 -25
  29. package/dist/db/client.js.map +1 -1
  30. package/dist/db/settings.d.ts +5 -4
  31. package/dist/db/settings.d.ts.map +1 -1
  32. package/dist/db/settings.js +9 -12
  33. package/dist/db/settings.js.map +1 -1
  34. package/dist/files/walker.d.ts +6 -7
  35. package/dist/files/walker.d.ts.map +1 -1
  36. package/dist/files/walker.js +6 -9
  37. package/dist/files/walker.js.map +1 -1
  38. package/dist/graph/reader.d.ts +6 -6
  39. package/dist/graph/reader.d.ts.map +1 -1
  40. package/dist/graph/reader.js +22 -12
  41. package/dist/graph/reader.js.map +1 -1
  42. package/dist/index.d.ts +15 -9
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +20 -10
  45. package/dist/index.js.map +1 -1
  46. package/dist/middleware/auth.d.ts +17 -6
  47. package/dist/middleware/auth.d.ts.map +1 -1
  48. package/dist/middleware/auth.js +34 -43
  49. package/dist/middleware/auth.js.map +1 -1
  50. package/dist/middleware/origin.d.ts +1 -2
  51. package/dist/middleware/origin.d.ts.map +1 -1
  52. package/dist/middleware/origin.js +1 -7
  53. package/dist/middleware/origin.js.map +1 -1
  54. package/dist/middleware/passkeys.d.ts +29 -5
  55. package/dist/middleware/passkeys.d.ts.map +1 -1
  56. package/dist/middleware/passkeys.js +47 -76
  57. package/dist/middleware/passkeys.js.map +1 -1
  58. package/dist/middleware/tailscale.d.ts +14 -11
  59. package/dist/middleware/tailscale.d.ts.map +1 -1
  60. package/dist/middleware/tailscale.js +11 -10
  61. package/dist/middleware/tailscale.js.map +1 -1
  62. package/dist/routes/brain.d.ts +9 -154
  63. package/dist/routes/brain.d.ts.map +1 -1
  64. package/dist/routes/brain.js +187 -188
  65. package/dist/routes/brain.js.map +1 -1
  66. package/dist/routes/files.d.ts +4 -88
  67. package/dist/routes/files.d.ts.map +1 -1
  68. package/dist/routes/files.js +74 -71
  69. package/dist/routes/files.js.map +1 -1
  70. package/dist/routes/graph.d.ts +4 -255
  71. package/dist/routes/graph.d.ts.map +1 -1
  72. package/dist/routes/graph.js +67 -64
  73. package/dist/routes/graph.js.map +1 -1
  74. package/dist/routes/health.d.ts +8 -22
  75. package/dist/routes/health.d.ts.map +1 -1
  76. package/dist/routes/health.js +10 -10
  77. package/dist/routes/health.js.map +1 -1
  78. package/dist/routes/models.d.ts +7 -90
  79. package/dist/routes/models.d.ts.map +1 -1
  80. package/dist/routes/models.js +53 -51
  81. package/dist/routes/models.js.map +1 -1
  82. package/dist/routes/providers.d.ts +5 -34
  83. package/dist/routes/providers.d.ts.map +1 -1
  84. package/dist/routes/providers.js +13 -11
  85. package/dist/routes/providers.js.map +1 -1
  86. package/dist/routes/sessions.d.ts +7 -78
  87. package/dist/routes/sessions.d.ts.map +1 -1
  88. package/dist/routes/sessions.js +35 -34
  89. package/dist/routes/sessions.js.map +1 -1
  90. package/dist/routes/share.d.ts +7 -60
  91. package/dist/routes/share.d.ts.map +1 -1
  92. package/dist/routes/share.js +80 -77
  93. package/dist/routes/share.js.map +1 -1
  94. package/dist/routes/voice.d.ts +8 -91
  95. package/dist/routes/voice.d.ts.map +1 -1
  96. package/dist/routes/voice.js +62 -59
  97. package/dist/routes/voice.js.map +1 -1
  98. package/dist/share/staging.d.ts +4 -4
  99. package/dist/share/staging.d.ts.map +1 -1
  100. package/dist/share/staging.js +7 -8
  101. package/dist/share/staging.js.map +1 -1
  102. package/dist/voice/deepgram-token.d.ts +1 -1
  103. package/dist/voice/deepgram-token.d.ts.map +1 -1
  104. package/dist/voice/deepgram-token.js +1 -2
  105. package/dist/voice/deepgram-token.js.map +1 -1
  106. package/dist/voice/keyterm-builder.d.ts +23 -8
  107. package/dist/voice/keyterm-builder.d.ts.map +1 -1
  108. package/dist/voice/keyterm-builder.js +67 -43
  109. package/dist/voice/keyterm-builder.js.map +1 -1
  110. package/dist/voice/speech-providers.d.ts +6 -4
  111. package/dist/voice/speech-providers.d.ts.map +1 -1
  112. package/dist/voice/speech-providers.js +28 -25
  113. package/dist/voice/speech-providers.js.map +1 -1
  114. package/dist/ws/bridge.d.ts.map +1 -1
  115. package/dist/ws/bridge.js +2 -3
  116. package/dist/ws/bridge.js.map +1 -1
  117. package/dist/ws/clients.d.ts +18 -10
  118. package/dist/ws/clients.d.ts.map +1 -1
  119. package/dist/ws/clients.js +35 -37
  120. package/dist/ws/clients.js.map +1 -1
  121. package/dist/ws/connection.d.ts.map +1 -1
  122. package/dist/ws/connection.js +5 -7
  123. package/dist/ws/connection.js.map +1 -1
  124. package/dist/ws/dispatch.d.ts.map +1 -1
  125. package/dist/ws/dispatch.js +1 -2
  126. package/dist/ws/dispatch.js.map +1 -1
  127. package/dist/ws/host.d.ts +16 -12
  128. package/dist/ws/host.d.ts.map +1 -1
  129. package/dist/ws/host.js +14 -23
  130. package/dist/ws/host.js.map +1 -1
  131. package/dist/ws/routing.d.ts +2 -1
  132. package/dist/ws/routing.d.ts.map +1 -1
  133. package/dist/ws/routing.js +5 -6
  134. package/dist/ws/routing.js.map +1 -1
  135. package/dist/ws/run-session.js +1 -1
  136. package/dist/ws/run-session.js.map +1 -1
  137. package/dist/ws/session-catalog.d.ts +2 -2
  138. package/dist/ws/session-catalog.d.ts.map +1 -1
  139. package/dist/ws/session-catalog.js +2 -3
  140. package/dist/ws/session-catalog.js.map +1 -1
  141. package/package.json +8 -5
  142. package/src/agent/backend.ts +509 -340
  143. package/src/app.ts +157 -36
  144. package/src/brain/client.ts +166 -143
  145. package/src/config/env.ts +401 -0
  146. package/src/cron/scheduler.ts +77 -59
  147. package/src/db/brain-db.ts +104 -0
  148. package/src/db/client.ts +12 -31
  149. package/src/db/settings.ts +12 -14
  150. package/src/files/walker.ts +6 -10
  151. package/src/graph/reader.ts +26 -15
  152. package/src/index.ts +56 -17
  153. package/src/middleware/auth.ts +51 -45
  154. package/src/middleware/origin.ts +1 -8
  155. package/src/middleware/passkeys.ts +72 -68
  156. package/src/middleware/tailscale.ts +22 -17
  157. package/src/routes/brain.ts +31 -30
  158. package/src/routes/files.ts +10 -7
  159. package/src/routes/graph.ts +9 -6
  160. package/src/routes/health.ts +17 -10
  161. package/src/routes/models.ts +19 -15
  162. package/src/routes/providers.ts +14 -15
  163. package/src/routes/sessions.ts +18 -11
  164. package/src/routes/share.ts +35 -24
  165. package/src/routes/voice.ts +78 -68
  166. package/src/share/staging.ts +10 -8
  167. package/src/voice/deepgram-token.ts +4 -2
  168. package/src/voice/keyterm-builder.ts +90 -56
  169. package/src/voice/speech-providers.ts +28 -24
  170. package/src/ws/bridge.ts +2 -3
  171. package/src/ws/clients.ts +38 -41
  172. package/src/ws/connection.ts +5 -7
  173. package/src/ws/dispatch.ts +1 -2
  174. package/src/ws/host.ts +24 -27
  175. package/src/ws/routing.ts +6 -10
  176. package/src/ws/run-session.ts +1 -1
  177. package/src/ws/session-catalog.ts +2 -3
  178. package/dist/ws/handler.d.ts +0 -34
  179. package/dist/ws/handler.d.ts.map +0 -1
  180. package/dist/ws/handler.js +0 -33
  181. package/dist/ws/handler.js.map +0 -1
  182. package/src/ws/handler.ts +0 -50
package/src/db/client.ts CHANGED
@@ -2,42 +2,23 @@ import { Database } from "bun:sqlite";
2
2
  import { readdirSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
4
 
5
- let db: Database | null = null;
6
- let configuredPath: string | null = null;
7
-
8
5
  /**
9
- * Override the database path before the first `getDb()` call (used by
10
- * `createApp({ dbPath })`). Configuring after the handle exists would silently
11
- * split state across two files — refuse instead.
6
+ * The UI's OWN SQLite database (sessions, passkeys, settings, cron runs)
7
+ * distinct from the brain database, which is opened read-only via
8
+ * src/db/brain-db.ts.
9
+ *
10
+ * No module-level handle: `createApp()` opens one per app instance and threads
11
+ * it to every consumer, so two apps with different configuration can coexist
12
+ * in one process and a test gets an isolated database by construction.
12
13
  */
13
- export function configureDb(dbPath: string): void {
14
- if (db && configuredPath !== dbPath) {
15
- throw new Error(
16
- "configureDb() called after the database was opened; set dbPath before the first use"
17
- );
18
- }
19
- configuredPath = dbPath;
20
- }
21
-
22
- export function getDb(): Database {
23
- if (!db) {
24
- const dbPath =
25
- configuredPath || process.env.DB_PATH || join(process.cwd(), "brain-ui.db");
26
- db = new Database(dbPath, { create: true });
27
- db.exec("PRAGMA journal_mode = WAL");
28
- db.exec("PRAGMA foreign_keys = ON");
29
- runMigrations(db);
30
- }
14
+ export function createUiDb(dbPath: string): Database {
15
+ const db = new Database(dbPath, { create: true });
16
+ db.exec("PRAGMA journal_mode = WAL");
17
+ db.exec("PRAGMA foreign_keys = ON");
18
+ runMigrations(db);
31
19
  return db;
32
20
  }
33
21
 
34
- export function closeDb() {
35
- if (db) {
36
- db.close();
37
- db = null;
38
- }
39
- }
40
-
41
22
  function runMigrations(database: Database) {
42
23
  // Ensure _migrations table exists
43
24
  database.exec(`
@@ -6,12 +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 { getDb } from "./client.js";
9
+ import type { Database } from "bun:sqlite";
10
10
 
11
11
  const HIDDEN_MODELS_KEY = "models.hidden";
12
12
 
13
- export function getSetting<T>(key: string, fallback: T): T {
14
- const row = getDb()
13
+ export function getSetting<T>(db: Database, key: string, fallback: T): T {
14
+ const row = db
15
15
  .query("SELECT value FROM settings WHERE key = ?")
16
16
  .get(key) as { value: string } | null;
17
17
  if (!row) return fallback;
@@ -23,24 +23,22 @@ export function getSetting<T>(key: string, fallback: T): T {
23
23
  }
24
24
  }
25
25
 
26
- export function setSetting(key: string, value: unknown): void {
27
- getDb()
28
- .prepare(
29
- `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
30
- ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
31
- )
32
- .run(key, JSON.stringify(value), Date.now());
26
+ export function setSetting(db: Database, key: string, value: unknown): void {
27
+ db.prepare(
28
+ `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
29
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
30
+ ).run(key, JSON.stringify(value), Date.now());
33
31
  }
34
32
 
35
33
  /** Profile ids the user keeps out of the model picker. */
36
- export function getHiddenModelIds(): string[] {
37
- const value = getSetting<unknown>(HIDDEN_MODELS_KEY, []);
34
+ export function getHiddenModelIds(db: Database): string[] {
35
+ const value = getSetting<unknown>(db, HIDDEN_MODELS_KEY, []);
38
36
  if (!Array.isArray(value)) return [];
39
37
  return value.filter((id): id is string => typeof id === "string");
40
38
  }
41
39
 
42
40
  /** Replace the hidden set (the client always sends the full list, not a delta). */
43
- export function setHiddenModelIds(ids: string[]): void {
41
+ export function setHiddenModelIds(db: Database, ids: string[]): void {
44
42
  const unique = [...new Set(ids.filter((id) => typeof id === "string" && id))];
45
- setSetting(HIDDEN_MODELS_KEY, unique);
43
+ setSetting(db, HIDDEN_MODELS_KEY, unique);
46
44
  }
@@ -25,10 +25,6 @@ const HARD_EXCLUDE_FILE_NAMES = new Set(
25
25
  ].map((n) => n.toLowerCase())
26
26
  );
27
27
 
28
- export function getBrainRoot(): string {
29
- return process.env.BRAIN_PATH || join(process.env.HOME || "/root", "brain");
30
- }
31
-
32
28
  export class PathEscapeError extends Error {
33
29
  constructor(rel: string) {
34
30
  super(`path_escape: ${rel}`);
@@ -54,7 +50,7 @@ export class TooLargeError extends Error {
54
50
  * Resolve a repo-relative path to an absolute path, rejecting any escape
55
51
  * outside the brain root (including via symlinks).
56
52
  */
57
- export async function safeResolve(rel: string, root = getBrainRoot()): Promise<string> {
53
+ export async function safeResolve(rel: string, root: string): Promise<string> {
58
54
  if (typeof rel !== "string") throw new PathEscapeError(String(rel));
59
55
  // Reject absolute paths and null bytes
60
56
  if (rel.startsWith("/") || rel.startsWith("\\") || rel.includes("\0")) {
@@ -123,7 +119,7 @@ function isHardExcluded(name: string, isDir: boolean): boolean {
123
119
  * List entries of a directory, applying ignore rules.
124
120
  * Always excludes: .git, node_modules, *.db*. Then applies .gitignore.
125
121
  */
126
- export async function listDirectory(rel: string, root = getBrainRoot()): Promise<FileEntry[]> {
122
+ export async function listDirectory(rel: string, root: string): Promise<FileEntry[]> {
127
123
  const abs = await safeResolve(rel, root);
128
124
  let s;
129
125
  try {
@@ -260,7 +256,7 @@ export interface FileContent {
260
256
  content?: string;
261
257
  }
262
258
 
263
- export async function readFileContent(rel: string, root = getBrainRoot()): Promise<FileContent> {
259
+ export async function readFileContent(rel: string, root: string): Promise<FileContent> {
264
260
  const abs = await safeResolve(rel, root);
265
261
  let s;
266
262
  try {
@@ -314,7 +310,7 @@ export async function readFileContent(rel: string, root = getBrainRoot()): Promi
314
310
  * Stream raw bytes of a file (for image/binary preview).
315
311
  * Returns the absolute path; caller uses Bun.file() to stream.
316
312
  */
317
- export async function resolveForRaw(rel: string, root = getBrainRoot()): Promise<{ abs: string; size: number; mime: string; kind: FileContentKind }> {
313
+ export async function resolveForRaw(rel: string, root: string): Promise<{ abs: string; size: number; mime: string; kind: FileContentKind }> {
318
314
  const abs = await safeResolve(rel, root);
319
315
  let s;
320
316
  try {
@@ -336,7 +332,7 @@ export async function resolveForRaw(rel: string, root = getBrainRoot()): Promise
336
332
  * If two files share a slug (rare), the first one encountered wins —
337
333
  * deterministic given the sorted directory traversal.
338
334
  */
339
- export async function buildWikilinkMap(root = getBrainRoot()): Promise<Record<string, string>> {
335
+ export async function buildWikilinkMap(root: string): Promise<Record<string, string>> {
340
336
  const matcher = await loadIgnore(root);
341
337
  const out: Record<string, string> = {};
342
338
 
@@ -397,7 +393,7 @@ export async function buildWikilinkMap(root = getBrainRoot()): Promise<Record<st
397
393
  * Return ancestor directories of a path, root-first (excluding root and the path itself).
398
394
  * resolve("a/b/c.md") -> { ancestors: ["a", "a/b"], exists, type }
399
395
  */
400
- export async function resolveAncestors(rel: string, root = getBrainRoot()): Promise<{ ancestors: string[]; exists: boolean; type?: "dir" | "file" }> {
396
+ export async function resolveAncestors(rel: string, root: string): Promise<{ ancestors: string[]; exists: boolean; type?: "dir" | "file" }> {
401
397
  const normalized = rel.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
402
398
  if (!normalized) return { ancestors: [], exists: true, type: "dir" };
403
399
  const parts = normalized.split("/");
@@ -14,9 +14,8 @@
14
14
  * are too interactive to shell out for.
15
15
  */
16
16
 
17
- import { Database } from "bun:sqlite";
18
- import { existsSync } from "fs";
19
- import { basename, join } from "path";
17
+ import type { Database } from "bun:sqlite";
18
+ import { basename } from "path";
20
19
  import type {
21
20
  GraphCommunityPayload,
22
21
  GraphEdgePayload,
@@ -25,7 +24,7 @@ import type {
25
24
  GraphNodePayload,
26
25
  GraphSubgraphResponse,
27
26
  } from "@schlessera/brain-ui-sdk/protocol";
28
- import { getBrainRoot } from "../files/walker.js";
27
+ import { BrainDbUnavailableError, openBrainDb } from "../db/brain-db.js";
29
28
 
30
29
  /** brain.db schema that first carries the graph tables. */
31
30
  export const MIN_SCHEMA_VERSION = 8;
@@ -59,8 +58,8 @@ export class GraphNotFoundError extends Error {
59
58
  }
60
59
 
61
60
  export interface GraphReadOptions {
62
- /** Defaults to the repo the rest of the server reads (BRAIN_PATH). */
63
- brainPath?: string;
61
+ /** The brain repo to read injected by the route layer from ServerConfig. */
62
+ brainPath: string;
64
63
  }
65
64
 
66
65
  export interface ClustersOptions extends GraphReadOptions {
@@ -89,13 +88,25 @@ export const DEFAULT_STALE_DAYS = 180;
89
88
 
90
89
  // --- db access -------------------------------------------------------------
91
90
 
92
- function openDb(brainPath?: string): Database {
93
- const dbPath = join(brainPath ?? getBrainRoot(), "brain.db");
94
- if (!existsSync(dbPath)) throw new GraphUnavailableError("schema");
95
- return new Database(dbPath, { readonly: true });
91
+ /**
92
+ * Every open goes through src/db/brain-db.ts (the package-wide brain.db
93
+ * chokepoint). A missing file or a schema older than the package's baseline
94
+ * maps onto the same "schema" unavailability the route layer already renders;
95
+ * the graph-specific v8 gate stays per-mode below, because the neighborhood
96
+ * mode deliberately works on repos that predate the graph tables.
97
+ */
98
+ function openDb(brainPath: string): Database {
99
+ try {
100
+ return openBrainDb(brainPath).db;
101
+ } catch (err) {
102
+ if (err instanceof BrainDbUnavailableError) {
103
+ throw new GraphUnavailableError("schema");
104
+ }
105
+ throw err;
106
+ }
96
107
  }
97
108
 
98
- function withDb<T>(brainPath: string | undefined, fn: (db: Database) => T): T {
109
+ function withDb<T>(brainPath: string, fn: (db: Database) => T): T {
99
110
  const db = openDb(brainPath);
100
111
  try {
101
112
  return fn(db);
@@ -423,7 +434,7 @@ function isStale(db: Database, computedAt: string): boolean {
423
434
  return computed < indexed;
424
435
  }
425
436
 
426
- export function getGraphMeta(opts: GraphReadOptions = {}): GraphMetaResponse {
437
+ export function getGraphMeta(opts: GraphReadOptions): GraphMetaResponse {
427
438
  let db: Database;
428
439
  try {
429
440
  db = openDb(opts.brainPath);
@@ -478,7 +489,7 @@ function unavailable(
478
489
 
479
490
  // --- modes -----------------------------------------------------------------
480
491
 
481
- export function getClusters(opts: ClustersOptions = {}): GraphSubgraphResponse {
492
+ export function getClusters(opts: ClustersOptions): GraphSubgraphResponse {
482
493
  return withDb(opts.brainPath, (db) => {
483
494
  requireComputedGraph(db, readMetadata(db));
484
495
 
@@ -528,7 +539,7 @@ export function getNeighborhood(opts: NeighborhoodOptions): GraphSubgraphRespons
528
539
  });
529
540
  }
530
541
 
531
- export function getDiscovery(opts: DiscoveryOptions = {}): GraphSubgraphResponse {
542
+ export function getDiscovery(opts: DiscoveryOptions): GraphSubgraphResponse {
532
543
  const maxDepth = clamp(opts.maxDepth ?? MAX_DISCOVERY_DEPTH, 1, MAX_DISCOVERY_DEPTH);
533
544
  const direction = opts.direction ?? "out";
534
545
 
@@ -605,7 +616,7 @@ export function getDiscovery(opts: DiscoveryOptions = {}): GraphSubgraphResponse
605
616
  });
606
617
  }
607
618
 
608
- export function getMaintenance(opts: MaintenanceOptions = {}): GraphMaintenanceResponse {
619
+ export function getMaintenance(opts: MaintenanceOptions): GraphMaintenanceResponse {
609
620
  const staleDays = clamp(opts.staleDays ?? DEFAULT_STALE_DAYS, 1, 3650);
610
621
 
611
622
  return withDb(opts.brainPath, (db) => {
package/src/index.ts CHANGED
@@ -5,39 +5,78 @@
5
5
  * decides the port/idleTimeout, wires SIGTERM, and injects deployment-only
6
6
  * pieces (static client build, PNG/PDF renderer). Everything else — routes,
7
7
  * auth, passkeys, the WebSocket turn coordinator, the session catalog — lives
8
- * behind createApp().
8
+ * behind createApp(), which returns a handle carrying the app's own resources
9
+ * (config, database, ws host) instead of module-level singletons.
9
10
  */
10
- export { createApp, type CreateAppOptions, type AppRenderer } from "./app.js";
11
+ export {
12
+ createApp,
13
+ type CreateAppOptions,
14
+ type BrainUiApp,
15
+ type AppRenderer,
16
+ } from "./app.js";
11
17
 
12
- // Process lifecycle helpers for the bin entry.
13
- export { websocket, cancelActiveTurn, isTurnActive } from "./ws/handler.js";
14
- export { getDb, closeDb, configureDb } from "./db/client.js";
18
+ // Configuration: the package's single environment chokepoint. The descriptor
19
+ // (ENV_VARS) is the artifact the env-parity gate diffs against documentation.
20
+ export {
21
+ ENV_VARS,
22
+ resolveServerConfig,
23
+ type EnvVarDescriptor,
24
+ type ServerConfig,
25
+ type AuthConfig,
26
+ type WebAuthnConfig,
27
+ type AgentConfig,
28
+ type VoiceConfig,
29
+ } from "./config/env.js";
15
30
 
16
31
  // Boot-time diagnostics (fail fast on a bad AGENT_BACKEND / profile config,
17
32
  // log the resolved auth mode).
18
- export { getBackends, getBackendsInfo } from "./agent/backend.js";
19
- export { resolveAuthMode, type AuthMode } from "./middleware/auth.js";
33
+ export {
34
+ assertBackendResolvable,
35
+ createBackendRegistry,
36
+ createStaticBackendRegistry,
37
+ type BackendRegistry,
38
+ type ModelDiscoverySource,
39
+ type ModelDiscoveryState,
40
+ } from "./agent/backend.js";
41
+ export {
42
+ resolveAuthMode,
43
+ type AuthMode,
44
+ type AuthRuntime,
45
+ } from "./middleware/auth.js";
46
+
47
+ // The app's own SQLite database (sessions, passkeys, settings).
48
+ export { createUiDb } from "./db/client.js";
49
+
50
+ // Brain database access (read-only, schema-gated) for embedders adding
51
+ // their own readers.
52
+ export {
53
+ openBrainDb,
54
+ withBrainDb,
55
+ BrainDbUnavailableError,
56
+ MIN_BRAIN_SCHEMA_VERSION,
57
+ } from "./db/brain-db.js";
20
58
 
21
59
  // WebSocket internals for embedders and tests.
60
+ export { WsHost, type WsHostOptions } from "./ws/host.js";
61
+ export { createWsUpgrade, websocket } from "./ws/connection.js";
62
+ export { handleClientMessage } from "./ws/dispatch.js";
63
+ export { resolveTurnTarget } from "./ws/routing.js";
22
64
  export {
23
- WsHost,
24
- type WsHostOptions,
25
- defaultWsHost,
26
- configureWsHost,
27
- handleClientMessage,
28
- resetForTests,
29
- resolveTurnTarget,
30
65
  createSessionCatalog,
31
66
  type SessionCatalog,
32
- } from "./ws/handler.js";
67
+ } from "./ws/session-catalog.js";
33
68
 
34
69
  // Brain repo access (spawned CLI wrapper) — useful for embedders that add
35
70
  // their own routes on top.
36
- export * as brainClient from "./brain/client.js";
71
+ export { createBrainClient, type BrainClient } from "./brain/client.js";
37
72
 
38
73
  // Share staging: a deployment can sweep expired staging dirs at boot; the
39
74
  // intake route also sweeps opportunistically on every share.
40
75
  export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
41
76
 
42
77
  // Voice keyterm cache rebuild (used by deployments after `brain sync`).
43
- export { buildKeyterms, writeCache } from "./voice/keyterm-builder.js";
78
+ export {
79
+ buildKeyterms,
80
+ writeCache,
81
+ type KeytermSettings,
82
+ } from "./voice/keyterm-builder.js";
@@ -2,6 +2,7 @@ import { Hono } from "hono";
2
2
  import type { Context, MiddlewareHandler } from "hono";
3
3
  import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
4
4
  import { isTailscaleAllowed, clientIp } from "./tailscale.js";
5
+ import type { AuthConfig } from "../config/env.js";
5
6
 
6
7
  /**
7
8
  * Authentication for a remote surface to an agent with write access to the
@@ -20,10 +21,21 @@ import { isTailscaleAllowed, clientIp } from "./tailscale.js";
20
21
  * explicit BRAIN_UI_DANGEROUSLY_DISABLE_AUTH=1 escape hatch.
21
22
  *
22
23
  * Default: `password` when BRAIN_UI_PASSWORD_HASH is set, else `tailscale`.
24
+ *
25
+ * All configuration is injected as the resolved {@link AuthRuntime} — this
26
+ * module never reads the environment, so two apps with different auth
27
+ * configuration can coexist and tests vary it without global mutation. The
28
+ * validation semantics themselves are unchanged.
23
29
  */
24
30
 
25
31
  export type AuthMode = "password" | "tailscale" | "proxy" | "none";
26
32
 
33
+ /** What the auth layer needs from the resolved server config. */
34
+ export interface AuthRuntime extends AuthConfig {
35
+ /** Bind host, for the loopback check on AUTH_MODE=none. */
36
+ host: string;
37
+ }
38
+
27
39
  const COOKIE_NAME = "brain_ui_session";
28
40
  const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
29
41
  export const LOGIN_RATE_LIMIT = 5; // attempts per window, per client IP
@@ -35,22 +47,15 @@ const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
35
47
  // verify cost, makes online guessing infeasible without being a lockout an
36
48
  // attacker could weaponize to deny the owner access.
37
49
  export const GLOBAL_LOGIN_RATE_LIMIT = 20; // attempts per window, all IPs combined
38
- const DEFAULT_PROXY_HEADER = "x-forwarded-user";
39
50
 
40
- export function resolveAuthMode(): AuthMode {
41
- const explicit = process.env.AUTH_MODE?.trim().toLowerCase();
42
- if (
43
- explicit === "password" ||
44
- explicit === "tailscale" ||
45
- explicit === "proxy" ||
46
- explicit === "none"
47
- ) {
48
- return explicit;
49
- }
50
- if (explicit) {
51
- console.warn(`[auth] Unknown AUTH_MODE="${explicit}"; auto-detecting instead.`);
51
+ export function resolveAuthMode(auth: AuthRuntime): AuthMode {
52
+ if (auth.mode) return auth.mode;
53
+ if (auth.invalidMode) {
54
+ console.warn(
55
+ `[auth] Unknown AUTH_MODE="${auth.invalidMode}"; auto-detecting instead.`
56
+ );
52
57
  }
53
- if (process.env.BRAIN_UI_PASSWORD_HASH) return "password";
58
+ if (auth.passwordHash) return "password";
54
59
  return "tailscale";
55
60
  }
56
61
 
@@ -58,18 +63,18 @@ export function resolveAuthMode(): AuthMode {
58
63
  * Validate the auth configuration at startup. Throws (refusing to boot) on an
59
64
  * unsafe or unusable configuration.
60
65
  */
61
- export function assertAuthConfig(mode: AuthMode): void {
62
- const host = process.env.HOST ?? "";
66
+ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime): void {
67
+ const host = auth.host;
63
68
  const loopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
64
69
 
65
70
  if (mode === "password") {
66
- if (!process.env.BRAIN_UI_PASSWORD_HASH) {
71
+ if (!auth.passwordHash) {
67
72
  throw new Error(
68
73
  "AUTH_MODE=password requires BRAIN_UI_PASSWORD_HASH. Generate one with:\n" +
69
74
  " bun -e 'console.log(await Bun.password.hash(process.argv[1]))' 'your-password'"
70
75
  );
71
76
  }
72
- if (!process.env.COOKIE_SECRET) {
77
+ if (!auth.cookieSecret) {
73
78
  throw new Error(
74
79
  "AUTH_MODE=password requires COOKIE_SECRET (a long random string used to " +
75
80
  "sign the session cookie). Generate one with:\n openssl rand -hex 32"
@@ -81,7 +86,7 @@ export function assertAuthConfig(mode: AuthMode): void {
81
86
  // difference between "auth required" and "agent with file-write access
82
87
  // exposed to the network".
83
88
  if (mode === "none" && !loopback) {
84
- if (process.env.BRAIN_UI_DANGEROUSLY_DISABLE_AUTH === "1") {
89
+ if (auth.dangerouslyDisableAuth) {
85
90
  console.warn(
86
91
  "[auth] AUTH_MODE=none on a non-loopback host, allowed by " +
87
92
  "BRAIN_UI_DANGEROUSLY_DISABLE_AUTH=1 — every network peer has full " +
@@ -100,7 +105,7 @@ export function assertAuthConfig(mode: AuthMode): void {
100
105
  }
101
106
 
102
107
  if (mode === "proxy") {
103
- if (process.env.TRUST_PROXY !== "1") {
108
+ if (!auth.trustProxy) {
104
109
  throw new Error(
105
110
  "AUTH_MODE=proxy requires TRUST_PROXY=1 — the proxy-auth header is only " +
106
111
  "trustworthy when a fronting proxy is guaranteed to set it and strip " +
@@ -108,7 +113,7 @@ export function assertAuthConfig(mode: AuthMode): void {
108
113
  );
109
114
  }
110
115
  console.log(
111
- `[auth] mode: proxy (trusting header "${proxyHeaderName()}"; ensure your ` +
116
+ `[auth] mode: proxy (trusting header "${auth.proxyAuthHeader}"; ensure your ` +
112
117
  "upstream proxy sets it and strips any client-supplied copy)"
113
118
  );
114
119
  return;
@@ -117,11 +122,11 @@ export function assertAuthConfig(mode: AuthMode): void {
117
122
  }
118
123
 
119
124
  /** Middleware guarding /api/* according to the resolved mode. */
120
- export function authGuard(mode: AuthMode): MiddlewareHandler {
125
+ export function authGuard(mode: AuthMode, auth: AuthRuntime): MiddlewareHandler {
121
126
  switch (mode) {
122
127
  case "tailscale":
123
128
  return async (c, next) => {
124
- if (isTailscaleAllowed(c, process.env.TRUST_PROXY === "1")) {
129
+ if (isTailscaleAllowed(c, auth.trustProxy, auth.trustProxyHops)) {
125
130
  await next();
126
131
  } else {
127
132
  return c.json({ error: "VPN access required" }, 403);
@@ -129,7 +134,7 @@ export function authGuard(mode: AuthMode): MiddlewareHandler {
129
134
  };
130
135
  case "proxy":
131
136
  return async (c, next) => {
132
- if (hasProxyAuth(c)) {
137
+ if (hasProxyAuth(c, auth)) {
133
138
  await next();
134
139
  } else {
135
140
  return c.json(
@@ -140,7 +145,7 @@ export function authGuard(mode: AuthMode): MiddlewareHandler {
140
145
  };
141
146
  case "password":
142
147
  return async (c, next) => {
143
- if (await hasValidSession(c)) {
148
+ if (await hasValidSession(c, auth)) {
144
149
  await next();
145
150
  } else {
146
151
  return c.json(
@@ -163,16 +168,20 @@ export function authGuard(mode: AuthMode): MiddlewareHandler {
163
168
  * middleware may sit on the WS route (immutable-header errors). Mirrors
164
169
  * {@link authGuard} without emitting a response.
165
170
  */
166
- export async function isWsAuthorized(c: Context, mode: AuthMode): Promise<boolean> {
171
+ export async function isWsAuthorized(
172
+ c: Context,
173
+ mode: AuthMode,
174
+ auth: AuthRuntime
175
+ ): Promise<boolean> {
167
176
  switch (mode) {
168
177
  case "none":
169
178
  return true;
170
179
  case "tailscale":
171
- return isTailscaleAllowed(c, process.env.TRUST_PROXY === "1");
180
+ return isTailscaleAllowed(c, auth.trustProxy, auth.trustProxyHops);
172
181
  case "proxy":
173
- return hasProxyAuth(c);
182
+ return hasProxyAuth(c, auth);
174
183
  case "password":
175
- return hasValidSession(c);
184
+ return hasValidSession(c, auth);
176
185
  }
177
186
  }
178
187
 
@@ -183,8 +192,8 @@ export async function isWsAuthorized(c: Context, mode: AuthMode): Promise<boolea
183
192
  * both password login and passkey login (middleware/passkeys.ts) call this, so
184
193
  * authGuard / isWsAuthorized / TTL semantics stay identical across methods.
185
194
  */
186
- export async function issueSessionCookie(c: Context): Promise<void> {
187
- const secret = process.env.COOKIE_SECRET ?? "";
195
+ export async function issueSessionCookie(c: Context, auth: AuthRuntime): Promise<void> {
196
+ const secret = auth.cookieSecret ?? "";
188
197
  await setSignedCookie(c, COOKIE_NAME, String(Date.now()), secret, {
189
198
  httpOnly: true,
190
199
  sameSite: "Strict",
@@ -197,8 +206,8 @@ export async function issueSessionCookie(c: Context): Promise<void> {
197
206
  });
198
207
  }
199
208
 
200
- async function hasValidSession(c: Context): Promise<boolean> {
201
- const secret = process.env.COOKIE_SECRET ?? "";
209
+ async function hasValidSession(c: Context, auth: AuthRuntime): Promise<boolean> {
210
+ const secret = auth.cookieSecret ?? "";
202
211
  if (!secret) return false;
203
212
  try {
204
213
  const value = await getSignedCookie(c, secret, COOKIE_NAME);
@@ -217,17 +226,13 @@ async function hasValidSession(c: Context): Promise<boolean> {
217
226
 
218
227
  // --- proxy mode helpers ---
219
228
 
220
- function proxyHeaderName(): string {
221
- return (process.env.PROXY_AUTH_HEADER || DEFAULT_PROXY_HEADER).toLowerCase();
222
- }
223
-
224
- function hasProxyAuth(c: Context): boolean {
229
+ function hasProxyAuth(c: Context, auth: AuthRuntime): boolean {
225
230
  // The proxy-auth header is only meaningful when a trusted proxy fronts the app
226
231
  // and TRUST_PROXY says so; otherwise a client could set it directly. Gate on
227
- // TRUST_PROXY, consistent with tailscale-mode XFF trust. (assertAuthConfig
232
+ // trustProxy, consistent with tailscale-mode XFF trust. (assertAuthConfig
228
233
  // already refuses to boot proxy mode without it — this is belt-and-braces.)
229
- if (process.env.TRUST_PROXY !== "1") return false;
230
- const user = c.req.header(proxyHeaderName());
234
+ if (!auth.trustProxy) return false;
235
+ const user = c.req.header(auth.proxyAuthHeader);
231
236
  return !!user && user.trim().length > 0;
232
237
  }
233
238
 
@@ -259,6 +264,7 @@ export function consumeLoginToken(key: string, limit: number): boolean {
259
264
  /** Login/logout routes. Only functional in `password` mode. */
260
265
  export function authRoutes(
261
266
  mode: AuthMode,
267
+ auth: AuthRuntime,
262
268
  deps: {
263
269
  /**
264
270
  * When provided and returning true, password login is refused (the app
@@ -276,7 +282,7 @@ export function authRoutes(
276
282
  return c.json({ error: "Password login is not enabled" }, 400);
277
283
  }
278
284
 
279
- const key = clientIp(c, process.env.TRUST_PROXY === "1") || "unknown";
285
+ const key = clientIp(c, auth.trustProxy, auth.trustProxyHops) || "unknown";
280
286
  const perIpOk = consumeLoginToken(`ip:${key}`, LOGIN_RATE_LIMIT);
281
287
  const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
282
288
  if (!perIpOk || !globalOk) {
@@ -290,8 +296,8 @@ export function authRoutes(
290
296
  );
291
297
  }
292
298
 
293
- const hash = process.env.BRAIN_UI_PASSWORD_HASH ?? "";
294
- const secret = process.env.COOKIE_SECRET ?? "";
299
+ const hash = auth.passwordHash ?? "";
300
+ const secret = auth.cookieSecret ?? "";
295
301
  let body: { password?: unknown };
296
302
  try {
297
303
  body = await c.req.json();
@@ -308,7 +314,7 @@ export function authRoutes(
308
314
  return c.json({ error: "Invalid credentials" }, 401);
309
315
  }
310
316
 
311
- await issueSessionCookie(c);
317
+ await issueSessionCookie(c, auth);
312
318
  return c.json({ ok: true });
313
319
  });
314
320
 
@@ -13,14 +13,7 @@ import type { Context } from "hono";
13
13
  * The check is free because the routes that use it are only ever called by the
14
14
  * app itself, same-origin. Same shape as the WebSocket upgrade's origin guard.
15
15
  */
16
- export function allowedOriginList(): string[] {
17
- return (process.env.ALLOWED_ORIGINS ?? "")
18
- .split(",")
19
- .map((origin) => origin.trim())
20
- .filter(Boolean);
21
- }
22
-
23
- export function isSameOriginRequest(c: Context, allowed = allowedOriginList()): boolean {
16
+ export function isSameOriginRequest(c: Context, allowed: string[]): boolean {
24
17
  // Chromium and Firefox send this, and it is not settable by script.
25
18
  const site = c.req.header("sec-fetch-site");
26
19
  if (site) return site === "same-origin" || site === "none";