@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.
- package/README.md +51 -12
- package/dist/agent/backend.d.ts +163 -37
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +281 -286
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts +46 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +94 -35
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +41 -30
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +138 -132
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +104 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +296 -0
- package/dist/config/env.js.map +1 -0
- package/dist/cron/scheduler.d.ts +16 -8
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +54 -53
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/brain-db.d.ts +50 -0
- package/dist/db/brain-db.d.ts.map +1 -0
- package/dist/db/brain-db.js +75 -0
- package/dist/db/brain-db.js.map +1 -0
- package/dist/db/client.d.ts +8 -6
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +12 -25
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +5 -4
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +9 -12
- package/dist/db/settings.js.map +1 -1
- package/dist/files/walker.d.ts +6 -7
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -9
- package/dist/files/walker.js.map +1 -1
- package/dist/graph/reader.d.ts +6 -6
- package/dist/graph/reader.d.ts.map +1 -1
- package/dist/graph/reader.js +22 -12
- package/dist/graph/reader.js.map +1 -1
- package/dist/index.d.ts +15 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -10
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +17 -6
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +34 -43
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/origin.d.ts +1 -2
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +1 -7
- package/dist/middleware/origin.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +29 -5
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +47 -76
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/tailscale.d.ts +14 -11
- package/dist/middleware/tailscale.d.ts.map +1 -1
- package/dist/middleware/tailscale.js +11 -10
- package/dist/middleware/tailscale.js.map +1 -1
- package/dist/routes/brain.d.ts +9 -154
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +187 -188
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts +4 -88
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +74 -71
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/graph.d.ts +4 -255
- package/dist/routes/graph.d.ts.map +1 -1
- package/dist/routes/graph.js +67 -64
- package/dist/routes/graph.js.map +1 -1
- package/dist/routes/health.d.ts +8 -22
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +10 -10
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/models.d.ts +7 -90
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +53 -51
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/providers.d.ts +5 -34
- package/dist/routes/providers.d.ts.map +1 -1
- package/dist/routes/providers.js +13 -11
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/sessions.d.ts +7 -78
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +35 -34
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/share.d.ts +7 -60
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +80 -77
- package/dist/routes/share.js.map +1 -1
- package/dist/routes/voice.d.ts +8 -91
- package/dist/routes/voice.d.ts.map +1 -1
- package/dist/routes/voice.js +62 -59
- package/dist/routes/voice.js.map +1 -1
- package/dist/share/staging.d.ts +4 -4
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +7 -8
- package/dist/share/staging.js.map +1 -1
- package/dist/voice/deepgram-token.d.ts +1 -1
- package/dist/voice/deepgram-token.d.ts.map +1 -1
- package/dist/voice/deepgram-token.js +1 -2
- package/dist/voice/deepgram-token.js.map +1 -1
- package/dist/voice/keyterm-builder.d.ts +23 -8
- package/dist/voice/keyterm-builder.d.ts.map +1 -1
- package/dist/voice/keyterm-builder.js +67 -43
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/voice/speech-providers.d.ts +6 -4
- package/dist/voice/speech-providers.d.ts.map +1 -1
- package/dist/voice/speech-providers.js +28 -25
- package/dist/voice/speech-providers.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +2 -3
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +18 -10
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +35 -37
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +5 -7
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +1 -2
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +16 -12
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +14 -23
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/routing.d.ts +2 -1
- package/dist/ws/routing.d.ts.map +1 -1
- package/dist/ws/routing.js +5 -6
- package/dist/ws/routing.js.map +1 -1
- package/dist/ws/run-session.js +1 -1
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/session-catalog.d.ts +2 -2
- package/dist/ws/session-catalog.d.ts.map +1 -1
- package/dist/ws/session-catalog.js +2 -3
- package/dist/ws/session-catalog.js.map +1 -1
- package/package.json +8 -5
- package/src/agent/backend.ts +509 -340
- package/src/app.ts +157 -36
- package/src/brain/client.ts +166 -143
- package/src/config/env.ts +401 -0
- package/src/cron/scheduler.ts +77 -59
- package/src/db/brain-db.ts +104 -0
- package/src/db/client.ts +12 -31
- package/src/db/settings.ts +12 -14
- package/src/files/walker.ts +6 -10
- package/src/graph/reader.ts +26 -15
- package/src/index.ts +56 -17
- package/src/middleware/auth.ts +51 -45
- package/src/middleware/origin.ts +1 -8
- package/src/middleware/passkeys.ts +72 -68
- package/src/middleware/tailscale.ts +22 -17
- package/src/routes/brain.ts +31 -30
- package/src/routes/files.ts +10 -7
- package/src/routes/graph.ts +9 -6
- package/src/routes/health.ts +17 -10
- package/src/routes/models.ts +19 -15
- package/src/routes/providers.ts +14 -15
- package/src/routes/sessions.ts +18 -11
- package/src/routes/share.ts +35 -24
- package/src/routes/voice.ts +78 -68
- package/src/share/staging.ts +10 -8
- package/src/voice/deepgram-token.ts +4 -2
- package/src/voice/keyterm-builder.ts +90 -56
- package/src/voice/speech-providers.ts +28 -24
- package/src/ws/bridge.ts +2 -3
- package/src/ws/clients.ts +38 -41
- package/src/ws/connection.ts +5 -7
- package/src/ws/dispatch.ts +1 -2
- package/src/ws/host.ts +24 -27
- package/src/ws/routing.ts +6 -10
- package/src/ws/run-session.ts +1 -1
- package/src/ws/session-catalog.ts +2 -3
- package/dist/ws/handler.d.ts +0 -34
- package/dist/ws/handler.d.ts.map +0 -1
- package/dist/ws/handler.js +0 -33
- package/dist/ws/handler.js.map +0 -1
- 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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(`
|
package/src/db/settings.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
}
|
package/src/files/walker.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("/");
|
package/src/graph/reader.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
/**
|
|
63
|
-
brainPath
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
11
|
+
export {
|
|
12
|
+
createApp,
|
|
13
|
+
type CreateAppOptions,
|
|
14
|
+
type BrainUiApp,
|
|
15
|
+
type AppRenderer,
|
|
16
|
+
} from "./app.js";
|
|
11
17
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
export {
|
|
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 {
|
|
19
|
-
|
|
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/
|
|
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
|
|
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 {
|
|
78
|
+
export {
|
|
79
|
+
buildKeyterms,
|
|
80
|
+
writeCache,
|
|
81
|
+
type KeytermSettings,
|
|
82
|
+
} from "./voice/keyterm-builder.js";
|
package/src/middleware/auth.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 (
|
|
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 =
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
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 "${
|
|
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,
|
|
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(
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
230
|
-
const user = c.req.header(
|
|
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,
|
|
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 =
|
|
294
|
-
const 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
|
|
package/src/middleware/origin.ts
CHANGED
|
@@ -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
|
|
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";
|