@schlessera/brain-ui-server 0.4.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 (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/agent/backend.d.ts +30 -0
  4. package/dist/agent/backend.d.ts.map +1 -0
  5. package/dist/agent/backend.js +226 -0
  6. package/dist/agent/backend.js.map +1 -0
  7. package/dist/app.d.ts +27 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +116 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/brain/client.d.ts +43 -0
  12. package/dist/brain/client.d.ts.map +1 -0
  13. package/dist/brain/client.js +154 -0
  14. package/dist/brain/client.js.map +1 -0
  15. package/dist/brain/types.d.ts +41 -0
  16. package/dist/brain/types.d.ts.map +1 -0
  17. package/dist/brain/types.js +2 -0
  18. package/dist/brain/types.js.map +1 -0
  19. package/dist/cron/scheduler.d.ts +9 -0
  20. package/dist/cron/scheduler.d.ts.map +1 -0
  21. package/dist/cron/scheduler.js +58 -0
  22. package/dist/cron/scheduler.js.map +1 -0
  23. package/dist/db/client.d.ts +10 -0
  24. package/dist/db/client.d.ts.map +1 -0
  25. package/dist/db/client.js +73 -0
  26. package/dist/db/client.js.map +1 -0
  27. package/dist/files/walker.d.ts +63 -0
  28. package/dist/files/walker.d.ts.map +1 -0
  29. package/dist/files/walker.js +416 -0
  30. package/dist/files/walker.js.map +1 -0
  31. package/dist/index.d.ts +18 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +25 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/middleware/auth.d.ts +59 -0
  36. package/dist/middleware/auth.d.ts.map +1 -0
  37. package/dist/middleware/auth.js +246 -0
  38. package/dist/middleware/auth.js.map +1 -0
  39. package/dist/middleware/passkeys.d.ts +36 -0
  40. package/dist/middleware/passkeys.d.ts.map +1 -0
  41. package/dist/middleware/passkeys.js +427 -0
  42. package/dist/middleware/passkeys.js.map +1 -0
  43. package/dist/middleware/tailscale.d.ts +31 -0
  44. package/dist/middleware/tailscale.d.ts.map +1 -0
  45. package/dist/middleware/tailscale.js +64 -0
  46. package/dist/middleware/tailscale.js.map +1 -0
  47. package/dist/render/template.d.ts +7 -0
  48. package/dist/render/template.d.ts.map +1 -0
  49. package/dist/render/template.js +112 -0
  50. package/dist/render/template.js.map +1 -0
  51. package/dist/routes/brain.d.ts +155 -0
  52. package/dist/routes/brain.d.ts.map +1 -0
  53. package/dist/routes/brain.js +250 -0
  54. package/dist/routes/brain.js.map +1 -0
  55. package/dist/routes/files.d.ts +89 -0
  56. package/dist/routes/files.d.ts.map +1 -0
  57. package/dist/routes/files.js +91 -0
  58. package/dist/routes/files.js.map +1 -0
  59. package/dist/routes/health.d.ts +37 -0
  60. package/dist/routes/health.d.ts.map +1 -0
  61. package/dist/routes/health.js +27 -0
  62. package/dist/routes/health.js.map +1 -0
  63. package/dist/routes/providers.d.ts +33 -0
  64. package/dist/routes/providers.d.ts.map +1 -0
  65. package/dist/routes/providers.js +9 -0
  66. package/dist/routes/providers.js.map +1 -0
  67. package/dist/routes/render.d.ts +25 -0
  68. package/dist/routes/render.d.ts.map +1 -0
  69. package/dist/routes/render.js +47 -0
  70. package/dist/routes/render.js.map +1 -0
  71. package/dist/routes/sessions.d.ts +79 -0
  72. package/dist/routes/sessions.d.ts.map +1 -0
  73. package/dist/routes/sessions.js +37 -0
  74. package/dist/routes/sessions.js.map +1 -0
  75. package/dist/routes/voice.d.ts +92 -0
  76. package/dist/routes/voice.d.ts.map +1 -0
  77. package/dist/routes/voice.js +64 -0
  78. package/dist/routes/voice.js.map +1 -0
  79. package/dist/voice/deepgram-token.d.ts +5 -0
  80. package/dist/voice/deepgram-token.d.ts.map +1 -0
  81. package/dist/voice/deepgram-token.js +63 -0
  82. package/dist/voice/deepgram-token.js.map +1 -0
  83. package/dist/voice/keyterm-builder.d.ts +18 -0
  84. package/dist/voice/keyterm-builder.d.ts.map +1 -0
  85. package/dist/voice/keyterm-builder.js +391 -0
  86. package/dist/voice/keyterm-builder.js.map +1 -0
  87. package/dist/voice/speech-providers.d.ts +25 -0
  88. package/dist/voice/speech-providers.d.ts.map +1 -0
  89. package/dist/voice/speech-providers.js +104 -0
  90. package/dist/voice/speech-providers.js.map +1 -0
  91. package/dist/ws/attachments.d.ts +10 -0
  92. package/dist/ws/attachments.d.ts.map +1 -0
  93. package/dist/ws/attachments.js +63 -0
  94. package/dist/ws/attachments.js.map +1 -0
  95. package/dist/ws/bridge.d.ts +7 -0
  96. package/dist/ws/bridge.d.ts.map +1 -0
  97. package/dist/ws/bridge.js +86 -0
  98. package/dist/ws/bridge.js.map +1 -0
  99. package/dist/ws/clients.d.ts +20 -0
  100. package/dist/ws/clients.d.ts.map +1 -0
  101. package/dist/ws/clients.js +48 -0
  102. package/dist/ws/clients.js.map +1 -0
  103. package/dist/ws/connection.d.ts +8 -0
  104. package/dist/ws/connection.d.ts.map +1 -0
  105. package/dist/ws/connection.js +116 -0
  106. package/dist/ws/connection.js.map +1 -0
  107. package/dist/ws/dispatch.d.ts +5 -0
  108. package/dist/ws/dispatch.d.ts.map +1 -0
  109. package/dist/ws/dispatch.js +139 -0
  110. package/dist/ws/dispatch.js.map +1 -0
  111. package/dist/ws/frames.d.ts +17 -0
  112. package/dist/ws/frames.d.ts.map +1 -0
  113. package/dist/ws/frames.js +37 -0
  114. package/dist/ws/frames.js.map +1 -0
  115. package/dist/ws/handler.d.ts +34 -0
  116. package/dist/ws/handler.d.ts.map +1 -0
  117. package/dist/ws/handler.js +33 -0
  118. package/dist/ws/handler.js.map +1 -0
  119. package/dist/ws/history.d.ts +17 -0
  120. package/dist/ws/history.d.ts.map +1 -0
  121. package/dist/ws/history.js +45 -0
  122. package/dist/ws/history.js.map +1 -0
  123. package/dist/ws/host.d.ts +40 -0
  124. package/dist/ws/host.d.ts.map +1 -0
  125. package/dist/ws/host.js +49 -0
  126. package/dist/ws/host.js.map +1 -0
  127. package/dist/ws/routing.d.ts +12 -0
  128. package/dist/ws/routing.d.ts.map +1 -0
  129. package/dist/ws/routing.js +31 -0
  130. package/dist/ws/routing.js.map +1 -0
  131. package/dist/ws/run-session.d.ts +17 -0
  132. package/dist/ws/run-session.d.ts.map +1 -0
  133. package/dist/ws/run-session.js +160 -0
  134. package/dist/ws/run-session.js.map +1 -0
  135. package/dist/ws/session-catalog.d.ts +25 -0
  136. package/dist/ws/session-catalog.d.ts.map +1 -0
  137. package/dist/ws/session-catalog.js +45 -0
  138. package/dist/ws/session-catalog.js.map +1 -0
  139. package/dist/ws/shrink.d.ts +34 -0
  140. package/dist/ws/shrink.d.ts.map +1 -0
  141. package/dist/ws/shrink.js +91 -0
  142. package/dist/ws/shrink.js.map +1 -0
  143. package/dist/ws/turns.d.ts +77 -0
  144. package/dist/ws/turns.d.ts.map +1 -0
  145. package/dist/ws/turns.js +89 -0
  146. package/dist/ws/turns.js.map +1 -0
  147. package/migrations/001_initial.sql +29 -0
  148. package/migrations/002_session_provider.sql +1 -0
  149. package/migrations/003_session_backend.sql +5 -0
  150. package/migrations/004_drop_gemini_backend.sql +20 -0
  151. package/migrations/005_passkey_credentials.sql +18 -0
  152. package/package.json +72 -0
  153. package/src/agent/backend.ts +308 -0
  154. package/src/app.ts +159 -0
  155. package/src/brain/client.ts +186 -0
  156. package/src/brain/types.ts +44 -0
  157. package/src/cron/scheduler.ts +80 -0
  158. package/src/db/client.ts +85 -0
  159. package/src/files/walker.ts +418 -0
  160. package/src/index.ts +39 -0
  161. package/src/middleware/auth.ts +321 -0
  162. package/src/middleware/passkeys.ts +510 -0
  163. package/src/middleware/tailscale.ts +81 -0
  164. package/src/render/template.ts +124 -0
  165. package/src/routes/brain.ts +305 -0
  166. package/src/routes/files.ts +99 -0
  167. package/src/routes/health.ts +29 -0
  168. package/src/routes/providers.ts +9 -0
  169. package/src/routes/render.ts +68 -0
  170. package/src/routes/sessions.ts +49 -0
  171. package/src/routes/voice.ts +80 -0
  172. package/src/voice/deepgram-token.ts +80 -0
  173. package/src/voice/keyterm-builder.ts +426 -0
  174. package/src/voice/speech-providers.ts +111 -0
  175. package/src/ws/attachments.ts +86 -0
  176. package/src/ws/bridge.ts +139 -0
  177. package/src/ws/clients.ts +57 -0
  178. package/src/ws/connection.ts +132 -0
  179. package/src/ws/dispatch.ts +162 -0
  180. package/src/ws/frames.ts +42 -0
  181. package/src/ws/handler.ts +50 -0
  182. package/src/ws/history.ts +51 -0
  183. package/src/ws/host.ts +69 -0
  184. package/src/ws/routing.ts +43 -0
  185. package/src/ws/run-session.ts +192 -0
  186. package/src/ws/session-catalog.ts +97 -0
  187. package/src/ws/shrink.ts +95 -0
  188. package/src/ws/turns.ts +151 -0
@@ -0,0 +1,308 @@
1
+ import { createRequire } from "module";
2
+ import {
3
+ createClaudeBackend,
4
+ defineProfiles,
5
+ type InferenceProfile,
6
+ type InferenceProfileInput,
7
+ } from "@schlessera/brain-backend-claude";
8
+ import type { ProviderInfo } from "@schlessera/brain-ui-sdk";
9
+ import type {
10
+ AgentBackend,
11
+ BackendCapabilities,
12
+ } from "@schlessera/brain-ui-sdk/server";
13
+
14
+ /**
15
+ * Backend registry for the deployment. Claude is always available.
16
+ * `AGENT_BACKEND=pi` retains the legacy single-backend deployment mode when the
17
+ * optional pi package is installed.
18
+ *
19
+ * The old five hardcoded personal inference profiles are gone: the Claude
20
+ * backend ships the single "claude" default, and additional Anthropic-compatible
21
+ * endpoints are declared via the BRAIN_UI_CLAUDE_PROFILES env var (documented in
22
+ * .env.example) rather than in code.
23
+ */
24
+
25
+ const BRAIN_PATH =
26
+ process.env.BRAIN_PATH || `${process.env.HOME || "/root"}/brain`;
27
+
28
+ // The Claude Code native binary is not in node_modules — it is installed via
29
+ // the official curl-bash installer (~/.local/bin/claude, symlinked to
30
+ // /usr/local/bin/claude in the container). The Agent SDK can't always
31
+ // auto-discover it, so hand it the explicit path; override with CLAUDE_CODE_PATH.
32
+ const CLAUDE_CODE_PATH = process.env.CLAUDE_CODE_PATH || "/usr/local/bin/claude";
33
+
34
+ interface BackendRegistry {
35
+ backends: AgentBackend[];
36
+ byId: Map<string, AgentBackend>;
37
+ defaultBackendId: string;
38
+ profileOwners: Map<string, string>;
39
+ profiles: Map<string, ProviderInfo[]>;
40
+ }
41
+
42
+ let cachedRegistry: Promise<BackendRegistry> | null = null;
43
+
44
+ async function createRegistry(
45
+ backends: AgentBackend[],
46
+ defaultBackendId: string
47
+ ): Promise<BackendRegistry> {
48
+ if (backends.length === 0) {
49
+ throw new Error("Backend registry requires at least one backend.");
50
+ }
51
+
52
+ const byId = new Map(backends.map((backend) => [backend.id, backend]));
53
+ const resolvedDefaultId = byId.has(defaultBackendId)
54
+ ? defaultBackendId
55
+ : backends[0].id;
56
+ const ordered = [
57
+ byId.get(resolvedDefaultId)!,
58
+ ...backends.filter((backend) => backend.id !== resolvedDefaultId),
59
+ ];
60
+ const listedProfiles = await Promise.all(
61
+ ordered.map(async (backend) => ({
62
+ backend,
63
+ profiles: await backend.listProfiles(),
64
+ }))
65
+ );
66
+ const profileOwners = new Map<string, string>();
67
+ const profiles = new Map<string, ProviderInfo[]>();
68
+
69
+ for (const listed of listedProfiles) {
70
+ profiles.set(listed.backend.id, listed.profiles);
71
+ for (const profile of listed.profiles) {
72
+ const owner = profileOwners.get(profile.id);
73
+ if (owner && owner !== listed.backend.id) {
74
+ throw new Error(
75
+ `Profile id collision across backends: "${profile.id}" is exposed by "${owner}" and "${listed.backend.id}".`
76
+ );
77
+ }
78
+ profileOwners.set(profile.id, listed.backend.id);
79
+ }
80
+ }
81
+
82
+ return {
83
+ backends: ordered,
84
+ byId,
85
+ defaultBackendId: resolvedDefaultId,
86
+ profileOwners,
87
+ profiles,
88
+ };
89
+ }
90
+
91
+ async function buildRegistry(): Promise<BackendRegistry> {
92
+ const primary = (process.env.AGENT_BACKEND || "claude")
93
+ .trim()
94
+ .toLowerCase();
95
+
96
+ if (primary === "pi") {
97
+ const pi = buildPiBackend();
98
+ return createRegistry([pi], pi.id);
99
+ }
100
+
101
+ const claude = createClaudeBackend({
102
+ brainPath: BRAIN_PATH,
103
+ claudeCodePath: CLAUDE_CODE_PATH,
104
+ profiles: loadClaudeProfiles(),
105
+ });
106
+ const backends = [claude];
107
+
108
+ // AGENT_BACKEND must name a configured backend. The in-process options are
109
+ // "claude" (default) and "pi" (handled above). An unrecognized value is a
110
+ // misconfiguration — fail loudly instead of silently coercing to claude and
111
+ // running on the wrong backend with nothing in the logs.
112
+ if (process.env.AGENT_BACKEND && !backends.some((b) => b.id === primary)) {
113
+ throw new Error(
114
+ `AGENT_BACKEND="${primary}" does not match any configured backend ` +
115
+ `(expected "claude" or "pi").`
116
+ );
117
+ }
118
+ return createRegistry(backends, primary);
119
+ }
120
+
121
+ async function getRegistry(): Promise<BackendRegistry> {
122
+ if (!cachedRegistry) cachedRegistry = buildRegistry();
123
+ return cachedRegistry;
124
+ }
125
+
126
+ /** All configured backends, with the default backend first. */
127
+ export async function getBackends(): Promise<AgentBackend[]> {
128
+ return (await getRegistry()).backends;
129
+ }
130
+
131
+ /** Find a configured backend by its stable id. */
132
+ export async function getBackendById(
133
+ id: string
134
+ ): Promise<AgentBackend | undefined> {
135
+ return (await getRegistry()).byId.get(id);
136
+ }
137
+
138
+ /** The default backend used for new and legacy sessions. */
139
+ export async function getDefaultBackend(): Promise<AgentBackend> {
140
+ const registry = await getRegistry();
141
+ return registry.byId.get(registry.defaultBackendId)!;
142
+ }
143
+
144
+ /** The stable id of the default backend. */
145
+ export async function getDefaultBackendId(): Promise<string> {
146
+ return (await getRegistry()).defaultBackendId;
147
+ }
148
+
149
+ /** Resolve the backend that owns a globally unique profile id. */
150
+ export async function getBackendForProfile(
151
+ profileId: string
152
+ ): Promise<AgentBackend | undefined> {
153
+ const registry = await getRegistry();
154
+ const backendId = registry.profileOwners.get(profileId);
155
+ return backendId ? registry.byId.get(backendId) : undefined;
156
+ }
157
+
158
+ /** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
159
+ export async function getBackendForSession(
160
+ backendId: string | null | undefined
161
+ ): Promise<AgentBackend> {
162
+ if (backendId) {
163
+ const backend = await getBackendById(backendId);
164
+ if (backend) return backend;
165
+ }
166
+ return getDefaultBackend();
167
+ }
168
+
169
+ /** List every available profile, tagged with its owning backend id. */
170
+ export async function listAllProviders(): Promise<ProviderInfo[]> {
171
+ const registry = await getRegistry();
172
+ return registry.backends.flatMap((backend) =>
173
+ (registry.profiles.get(backend.id) ?? []).map((profile) => ({
174
+ ...profile,
175
+ backendId: backend.id,
176
+ }))
177
+ );
178
+ }
179
+
180
+ /** Per-backend capability metadata exposed by the providers route. */
181
+ export async function getBackendsInfo(): Promise<
182
+ Record<string, { id: string; capabilities: BackendCapabilities }>
183
+ > {
184
+ const backends = await getBackends();
185
+ return Object.fromEntries(
186
+ backends.map((backend) => [
187
+ backend.id,
188
+ { id: backend.id, capabilities: backend.capabilities },
189
+ ])
190
+ );
191
+ }
192
+
193
+ /** @deprecated Use getDefaultBackend() or a routing-specific accessor. */
194
+ export async function getBackend(): Promise<AgentBackend> {
195
+ return getDefaultBackend();
196
+ }
197
+
198
+ /** Test seam: drop the cached registry so later access rebuilds from env. */
199
+ export function resetBackendForTests(): void {
200
+ cachedRegistry = null;
201
+ }
202
+
203
+ /** Test seam: install one fake backend as the complete registry. */
204
+ export function setBackendForTests(backend: AgentBackend): void {
205
+ cachedRegistry = createRegistry([backend], backend.id);
206
+ }
207
+
208
+ /** Test seam: install a complete fake registry with an explicit default. */
209
+ export function setBackendsForTests(
210
+ backends: AgentBackend[],
211
+ defaultBackendId = backends[0]?.id ?? ""
212
+ ): void {
213
+ cachedRegistry = createRegistry(backends, defaultBackendId);
214
+ }
215
+
216
+ // The built-in default profile's model. The Claude backend historically pinned
217
+ // the default to a specific model; after the SDK extraction the pin was lost and
218
+ // resumed default-profile sessions drifted to "whatever the CLI defaults to",
219
+ // silently changing model/behaviour/billing on every CLI bump. Re-pin it here
220
+ // (a committed default, overridable per deploy via BRAIN_UI_CLAUDE_DEFAULT_MODEL)
221
+ // so the default stays on a known model.
222
+ const BUILTIN_DEFAULT_MODEL =
223
+ process.env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6";
224
+
225
+ function builtinDefaultProfiles(): InferenceProfile[] {
226
+ return defineProfiles([
227
+ {
228
+ id: "claude",
229
+ label: "Claude",
230
+ vendor: "anthropic",
231
+ model: BUILTIN_DEFAULT_MODEL,
232
+ modelAliases: true,
233
+ },
234
+ ]);
235
+ }
236
+
237
+ /**
238
+ * The Claude roster: the pinned built-in "claude" default first, plus any extra
239
+ * profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of InferenceProfileInput
240
+ * {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
241
+ *
242
+ * A malformed or duplicate-id roster THROWS — caught at boot by the registry
243
+ * fail-fast — rather than silently degrading to default-only and rebilling
244
+ * every pinned session to the subscription with nothing in the logs.
245
+ */
246
+ function loadClaudeProfiles(): InferenceProfile[] {
247
+ const base = builtinDefaultProfiles();
248
+ const raw = process.env.BRAIN_UI_CLAUDE_PROFILES?.trim();
249
+ if (!raw) return base;
250
+
251
+ let inputs: unknown;
252
+ try {
253
+ inputs = JSON.parse(raw);
254
+ } catch (err) {
255
+ throw new Error(
256
+ `BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${
257
+ err instanceof Error ? err.message : String(err)
258
+ }`
259
+ );
260
+ }
261
+ if (!Array.isArray(inputs)) {
262
+ throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
263
+ }
264
+
265
+ // Reject duplicate ids (including collisions with the built-in "claude"): a
266
+ // duplicate silently shadows and can resolve to the wrong credentials.
267
+ const seen = new Set(base.map((profile) => profile.id));
268
+ for (const input of inputs as InferenceProfileInput[]) {
269
+ if (!input || typeof input.id !== "string" || input.id.length === 0) {
270
+ throw new Error(
271
+ "Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id."
272
+ );
273
+ }
274
+ if (seen.has(input.id)) {
275
+ throw new Error(
276
+ `Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`
277
+ );
278
+ }
279
+ seen.add(input.id);
280
+ }
281
+
282
+ return [...base, ...defineProfiles(inputs as InferenceProfileInput[])];
283
+ }
284
+
285
+ /**
286
+ * Optional pi backend. @schlessera/brain-backend-pi is NOT a hard dependency — a
287
+ * deployment that never opts into it does not need it (or its pi SDK peer deps)
288
+ * installed. Loaded lazily so the server still boots without it; when requested
289
+ * but absent, fail with an actionable message.
290
+ */
291
+ function buildPiBackend(): AgentBackend {
292
+ const require = createRequire(import.meta.url);
293
+ let mod: { createPiBackend?: (opts: { brainPath: string }) => AgentBackend };
294
+ try {
295
+ mod = require("@schlessera/brain-backend-pi");
296
+ } catch {
297
+ throw new Error(
298
+ 'AGENT_BACKEND=pi but "@schlessera/brain-backend-pi" is not installed. ' +
299
+ "Add it (with its pi SDK dependencies) or set AGENT_BACKEND=claude."
300
+ );
301
+ }
302
+ if (typeof mod.createPiBackend !== "function") {
303
+ throw new Error(
304
+ '"@schlessera/brain-backend-pi" does not export createPiBackend.'
305
+ );
306
+ }
307
+ return mod.createPiBackend({ brainPath: BRAIN_PATH });
308
+ }
package/src/app.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { Hono } from "hono";
2
+ import type { Context } from "hono";
3
+ import { logger } from "hono/logger";
4
+ import { cors } from "hono/cors";
5
+ import { serveStatic } from "hono/bun";
6
+ import { join } from "path";
7
+ import { healthRoutes, statusRoutes } from "./routes/health.js";
8
+ import { brainRoutes } from "./routes/brain.js";
9
+ import { sessionRoutes } from "./routes/sessions.js";
10
+ import { voiceRoutes } from "./routes/voice.js";
11
+ import { filesRoutes } from "./routes/files.js";
12
+ import { createRenderRoutes, type AppRenderer } from "./routes/render.js";
13
+ import { providerRoutes } from "./routes/providers.js";
14
+ import {
15
+ resolveAuthMode,
16
+ assertAuthConfig,
17
+ authGuard,
18
+ authRoutes,
19
+ isWsAuthorized,
20
+ } from "./middleware/auth.js";
21
+ import {
22
+ passkeyPublicRoutes,
23
+ passkeyManagementRoutes,
24
+ passwordLoginDisabled,
25
+ assertPasskeyConfig,
26
+ } from "./middleware/passkeys.js";
27
+ import { configureDb } from "./db/client.js";
28
+ import { configureWsHost, wsUpgrade, websocket } from "./ws/handler.js";
29
+
30
+ export type { AppRenderer };
31
+
32
+ export interface CreateAppOptions {
33
+ /**
34
+ * Directory of a built SPA to serve at `/*` with an index.html fallback.
35
+ * The deployment shell decides whether (and what) to serve — the package
36
+ * has no client build of its own and no NODE_ENV heuristics.
37
+ */
38
+ staticRoot?: string;
39
+ /** Display name used in connection/status copy. Default "Brain UI". */
40
+ appName?: string;
41
+ /** SQLite path override; falls back to DB_PATH, then ./brain-ui.db. */
42
+ dbPath?: string;
43
+ /**
44
+ * PNG/PDF renderer for `POST /api/render`. The deployment owns the actual
45
+ * renderer instance (e.g. @schlessera/brain-render-puppeteer) — without one
46
+ * the route answers 501.
47
+ */
48
+ renderer?: AppRenderer;
49
+ /** Per-turn timeout in ms (default 10 minutes). */
50
+ turnTimeoutMs?: number;
51
+ }
52
+
53
+ // Cross-site WebSocket hijacking (CSWSH) defense. CORS does not apply to the WS
54
+ // handshake and browsers do not enforce same-origin on `new WebSocket()`, so
55
+ // rejecting a cross-site upgrade is entirely the server's job. A browser always
56
+ // sends `Origin` on a WS handshake; a non-browser client (no Origin) is allowed
57
+ // through here and still gated by isWsAuthorized. When ALLOWED_ORIGINS is set we
58
+ // use it; otherwise we require the Origin's host to match the request host
59
+ // (same-origin). Applied in every auth mode.
60
+ function isAllowedWsOrigin(c: Context, allowedOrigins: string[]): boolean {
61
+ const origin = c.req.header("origin");
62
+ if (!origin) return true;
63
+ if (allowedOrigins.length > 0) return allowedOrigins.includes(origin);
64
+ try {
65
+ const host = c.req.header("host");
66
+ return !!host && new URL(origin).host === host;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export function createApp(options: CreateAppOptions = {}) {
73
+ const app = new Hono();
74
+ const authMode = resolveAuthMode();
75
+ // Validate inside the factory, not the bin entry: every consumer of the app
76
+ // (a deployment bin, tests, another embedder) gets the same refuse-to-boot
77
+ // guarantee on an unsafe auth configuration.
78
+ assertAuthConfig(authMode);
79
+ assertPasskeyConfig();
80
+
81
+ if (options.dbPath) configureDb(options.dbPath);
82
+ configureWsHost({
83
+ ...(options.appName ? { appName: options.appName } : {}),
84
+ ...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
85
+ });
86
+
87
+ // Middleware
88
+ app.use("*", logger());
89
+
90
+ // CORS is only needed for a SPLIT topology where the client is served from a
91
+ // different origin than the API. ALLOWED_ORIGINS is a comma-separated
92
+ // allowlist; empty/unset means same-origin (the default), so the CORS
93
+ // middleware is skipped entirely.
94
+ const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "")
95
+ .split(",")
96
+ .map((origin) => origin.trim())
97
+ .filter(Boolean);
98
+ if (allowedOrigins.length > 0) {
99
+ app.use(
100
+ "/api/*",
101
+ cors({
102
+ origin: allowedOrigins,
103
+ allowMethods: ["GET", "POST", "PUT", "DELETE"],
104
+ allowHeaders: ["Content-Type"],
105
+ credentials: true,
106
+ })
107
+ );
108
+ }
109
+
110
+ // Public routes, registered BEFORE the auth guard: only the minimal liveness
111
+ // probe and the login/logout routes are reachable without a session.
112
+ // /api/status is intentionally NOT here — it leaks the git SHA, cron errors,
113
+ // and a session oracle, so it lives behind the guard below.
114
+ app.route("/api", healthRoutes);
115
+ app.route("/api", authRoutes(authMode, { passwordDisabled: passwordLoginDisabled }));
116
+ app.route("/api", passkeyPublicRoutes(authMode));
117
+
118
+ // Auth guard for every other /api/* route. The probe below is intentionally
119
+ // behind it: an unauthenticated client gets 401 (password/proxy) or 403
120
+ // (tailscale) from /api/vpn-check and shows the login / VPN screen.
121
+ app.use("/api/*", authGuard(authMode));
122
+ app.get("/api/vpn-check", (c) => c.json({ vpn: true }));
123
+ // Passkey registration/management: after the guard, so a session is required
124
+ // by mount position (the public assertion routes are registered above).
125
+ app.route("/api", passkeyManagementRoutes(authMode));
126
+ app.route("/api", statusRoutes);
127
+ app.route("/api", brainRoutes);
128
+ app.route("/api", sessionRoutes);
129
+ app.route("/api", voiceRoutes);
130
+ app.route("/api", filesRoutes);
131
+ app.route("/api", createRenderRoutes(options.renderer));
132
+ app.route("/api", providerRoutes);
133
+
134
+ // WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
135
+ // upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
136
+ // the handler — no header-modifying middleware may sit on this route.
137
+ app.get("/ws", async (c, next) => {
138
+ if (!isAllowedWsOrigin(c, allowedOrigins)) {
139
+ return c.json({ error: "Cross-origin WebSocket rejected" }, 403);
140
+ }
141
+ if (!(await isWsAuthorized(c, authMode))) {
142
+ return c.json({ error: "Authentication required" }, 401);
143
+ }
144
+ return wsUpgrade(c, next);
145
+ });
146
+
147
+ // Static files: the deployment shell passes its built client explicitly.
148
+ if (options.staticRoot) {
149
+ const staticRoot = options.staticRoot;
150
+ app.use("/*", serveStatic({ root: staticRoot }));
151
+ // SPA fallback
152
+ app.get("*", serveStatic({ path: join(staticRoot, "index.html") }));
153
+ }
154
+
155
+ return {
156
+ fetch: app.fetch,
157
+ websocket,
158
+ };
159
+ }
@@ -0,0 +1,186 @@
1
+ import { existsSync } from "fs";
2
+ import { join } from "path";
3
+ import type {
4
+ BrainSearchResult,
5
+ BrainSearchResponse,
6
+ BrainDocument,
7
+ BrainStats,
8
+ BrainSyncResult,
9
+ } from "./types.js";
10
+
11
+ const BRAIN_PATH = process.env.BRAIN_PATH || join(process.env.HOME || "/root", "brain");
12
+
13
+ /**
14
+ * argv prefix for invoking the brain CLI inside BRAIN_PATH.
15
+ *
16
+ * Two brain-repo layouts exist and both must work: a repo that depends on
17
+ * `@schlessera/brain` gets a real bin at `node_modules/.bin/brain`, while the
18
+ * legacy layout vendors `scripts/brain-cli.ts`. Prefer the packaged bin.
19
+ *
20
+ * Resolved per call (a bare `existsSync` stat) rather than at import time: in
21
+ * the container the brain repo is cloned and `bun install`ed by entrypoint.sh,
22
+ * and a repo can gain the dependency without restarting this server. The same
23
+ * preference order is mirrored in `scripts/entrypoint.sh`, which links whichever
24
+ * it finds onto PATH for cron.
25
+ */
26
+ export function brainCliCommand(): string[] {
27
+ const packaged = join(BRAIN_PATH, "node_modules", ".bin", "brain");
28
+ if (existsSync(packaged)) return [packaged];
29
+ return ["bun", "scripts/brain-cli.ts"];
30
+ }
31
+
32
+ interface ExecResult {
33
+ stdout: string;
34
+ stderr: string;
35
+ exitCode: number;
36
+ }
37
+
38
+ async function execBrain(args: string[]): Promise<ExecResult> {
39
+ const proc = Bun.spawn([...brainCliCommand(), ...args], {
40
+ cwd: BRAIN_PATH,
41
+ stdout: "pipe",
42
+ stderr: "pipe",
43
+ env: {
44
+ ...process.env,
45
+ // Force JSON output when not a TTY
46
+ NO_COLOR: "1",
47
+ },
48
+ });
49
+
50
+ const [stdout, stderr] = await Promise.all([
51
+ new Response(proc.stdout).text(),
52
+ new Response(proc.stderr).text(),
53
+ ]);
54
+ const exitCode = await proc.exited;
55
+
56
+ return { stdout, stderr, exitCode };
57
+ }
58
+
59
+ function parseJsonOutput<T>(result: ExecResult): T {
60
+ if (result.exitCode !== 0) {
61
+ throw new Error(
62
+ `brain CLI failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
63
+ );
64
+ }
65
+ try {
66
+ return JSON.parse(result.stdout);
67
+ } catch {
68
+ // Some commands output plain text, not JSON
69
+ return result.stdout as unknown as T;
70
+ }
71
+ }
72
+
73
+ export async function brainSearch(
74
+ query: string,
75
+ opts?: { type?: string; tag?: string; limit?: number; mode?: string }
76
+ ): Promise<BrainSearchResponse> {
77
+ const args = ["search", query];
78
+ if (opts?.type) args.push("--type", opts.type);
79
+ if (opts?.tag) args.push("--tag", opts.tag);
80
+ if (opts?.limit) args.push("--limit", String(opts.limit));
81
+ if (opts?.mode) args.push("--mode", opts.mode);
82
+
83
+ const result = await execBrain(args);
84
+ const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
85
+ // brain returns {results, warnings} since 22e82e1; tolerate the old bare array
86
+ return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
87
+ }
88
+
89
+ export async function brainBriefing(): Promise<string> {
90
+ const result = await execBrain(["briefing"]);
91
+ if (result.exitCode !== 0) {
92
+ throw new Error(`brain briefing failed: ${result.stderr}`);
93
+ }
94
+ return result.stdout;
95
+ }
96
+
97
+ export async function brainStats(): Promise<BrainStats> {
98
+ const result = await execBrain(["stats"]);
99
+ return parseJsonOutput(result);
100
+ }
101
+
102
+ export async function brainList(opts?: {
103
+ type?: string;
104
+ tag?: string;
105
+ status?: string;
106
+ relevance?: string;
107
+ limit?: number;
108
+ }): Promise<BrainDocument[]> {
109
+ const args = ["list"];
110
+ if (opts?.type) args.push("--type", opts.type);
111
+ if (opts?.tag) args.push("--tag", opts.tag);
112
+ if (opts?.status) args.push("--status", opts.status);
113
+ if (opts?.relevance) args.push("--relevance", opts.relevance);
114
+ if (opts?.limit) args.push("--limit", String(opts.limit));
115
+
116
+ const result = await execBrain(args);
117
+ return parseJsonOutput(result);
118
+ }
119
+
120
+ export async function brainRead(path: string): Promise<string> {
121
+ const result = await execBrain(["read", path]);
122
+ if (result.exitCode !== 0) {
123
+ throw new Error(`brain read failed: ${result.stderr}`);
124
+ }
125
+ return result.stdout;
126
+ }
127
+
128
+ export async function brainSync(): Promise<BrainSyncResult> {
129
+ // Note: brain sync spawns Claude Code internally via the /sync skill.
130
+ // For cron jobs, we use a simpler git-based sync instead.
131
+ const result = await execBrain(["sync"]);
132
+ if (result.exitCode !== 0) {
133
+ return {
134
+ success: false,
135
+ commits: 0,
136
+ conflicts: 0,
137
+ message: result.stderr || result.stdout,
138
+ };
139
+ }
140
+ try {
141
+ return parseJsonOutput(result);
142
+ } catch {
143
+ return {
144
+ success: true,
145
+ commits: 0,
146
+ conflicts: 0,
147
+ message: result.stdout,
148
+ };
149
+ }
150
+ }
151
+
152
+ export async function brainAdd(
153
+ content: string,
154
+ opts?: { type?: string; title?: string; tags?: string[] }
155
+ ): Promise<void> {
156
+ const args = ["add", content];
157
+ if (opts?.type) args.push("--type", opts.type);
158
+ if (opts?.title) args.push("--title", opts.title);
159
+ if (opts?.tags) args.push("--tags", opts.tags.join(","));
160
+
161
+ const result = await execBrain(args);
162
+ if (result.exitCode !== 0) {
163
+ throw new Error(`brain add failed: ${result.stderr}`);
164
+ }
165
+ }
166
+
167
+ export async function brainIndex(opts?: {
168
+ /** Full rebuild; indexing is incremental by default since brain 22e82e1. */
169
+ force?: boolean;
170
+ }): Promise<void> {
171
+ const args = ["index"];
172
+ if (opts?.force) args.push("--force");
173
+
174
+ const result = await execBrain(args);
175
+ if (result.exitCode !== 0) {
176
+ throw new Error(`brain index failed: ${result.stderr}`);
177
+ }
178
+ }
179
+
180
+ export async function brainValidate(): Promise<string> {
181
+ const result = await execBrain(["validate"]);
182
+ if (result.exitCode !== 0) {
183
+ throw new Error(`brain validate failed: ${result.stderr}`);
184
+ }
185
+ return result.stdout;
186
+ }
@@ -0,0 +1,44 @@
1
+ export interface BrainSearchResult {
2
+ path: string;
3
+ title: string;
4
+ type: string;
5
+ relevance: string;
6
+ score: number;
7
+ snippet: string;
8
+ }
9
+
10
+ export interface BrainSearchResponse {
11
+ results: BrainSearchResult[];
12
+ /** Degraded-mode notices from the CLI, e.g. vector search unavailable so results are FTS-only. */
13
+ warnings: string[];
14
+ }
15
+
16
+ export interface BrainDocument {
17
+ path: string;
18
+ title: string;
19
+ type: string;
20
+ status: string;
21
+ relevance: string;
22
+ tags: string[];
23
+ created: string;
24
+ updated: string;
25
+ summary?: string;
26
+ }
27
+
28
+ export interface BrainStats {
29
+ documents: number;
30
+ byType: Record<string, number>;
31
+ byStatus: Record<string, number>;
32
+ byRelevance: Record<string, number>;
33
+ tags: number;
34
+ links: number;
35
+ brokenLinks: number;
36
+ chunks: number;
37
+ }
38
+
39
+ export interface BrainSyncResult {
40
+ success: boolean;
41
+ commits: number;
42
+ conflicts: number;
43
+ message: string;
44
+ }