@schlessera/brain-ui-server 0.13.1 → 0.15.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
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Context } from "hono";
3
+ import type { Database } from "bun:sqlite";
3
4
  import {
4
5
  generateAuthenticationOptions,
5
6
  generateRegistrationOptions,
@@ -13,13 +14,14 @@ import type {
13
14
  } from "@simplewebauthn/server";
14
15
  import {
15
16
  type AuthMode,
17
+ type AuthRuntime,
16
18
  issueSessionCookie,
17
19
  consumeLoginToken,
18
20
  LOGIN_RATE_LIMIT,
19
21
  GLOBAL_LOGIN_RATE_LIMIT,
20
22
  } from "./auth.js";
21
23
  import { clientIp } from "./tailscale.js";
22
- import { getDb } from "../db/client.js";
24
+ import type { WebAuthnConfig } from "../config/env.js";
23
25
 
24
26
  /**
25
27
  * WebAuthn passkeys as an extension of `password` mode: the password bootstraps
@@ -27,27 +29,33 @@ import { getDb } from "../db/client.js";
27
29
  * login. A successful assertion mints the same session cookie as password login
28
30
  * (issueSessionCookie), so authGuard / isWsAuthorized / TTL are untouched.
29
31
  *
30
- * Self-contained on purpose this module (plus its auth.ts imports) is slated
31
- * to move wholesale into @schlessera/ui-server.
32
+ * Everything environmental is injected as a {@link PasskeyContext} the
33
+ * resolved WebAuthn identity, the auth runtime (for trust/proxy semantics and
34
+ * the cookie secret), the origin allowlist, and the app's own database.
32
35
  *
33
36
  * Credentials are RP-scoped: a credential registered on localhost does not
34
37
  * exist for the production domain and vice versa. Rows carry rp_id so all
35
38
  * environments share the table.
36
39
  */
37
40
 
41
+ /** Injected per-app dependencies for every passkey route and helper. */
42
+ export interface PasskeyContext {
43
+ db: Database;
44
+ webauthn: WebAuthnConfig;
45
+ auth: AuthRuntime;
46
+ /** ALLOWED_ORIGINS — split-topology origins also valid for ceremonies. */
47
+ allowedOrigins: string[];
48
+ }
49
+
38
50
  const CHALLENGE_TTL_MS = 120_000; // > the 60s ceremony timeout
39
51
  // Pending challenges are unauthenticated state; cap and evict oldest-first so
40
52
  // login-options spam can't grow memory. Losing one costs a retried ceremony.
41
53
  const MAX_PENDING_CHALLENGES = 100;
42
54
  const OPTIONS_RATE_LIMIT = 10; // login-options per IP per minute (own bucket)
43
- // Branding/identity are config-injected with the historical values as
44
- // defaults, so existing deployments keep their credentials.
45
- function rpName(): string {
46
- return process.env.WEBAUTHN_RP_NAME || "Brain UI";
47
- }
48
- // The single "user" every passkey belongs to. Stable across registrations so
49
- // an authenticator overwrites its existing entry instead of stacking
50
- // duplicates for the same site.
55
+
56
+ // The single "user" every passkey belongs to (webauthn.userId). Stable across
57
+ // registrations so an authenticator overwrites its existing entry instead of
58
+ // stacking duplicates for the same site.
51
59
  //
52
60
  // WARNING: the user handle is part of the WebAuthn wire contract — it is
53
61
  // burned into every resident credential at registration. Changing
@@ -55,25 +63,20 @@ function rpName(): string {
55
63
  // (authenticators will present a handle the server no longer recognizes as
56
64
  // the same user and re-registration stacks a second entry). Set it before the
57
65
  // first passkey is registered and never change it.
58
- function userName(): string {
59
- return process.env.WEBAUTHN_USER_NAME || "owner";
66
+ function userId(webauthn: WebAuthnConfig): Uint8Array<ArrayBuffer> {
67
+ return new TextEncoder().encode(webauthn.userId) as Uint8Array<ArrayBuffer>;
60
68
  }
69
+
61
70
  /** WebAuthn caps `user.id` at 64 bytes; browsers reject anything longer. */
62
71
  const MAX_USER_ID_BYTES = 64;
63
72
 
64
- function userId(): Uint8Array<ArrayBuffer> {
65
- return new TextEncoder().encode(
66
- process.env.WEBAUTHN_USER_ID || "brain-ui-owner"
67
- ) as Uint8Array<ArrayBuffer>;
68
- }
69
-
70
73
  /**
71
74
  * Refuse to boot on a WebAuthn user handle the browser would reject. Called
72
75
  * from createApp(): an oversized handle otherwise produces registration
73
76
  * options that look fine server-side and fail silently in every browser.
74
77
  */
75
- export function assertPasskeyConfig(): void {
76
- const bytes = userId().byteLength;
78
+ export function assertPasskeyConfig(webauthn: WebAuthnConfig): void {
79
+ const bytes = userId(webauthn).byteLength;
77
80
  if (bytes > MAX_USER_ID_BYTES) {
78
81
  throw new Error(
79
82
  `WEBAUTHN_USER_ID is ${bytes} bytes; WebAuthn allows at most ${MAX_USER_ID_BYTES}. ` +
@@ -136,13 +139,6 @@ function challengeFromResponse(response: {
136
139
 
137
140
  // --- RP / origin resolution ---
138
141
 
139
- function envList(name: string): string[] {
140
- return (process.env[name] ?? "")
141
- .split(",")
142
- .map((s) => s.trim())
143
- .filter(Boolean);
144
- }
145
-
146
142
  function isLoopbackHostname(hostname: string): boolean {
147
143
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
148
144
  }
@@ -153,7 +149,7 @@ function isLoopbackHostname(hostname: string): boolean {
153
149
  * unconfigured. WEBAUTHN_ORIGINS / WEBAUTHN_RP_ID remain as overrides for
154
150
  * proxies that rewrite Host.
155
151
  */
156
- function resolveRp(c: Context): { rpID: string; origin: string } | null {
152
+ function resolveRp(c: Context, ctx: PasskeyContext): { rpID: string; origin: string } | null {
157
153
  const origin = c.req.header("origin");
158
154
  if (!origin) return null;
159
155
 
@@ -165,22 +161,21 @@ function resolveRp(c: Context): { rpID: string; origin: string } | null {
165
161
  }
166
162
 
167
163
  const allowed =
168
- envList("WEBAUTHN_ORIGINS").includes(origin) ||
169
- envList("ALLOWED_ORIGINS").includes(origin) ||
164
+ ctx.webauthn.origins.includes(origin) ||
165
+ ctx.allowedOrigins.includes(origin) ||
170
166
  url.host === c.req.header("host") ||
171
167
  // Dev escape hatch: the vite proxy sets changeOrigin, so the server sees
172
168
  // Host localhost:3000 but Origin http://localhost:5173. Opt-in via an
173
169
  // explicit flag — NOT NODE_ENV, whose absence must never widen the RP.
174
- (process.env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN === "1" &&
175
- isLoopbackHostname(url.hostname));
170
+ (ctx.webauthn.allowLoopbackOrigin && isLoopbackHostname(url.hostname));
176
171
  if (!allowed) return null;
177
172
 
178
- return { rpID: process.env.WEBAUTHN_RP_ID ?? url.hostname, origin };
173
+ return { rpID: ctx.webauthn.rpId ?? url.hostname, origin };
179
174
  }
180
175
 
181
176
  /** rpID for endpoints where same-origin GETs legitimately omit Origin. */
182
- function rpIdForRead(c: Context): string {
183
- if (process.env.WEBAUTHN_RP_ID) return process.env.WEBAUTHN_RP_ID;
177
+ function rpIdForRead(c: Context, ctx: PasskeyContext): string {
178
+ if (ctx.webauthn.rpId) return ctx.webauthn.rpId;
184
179
  const origin = c.req.header("origin");
185
180
  if (origin) {
186
181
  try {
@@ -224,14 +219,14 @@ function toSummary(row: CredentialRow) {
224
219
  };
225
220
  }
226
221
 
227
- function credentialsForRp(rpID: string): CredentialRow[] {
228
- return getDb()
222
+ function credentialsForRp(db: Database, rpID: string): CredentialRow[] {
223
+ return db
229
224
  .query("SELECT * FROM passkey_credentials WHERE rp_id = ?")
230
225
  .all(rpID) as CredentialRow[];
231
226
  }
232
227
 
233
- function credentialById(id: string): CredentialRow | null {
234
- return getDb()
228
+ function credentialById(db: Database, id: string): CredentialRow | null {
229
+ return db
235
230
  .query("SELECT * FROM passkey_credentials WHERE id = ?")
236
231
  .get(id) as CredentialRow | null;
237
232
  }
@@ -263,11 +258,11 @@ function notEnabled(c: Context) {
263
258
  * until it has its own credential. Host-derived rpID is fine here: a forged
264
259
  * Host can at most re-enable password auth, which still requires the password.
265
260
  */
266
- export function passwordLoginDisabled(c: Context): boolean {
267
- if (process.env.BRAIN_UI_ALLOW_PASSWORD === "1") return false;
268
- const row = getDb()
261
+ export function passwordLoginDisabled(c: Context, ctx: PasskeyContext): boolean {
262
+ if (ctx.auth.allowPassword) return false;
263
+ const row = ctx.db
269
264
  .query("SELECT COUNT(*) AS n FROM passkey_credentials WHERE rp_id = ?")
270
- .get(rpIdForRead(c)) as { n: number };
265
+ .get(rpIdForRead(c, ctx)) as { n: number };
271
266
  return row.n > 0;
272
267
  }
273
268
 
@@ -275,7 +270,11 @@ export function passwordLoginDisabled(c: Context): boolean {
275
270
  * Unauthenticated passkey routes. Mount BEFORE the auth guard, next to
276
271
  * authRoutes.
277
272
  */
278
- export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hono {
273
+ export function passkeyPublicRoutes(
274
+ mode: AuthMode,
275
+ ctx: PasskeyContext,
276
+ deps: PasskeyDeps = {}
277
+ ): Hono {
279
278
  const verifyAuthentication = deps.verifyAuthenticationResponse ?? realVerifyAuthentication;
280
279
  const now = deps.now ?? Date.now;
281
280
  const app = new Hono();
@@ -286,21 +285,21 @@ export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hon
286
285
  if (mode !== "password") {
287
286
  return c.json({ password: false, passkey: false });
288
287
  }
289
- const row = getDb()
288
+ const row = ctx.db
290
289
  .query("SELECT COUNT(*) AS n FROM passkey_credentials WHERE rp_id = ?")
291
- .get(rpIdForRead(c)) as { n: number };
292
- return c.json({ password: !passwordLoginDisabled(c), passkey: row.n > 0 });
290
+ .get(rpIdForRead(c, ctx)) as { n: number };
291
+ return c.json({ password: !passwordLoginDisabled(c, ctx), passkey: row.n > 0 });
293
292
  });
294
293
 
295
294
  app.post("/auth/passkey/login-options", async (c) => {
296
295
  if (mode !== "password") return notEnabled(c);
297
- const ip = clientIp(c, process.env.TRUST_PROXY === "1") || "unknown";
296
+ const ip = clientIp(c, ctx.auth.trustProxy, ctx.auth.trustProxyHops) || "unknown";
298
297
  // Own bucket: conditional-UI mounts fire this on every login-screen load
299
298
  // and must not starve real verify attempts of their shared budget.
300
299
  if (!consumeLoginToken(`pk-opt:${ip}`, OPTIONS_RATE_LIMIT)) {
301
300
  return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
302
301
  }
303
- const rp = resolveRp(c);
302
+ const rp = resolveRp(c, ctx);
304
303
  if (!rp) return c.json({ error: "Origin not allowed" }, 400);
305
304
 
306
305
  // Empty allowCredentials + discoverable credentials = usernameless login.
@@ -315,14 +314,14 @@ export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hon
315
314
 
316
315
  app.post("/auth/passkey/login-verify", async (c) => {
317
316
  if (mode !== "password") return notEnabled(c);
318
- const ip = clientIp(c, process.env.TRUST_PROXY === "1") || "unknown";
317
+ const ip = clientIp(c, ctx.auth.trustProxy, ctx.auth.trustProxyHops) || "unknown";
319
318
  // Same buckets as password login: one combined online-guess budget.
320
319
  const perIpOk = consumeLoginToken(`ip:${ip}`, LOGIN_RATE_LIMIT);
321
320
  const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
322
321
  if (!perIpOk || !globalOk) {
323
322
  return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
324
323
  }
325
- const rp = resolveRp(c);
324
+ const rp = resolveRp(c, ctx);
326
325
  if (!rp) return c.json({ error: "Origin not allowed" }, 400);
327
326
 
328
327
  let response: AuthenticationResponseJSON;
@@ -334,7 +333,8 @@ export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hon
334
333
 
335
334
  const fail = () => c.json({ error: "Passkey verification failed" }, 401);
336
335
 
337
- const row = typeof response?.id === "string" ? credentialById(response.id) : null;
336
+ const row =
337
+ typeof response?.id === "string" ? credentialById(ctx.db, response.id) : null;
338
338
  if (!row || row.rp_id !== rp.rpID) return fail();
339
339
 
340
340
  const challenge = challengeFromResponse(response);
@@ -351,7 +351,7 @@ export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hon
351
351
  credential: toWebAuthnCredential(row),
352
352
  });
353
353
  if (!result.verified) return fail();
354
- getDb()
354
+ ctx.db
355
355
  .prepare(
356
356
  "UPDATE passkey_credentials SET counter = ?, last_used_at = ? WHERE id = ?"
357
357
  )
@@ -371,7 +371,7 @@ export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hon
371
371
  return fail();
372
372
  }
373
373
 
374
- await issueSessionCookie(c);
374
+ await issueSessionCookie(c, ctx.auth);
375
375
  return c.json({ ok: true });
376
376
  });
377
377
 
@@ -382,24 +382,28 @@ export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hon
382
382
  * Session-gated passkey routes. Mount AFTER the auth guard — gating comes from
383
383
  * mount position, not per-route checks.
384
384
  */
385
- export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hono {
385
+ export function passkeyManagementRoutes(
386
+ mode: AuthMode,
387
+ ctx: PasskeyContext,
388
+ deps: PasskeyDeps = {}
389
+ ): Hono {
386
390
  const verifyRegistration = deps.verifyRegistrationResponse ?? realVerifyRegistration;
387
391
  const now = deps.now ?? Date.now;
388
392
  const app = new Hono();
389
393
 
390
394
  app.post("/auth/passkey/register-options", async (c) => {
391
395
  if (mode !== "password") return notEnabled(c);
392
- const rp = resolveRp(c);
396
+ const rp = resolveRp(c, ctx);
393
397
  if (!rp) return c.json({ error: "Origin not allowed" }, 400);
394
398
 
395
399
  const options = await generateRegistrationOptions({
396
- rpName: rpName(),
400
+ rpName: ctx.webauthn.rpName,
397
401
  rpID: rp.rpID,
398
- userName: userName(),
399
- userID: userId(),
400
- userDisplayName: rpName(),
402
+ userName: ctx.webauthn.userName,
403
+ userID: userId(ctx.webauthn),
404
+ userDisplayName: ctx.webauthn.rpName,
401
405
  attestationType: "none",
402
- excludeCredentials: credentialsForRp(rp.rpID).map((row) => ({
406
+ excludeCredentials: credentialsForRp(ctx.db, rp.rpID).map((row) => ({
403
407
  id: row.id,
404
408
  transports: toWebAuthnCredential(row).transports,
405
409
  })),
@@ -415,7 +419,7 @@ export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}):
415
419
 
416
420
  app.post("/auth/passkey/register-verify", async (c) => {
417
421
  if (mode !== "password") return notEnabled(c);
418
- const rp = resolveRp(c);
422
+ const rp = resolveRp(c, ctx);
419
423
  if (!rp) return c.json({ error: "Origin not allowed" }, 400);
420
424
 
421
425
  let body: { response?: RegistrationResponseJSON; label?: unknown };
@@ -444,7 +448,7 @@ export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}):
444
448
  const { credential, aaguid, credentialDeviceType, credentialBackedUp } =
445
449
  result.registrationInfo;
446
450
  const createdAt = now();
447
- getDb()
451
+ ctx.db
448
452
  .prepare(
449
453
  `INSERT INTO passkey_credentials
450
454
  (id, public_key, counter, transports, rp_id, aaguid, device_type,
@@ -463,7 +467,7 @@ export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}):
463
467
  sanitizeLabel(body.label),
464
468
  createdAt
465
469
  );
466
- const row = credentialById(credential.id);
470
+ const row = credentialById(ctx.db, credential.id);
467
471
  return c.json({ ok: true, credential: row ? toSummary(row) : null });
468
472
  } catch (err) {
469
473
  const message = err instanceof Error ? err.message : String(err);
@@ -476,7 +480,7 @@ export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}):
476
480
  // delete stale localhost/dev credentials. The client badges foreign RPs.
477
481
  app.get("/auth/passkey/list", (c) => {
478
482
  if (mode !== "password") return notEnabled(c);
479
- const rows = getDb()
483
+ const rows = ctx.db
480
484
  .query("SELECT * FROM passkey_credentials ORDER BY created_at DESC")
481
485
  .all() as CredentialRow[];
482
486
  return c.json({ credentials: rows.map(toSummary) });
@@ -490,7 +494,7 @@ export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}):
490
494
  } catch {
491
495
  return c.json({ error: "Invalid request body" }, 400);
492
496
  }
493
- const result = getDb()
497
+ const result = ctx.db
494
498
  .prepare("UPDATE passkey_credentials SET label = ? WHERE id = ?")
495
499
  .run(sanitizeLabel(body.label), c.req.param("id"));
496
500
  if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
@@ -499,7 +503,7 @@ export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}):
499
503
 
500
504
  app.delete("/auth/passkey/:id", (c) => {
501
505
  if (mode !== "password") return notEnabled(c);
502
- const result = getDb()
506
+ const result = ctx.db
503
507
  .prepare("DELETE FROM passkey_credentials WHERE id = ?")
504
508
  .run(c.req.param("id"));
505
509
  if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
@@ -6,21 +6,21 @@ import { getConnInfo } from "hono/bun";
6
6
  * localhost.
7
7
  *
8
8
  * SECURITY: header trust is OFF by default. An exposed port lets anyone spoof
9
- * `x-forwarded-for`, so the real socket address is used unless `TRUST_PROXY=1`
10
- * — set that ONLY when a trusted reverse proxy in front of brain-ui sets the
11
- * forwarding header.
9
+ * `x-forwarded-for`, so the real socket address is used unless the resolved
10
+ * config says `trustProxy` (TRUST_PROXY=1) — set that ONLY when a trusted
11
+ * reverse proxy in front of brain-ui sets the forwarding header.
12
12
  */
13
13
  export interface TailscaleGuardOptions {
14
- /** Trust x-forwarded-for / x-real-ip. Defaults to `TRUST_PROXY === "1"`. */
15
- trustProxy?: boolean;
14
+ /** Trust x-forwarded-for / x-real-ip. */
15
+ trustProxy: boolean;
16
+ /** How many trusted proxies front the app (default 1). */
17
+ trustProxyHops?: number;
16
18
  }
17
19
 
18
- export function tailscaleGuard(
19
- options: TailscaleGuardOptions = {}
20
- ): MiddlewareHandler {
21
- const trustProxy = options.trustProxy ?? process.env.TRUST_PROXY === "1";
20
+ export function tailscaleGuard(options: TailscaleGuardOptions): MiddlewareHandler {
21
+ const { trustProxy, trustProxyHops } = options;
22
22
  return async (c, next) => {
23
- if (isTailscaleAllowed(c, trustProxy)) {
23
+ if (isTailscaleAllowed(c, trustProxy, trustProxyHops)) {
24
24
  await next();
25
25
  } else {
26
26
  return c.json({ error: "VPN access required" }, 403);
@@ -29,8 +29,12 @@ export function tailscaleGuard(
29
29
  }
30
30
 
31
31
  /** True when the request's client IP is a Tailscale or loopback address. */
32
- export function isTailscaleAllowed(c: Context, trustProxy: boolean): boolean {
33
- const ip = clientIp(c, trustProxy);
32
+ export function isTailscaleAllowed(
33
+ c: Context,
34
+ trustProxy: boolean,
35
+ trustProxyHops = 1
36
+ ): boolean {
37
+ const ip = clientIp(c, trustProxy, trustProxyHops);
34
38
  return isTailscaleIp(ip) || isLocalIp(ip);
35
39
  }
36
40
 
@@ -42,11 +46,12 @@ export function isTailscaleAllowed(c: Context, trustProxy: boolean): boolean {
42
46
  *
43
47
  * X-Forwarded-For is parsed RIGHT-TO-LEFT: each proxy appends the address it saw,
44
48
  * so the rightmost entries are the trusted hops we control and the leftmost is
45
- * client-controllable. `TRUST_PROXY_HOPS` (default 1) is how many proxies front
46
- * this app; the client IP is the entry just before them. Taking the leftmost
47
- * entry (the old behaviour) let a client spoof its address by pre-seeding XFF.
49
+ * client-controllable. `trustProxyHops` (TRUST_PROXY_HOPS, default 1) is how many
50
+ * proxies front this app; the client IP is the entry just before them. Taking the
51
+ * leftmost entry (the old behaviour) let a client spoof its address by
52
+ * pre-seeding XFF.
48
53
  */
49
- export function clientIp(c: Context, trustProxy: boolean): string {
54
+ export function clientIp(c: Context, trustProxy: boolean, trustProxyHops = 1): string {
50
55
  if (trustProxy) {
51
56
  const forwarded = c.req.header("x-forwarded-for");
52
57
  if (forwarded) {
@@ -55,7 +60,7 @@ export function clientIp(c: Context, trustProxy: boolean): string {
55
60
  .map((part) => part.trim())
56
61
  .filter(Boolean);
57
62
  if (parts.length > 0) {
58
- const hops = Math.max(1, Number(process.env.TRUST_PROXY_HOPS) || 1);
63
+ const hops = Math.max(1, trustProxyHops);
59
64
  const ip = parts[Math.max(0, parts.length - hops)];
60
65
  if (ip) return ip;
61
66
  }
@@ -1,17 +1,21 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
+ import type { BrainClient } from "../brain/client.js";
4
+ import { subprocessEnv } from "../config/env.js";
3
5
  import {
4
- brainSearch,
5
- brainBriefing,
6
- brainStats,
7
- brainList,
8
- brainAdd,
9
- brainCliCommand,
10
- } from "../brain/client.js";
11
- import { buildKeyterms, writeCache } from "../voice/keyterm-builder.js";
6
+ buildKeyterms,
7
+ writeCache,
8
+ type KeytermSettings,
9
+ } from "../voice/keyterm-builder.js";
12
10
  import { existsSync } from "fs";
13
11
  import { join } from "path";
14
12
 
13
+ export interface BrainRoutesDeps {
14
+ brain: BrainClient;
15
+ brainPath: string;
16
+ keyterms: KeytermSettings;
17
+ }
18
+
15
19
  /**
16
20
  * Locate the brain repo's `whatsup` script, relative to the repo root.
17
21
  *
@@ -31,14 +35,17 @@ function findWhatsupScript(brainPath: string): string | null {
31
35
  return null;
32
36
  }
33
37
 
34
- export const brainRoutes = new Hono()
38
+ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
39
+ const { brain, brainPath, keyterms } = deps;
40
+
41
+ return new Hono()
35
42
  .get("/brain/search", async (c) => {
36
43
  const q = c.req.query("q");
37
44
  if (!q) {
38
45
  return c.json({ error: "Query parameter 'q' is required" }, 400);
39
46
  }
40
47
  try {
41
- const { results, warnings } = await brainSearch(q, {
48
+ const { results, warnings } = await brain.search(q, {
42
49
  type: c.req.query("type"),
43
50
  tag: c.req.query("tag"),
44
51
  limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined,
@@ -55,7 +62,7 @@ export const brainRoutes = new Hono()
55
62
 
56
63
  .get("/brain/briefing", async (c) => {
57
64
  try {
58
- const content = await brainBriefing();
65
+ const content = await brain.briefing();
59
66
  return c.json({ content });
60
67
  } catch (err) {
61
68
  return c.json(
@@ -66,10 +73,6 @@ export const brainRoutes = new Hono()
66
73
  })
67
74
 
68
75
  .post("/brain/whatsup", async (c) => {
69
- const BRAIN_PATH =
70
- process.env.BRAIN_PATH ||
71
- `${process.env.HOME || "/root"}/brain`;
72
-
73
76
  // Defeat reverse-proxy buffering for SSE
74
77
  c.header("X-Accel-Buffering", "no");
75
78
 
@@ -91,7 +94,7 @@ export const brainRoutes = new Hono()
91
94
  try {
92
95
  await send({ type: "start", text: "Running whatsup briefing..." });
93
96
 
94
- const script = findWhatsupScript(BRAIN_PATH);
97
+ const script = findWhatsupScript(brainPath);
95
98
  if (!script) {
96
99
  // Terminate with the frames the client actually handles: it only
97
100
  // reads "progress" and "done", so an "error" type would leave the
@@ -109,10 +112,10 @@ export const brainRoutes = new Hono()
109
112
  // --claude backend uses --bare mode, which can't read the
110
113
  // CLAUDE_CODE_OAUTH_TOKEN either).
111
114
  const proc = Bun.spawn(["bun", script, "--gemini"], {
112
- cwd: BRAIN_PATH,
115
+ cwd: brainPath,
113
116
  stdout: "pipe",
114
117
  stderr: "pipe",
115
- env: { ...process.env, NO_COLOR: "1" },
118
+ env: subprocessEnv({ NO_COLOR: "1" }),
116
119
  });
117
120
 
118
121
  const reader = proc.stdout.getReader();
@@ -159,7 +162,7 @@ export const brainRoutes = new Hono()
159
162
 
160
163
  .get("/brain/stats", async (c) => {
161
164
  try {
162
- const stats = await brainStats();
165
+ const stats = await brain.stats();
163
166
  return c.json(stats);
164
167
  } catch (err) {
165
168
  return c.json(
@@ -171,7 +174,7 @@ export const brainRoutes = new Hono()
171
174
 
172
175
  .get("/brain/list", async (c) => {
173
176
  try {
174
- const results = await brainList({
177
+ const results = await brain.list({
175
178
  type: c.req.query("type"),
176
179
  tag: c.req.query("tag"),
177
180
  status: c.req.query("status"),
@@ -190,9 +193,6 @@ export const brainRoutes = new Hono()
190
193
  .post("/brain/sync", async (c) => {
191
194
  // Stream sync output via SSE so the client sees live progress.
192
195
  // brain sync spawns a nested Claude Code process that can take minutes.
193
- const BRAIN_PATH =
194
- process.env.BRAIN_PATH ||
195
- `${process.env.HOME || "/root"}/brain`;
196
196
 
197
197
  return streamSSE(c, async (stream) => {
198
198
  const send = (data: {
@@ -212,17 +212,17 @@ export const brainRoutes = new Hono()
212
212
 
213
213
  // Merge stderr into stdout — brain sync spawns a nested Claude Code
214
214
  // process whose output goes to stderr. The command comes from
215
- // brainCliCommand() (packaged bin or legacy vendored script), passed to
215
+ // brain.cliCommand() (packaged bin or legacy vendored script), passed to
216
216
  // bash as positional args rather than interpolated into the script, so a
217
217
  // BRAIN_PATH containing spaces or shell metacharacters stays inert.
218
- const [cliBin, ...cliArgs] = brainCliCommand();
218
+ const [cliBin, ...cliArgs] = brain.cliCommand();
219
219
  const proc = Bun.spawn(
220
220
  ["bash", "-c", '"$0" "$@" 2>&1', cliBin!, ...cliArgs, "sync"],
221
221
  {
222
- cwd: BRAIN_PATH,
222
+ cwd: brainPath,
223
223
  stdout: "pipe",
224
224
  stderr: "pipe",
225
- env: { ...process.env, NO_COLOR: "1" },
225
+ env: subprocessEnv({ NO_COLOR: "1" }),
226
226
  }
227
227
  );
228
228
 
@@ -254,8 +254,8 @@ export const brainRoutes = new Hono()
254
254
  // Rebuild voice keyterms cache after a successful sync.
255
255
  if (exitCode === 0) {
256
256
  try {
257
- const cache = buildKeyterms();
258
- writeCache(cache);
257
+ const cache = buildKeyterms(keyterms);
258
+ writeCache(keyterms, cache);
259
259
  await send({
260
260
  type: "progress",
261
261
  text: `[voice] Rebuilt keyterms cache (${cache.count} terms)`,
@@ -290,7 +290,7 @@ export const brainRoutes = new Hono()
290
290
  return c.json({ error: "Field 'content' is required" }, 400);
291
291
  }
292
292
  try {
293
- await brainAdd(body.content, {
293
+ await brain.add(body.content, {
294
294
  type: body.type,
295
295
  title: body.title,
296
296
  tags: body.tags,
@@ -303,3 +303,4 @@ export const brainRoutes = new Hono()
303
303
  );
304
304
  }
305
305
  });
306
+ }
@@ -10,7 +10,6 @@ import {
10
10
  TooLargeError,
11
11
  } from "../files/walker.js";
12
12
 
13
- let wikilinkCache: { generatedAt: number; map: Record<string, string> } | null = null;
14
13
  const WIKILINK_TTL_MS = 30_000;
15
14
 
16
15
  function errorResponse(err: unknown): { body: { error: string; size?: number }; status: 400 | 404 | 413 | 500 } {
@@ -27,11 +26,14 @@ function errorResponse(err: unknown): { body: { error: string; size?: number };
27
26
  return { body: { error: err instanceof Error ? err.message : "internal_error" }, status: 500 };
28
27
  }
29
28
 
30
- export const filesRoutes = new Hono()
29
+ export function createFilesRoutes(deps: { brainRoot: string }): Hono {
30
+ const { brainRoot } = deps;
31
+ let wikilinkCache: { generatedAt: number; map: Record<string, string> } | null = null;
32
+ return new Hono()
31
33
  .get("/files/tree", async (c) => {
32
34
  const path = c.req.query("path") ?? "";
33
35
  try {
34
- const entries = await listDirectory(path);
36
+ const entries = await listDirectory(path, brainRoot);
35
37
  return c.json({ path, entries });
36
38
  } catch (err) {
37
39
  const { body, status } = errorResponse(err);
@@ -45,7 +47,7 @@ export const filesRoutes = new Hono()
45
47
  const raw = c.req.query("raw") === "1";
46
48
  try {
47
49
  if (raw) {
48
- const { abs, mime, size } = await resolveForRaw(path);
50
+ const { abs, mime, size } = await resolveForRaw(path, brainRoot);
49
51
  const file = Bun.file(abs);
50
52
  return new Response(file, {
51
53
  status: 200,
@@ -59,7 +61,7 @@ export const filesRoutes = new Hono()
59
61
  },
60
62
  });
61
63
  }
62
- const result = await readFileContent(path);
64
+ const result = await readFileContent(path, brainRoot);
63
65
  return c.json({ path, ...result });
64
66
  } catch (err) {
65
67
  const { body, status } = errorResponse(err);
@@ -71,7 +73,7 @@ export const filesRoutes = new Hono()
71
73
  const path = c.req.query("path");
72
74
  if (!path) return c.json({ error: "missing_path" }, 400);
73
75
  try {
74
- const result = await resolveAncestors(path);
76
+ const result = await resolveAncestors(path, brainRoot);
75
77
  return c.json({ path, ...result });
76
78
  } catch (err) {
77
79
  const { body, status } = errorResponse(err);
@@ -84,7 +86,7 @@ export const filesRoutes = new Hono()
84
86
  const now = Date.now();
85
87
  try {
86
88
  if (refresh || !wikilinkCache || now - wikilinkCache.generatedAt > WIKILINK_TTL_MS) {
87
- const map = await buildWikilinkMap();
89
+ const map = await buildWikilinkMap(brainRoot);
88
90
  wikilinkCache = { generatedAt: now, map };
89
91
  }
90
92
  return c.json({
@@ -97,3 +99,4 @@ export const filesRoutes = new Hono()
97
99
  return c.json(body, status);
98
100
  }
99
101
  });
102
+ }