@schlessera/brain-ui-server 0.31.0 → 0.32.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 +46 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +49 -25
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +6 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +13 -7
- package/dist/config/env.js.map +1 -1
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +70 -10
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +25 -5
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +255 -47
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/body-limit.d.ts +15 -0
- package/dist/middleware/body-limit.d.ts.map +1 -0
- package/dist/middleware/body-limit.js +41 -0
- package/dist/middleware/body-limit.js.map +1 -0
- package/dist/middleware/origin.d.ts +20 -12
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +71 -25
- package/dist/middleware/origin.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +5 -1
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +67 -26
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +7 -2
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.js +2 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +22 -10
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/pi-auth.d.ts.map +1 -1
- package/dist/routes/pi-auth.js +12 -4
- package/dist/routes/pi-auth.js.map +1 -1
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +12 -4
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/render.d.ts +6 -0
- package/dist/routes/render.d.ts.map +1 -1
- package/dist/routes/render.js +8 -11
- package/dist/routes/render.js.map +1 -1
- package/dist/routes/share.d.ts +2 -0
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +30 -31
- package/dist/routes/share.js.map +1 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/skills.js +22 -8
- package/dist/routes/skills.js.map +1 -1
- package/dist/routes/web-search.d.ts.map +1 -1
- package/dist/routes/web-search.js +7 -2
- package/dist/routes/web-search.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +2 -1
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/clients.d.ts +13 -4
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +41 -5
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +10 -2
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/host.d.ts +5 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +18 -1
- package/dist/ws/host.js.map +1 -1
- package/package.json +3 -3
- package/src/app.ts +55 -23
- package/src/config/env.ts +18 -7
- package/src/files/walker.ts +66 -10
- package/src/index.ts +8 -0
- package/src/middleware/auth.ts +311 -48
- package/src/middleware/body-limit.ts +47 -0
- package/src/middleware/origin.ts +92 -24
- package/src/middleware/passkeys.ts +80 -29
- package/src/routes/brain.ts +7 -3
- package/src/routes/files.ts +2 -2
- package/src/routes/models.ts +17 -10
- package/src/routes/pi-auth.ts +10 -4
- package/src/routes/push.ts +10 -4
- package/src/routes/render.ts +14 -10
- package/src/routes/share.ts +35 -34
- package/src/routes/skills.ts +18 -8
- package/src/routes/web-search.ts +6 -2
- package/src/share/staging.ts +2 -1
- package/src/ws/clients.ts +45 -7
- package/src/ws/connection.ts +11 -2
- package/src/ws/host.ts +23 -1
package/src/routes/skills.ts
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
type Fetcher,
|
|
30
30
|
} from "../skills/install.js";
|
|
31
31
|
import { resolveGitHubToken } from "../config/env.js";
|
|
32
|
+
import { readJsonBody } from "../middleware/body-limit.js";
|
|
33
|
+
import { requireJson } from "../middleware/origin.js";
|
|
32
34
|
|
|
33
35
|
export interface SkillRoutesDeps {
|
|
34
36
|
brainPath: string;
|
|
@@ -101,8 +103,10 @@ export function createSkillRoutes(deps: SkillRoutesDeps): Hono {
|
|
|
101
103
|
return c.json({ error: message }, status);
|
|
102
104
|
}
|
|
103
105
|
})
|
|
104
|
-
.post("/skills/install/github", async (c) => {
|
|
105
|
-
const
|
|
106
|
+
.post("/skills/install/github", requireJson(), async (c) => {
|
|
107
|
+
const result = await readJsonBody(c).catch(() => null);
|
|
108
|
+
if (result instanceof Response) return result;
|
|
109
|
+
const body = result as {
|
|
106
110
|
source?: unknown;
|
|
107
111
|
ref?: unknown;
|
|
108
112
|
overwrite?: unknown;
|
|
@@ -145,8 +149,10 @@ export function createSkillRoutes(deps: SkillRoutesDeps): Hono {
|
|
|
145
149
|
return c.json({ error: message }, status);
|
|
146
150
|
}
|
|
147
151
|
})
|
|
148
|
-
.post("/skills", async (c) => {
|
|
149
|
-
const
|
|
152
|
+
.post("/skills", requireJson(), async (c) => {
|
|
153
|
+
const result = await readJsonBody(c).catch(() => null);
|
|
154
|
+
if (result instanceof Response) return result;
|
|
155
|
+
const body = result as {
|
|
150
156
|
name?: unknown;
|
|
151
157
|
content?: unknown;
|
|
152
158
|
} | null;
|
|
@@ -162,8 +168,10 @@ export function createSkillRoutes(deps: SkillRoutesDeps): Hono {
|
|
|
162
168
|
return c.json({ error: message }, status);
|
|
163
169
|
}
|
|
164
170
|
})
|
|
165
|
-
.put("/skills/:name", async (c) => {
|
|
166
|
-
const
|
|
171
|
+
.put("/skills/:name", requireJson(), async (c) => {
|
|
172
|
+
const result = await readJsonBody(c).catch(() => null);
|
|
173
|
+
if (result instanceof Response) return result;
|
|
174
|
+
const body = result as { content?: unknown } | null;
|
|
167
175
|
if (typeof body?.content !== "string") {
|
|
168
176
|
return c.json({ error: "Body needs string `content`." }, 400);
|
|
169
177
|
}
|
|
@@ -176,8 +184,10 @@ export function createSkillRoutes(deps: SkillRoutesDeps): Hono {
|
|
|
176
184
|
return c.json({ error: message }, status);
|
|
177
185
|
}
|
|
178
186
|
})
|
|
179
|
-
.post("/skills/:name/enabled", async (c) => {
|
|
180
|
-
const
|
|
187
|
+
.post("/skills/:name/enabled", requireJson(), async (c) => {
|
|
188
|
+
const result = await readJsonBody(c).catch(() => null);
|
|
189
|
+
if (result instanceof Response) return result;
|
|
190
|
+
const body = result as { enabled?: unknown } | null;
|
|
181
191
|
if (typeof body?.enabled !== "boolean") {
|
|
182
192
|
return c.json({ error: "Body needs boolean `enabled`." }, 400);
|
|
183
193
|
}
|
package/src/routes/web-search.ts
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
31
31
|
import { dirname } from "path";
|
|
32
32
|
import { Hono } from "hono";
|
|
33
|
+
import { readJsonBody } from "../middleware/body-limit.js";
|
|
34
|
+
import { requireJson } from "../middleware/origin.js";
|
|
33
35
|
import {
|
|
34
36
|
WEB_SEARCH_FALLBACK_ON,
|
|
35
37
|
WEB_SEARCH_PROVIDER_KEYS,
|
|
@@ -186,11 +188,13 @@ export function createWebSearchRoutes(deps: WebSearchRoutesDeps): Hono {
|
|
|
186
188
|
}
|
|
187
189
|
return c.json(view());
|
|
188
190
|
})
|
|
189
|
-
.put("/web-search", async (c) => {
|
|
191
|
+
.put("/web-search", requireJson(), async (c) => {
|
|
190
192
|
if (!piConfigured()) {
|
|
191
193
|
return c.json({ error: "The pi backend is not configured." }, 409);
|
|
192
194
|
}
|
|
193
|
-
const
|
|
195
|
+
const result = await readJsonBody(c).catch(() => null);
|
|
196
|
+
if (result instanceof Response) return result;
|
|
197
|
+
const body = result as {
|
|
194
198
|
enabled?: unknown;
|
|
195
199
|
apiKeys?: unknown;
|
|
196
200
|
clearOverride?: unknown;
|
package/src/share/staging.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ShareIntakeResult,
|
|
14
14
|
type ShareStagingManifest,
|
|
15
15
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
16
|
+
import { safeResolve } from "../files/walker.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Staging for an incoming system share.
|
|
@@ -301,7 +302,7 @@ export async function stageShare(
|
|
|
301
302
|
throw new ShareTooLargeError("share_too_large", SHARE_MAX_TOTAL_BYTES);
|
|
302
303
|
}
|
|
303
304
|
|
|
304
|
-
const root =
|
|
305
|
+
const root = await safeResolve(SHARE_STAGING_DIR, brainPath);
|
|
305
306
|
if ((await countStaged(root)) >= SHARE_MAX_STAGED) {
|
|
306
307
|
throw new ShareTooLargeError("inbox_full", SHARE_MAX_STAGED);
|
|
307
308
|
}
|
package/src/ws/clients.ts
CHANGED
|
@@ -8,9 +8,16 @@ import { shrinkForReplication } from "./shrink.js";
|
|
|
8
8
|
*/
|
|
9
9
|
export type WSContext = {
|
|
10
10
|
send: (data: string) => void;
|
|
11
|
-
|
|
11
|
+
close?: (code?: number, reason?: string) => void;
|
|
12
|
+
raw?: unknown;
|
|
12
13
|
};
|
|
13
14
|
|
|
15
|
+
function canSendRaw(raw: unknown): raw is { send: (data: string) => unknown } {
|
|
16
|
+
return (
|
|
17
|
+
typeof raw === "object" && raw !== null && "send" in raw && typeof raw.send === "function"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
/** Serialize + size-bound a frame, then send it to one specific socket. */
|
|
15
22
|
export function sendTo(ws: WSContext, msg: ServerMessage): void {
|
|
16
23
|
ws.send(JSON.stringify(shrinkForReplication(msg)));
|
|
@@ -24,24 +31,55 @@ export function sendTo(ws: WSContext, msg: ServerMessage): void {
|
|
|
24
31
|
* which would have cross-wired two coexisting app instances.)
|
|
25
32
|
*/
|
|
26
33
|
export class ClientSet {
|
|
27
|
-
|
|
34
|
+
// Hono creates a new WSContext for every Bun open/message/close callback.
|
|
35
|
+
// The raw socket remains stable across those wrappers, so it is the identity
|
|
36
|
+
// that admission and removal must share.
|
|
37
|
+
private readonly clients = new Map<unknown, WSContext>();
|
|
38
|
+
|
|
39
|
+
constructor(readonly maxConnections = 32) {}
|
|
28
40
|
|
|
29
|
-
add(ws: WSContext):
|
|
30
|
-
|
|
41
|
+
add(ws: WSContext): boolean {
|
|
42
|
+
const identity = ws.raw ?? ws;
|
|
43
|
+
if (!this.clients.has(identity) && this.clients.size >= this.maxConnections) return false;
|
|
44
|
+
this.clients.set(identity, ws);
|
|
45
|
+
return true;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
remove(ws: WSContext): void {
|
|
34
|
-
this.clients.delete(ws);
|
|
49
|
+
this.clients.delete(ws.raw ?? ws);
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
count(): number {
|
|
38
53
|
return this.clients.size;
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
/** Whether another distinct socket can be admitted without exceeding the cap. */
|
|
57
|
+
hasCapacity(): boolean {
|
|
58
|
+
return this.clients.size < this.maxConnections;
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
hasClients(): boolean {
|
|
42
62
|
return this.clients.size > 0;
|
|
43
63
|
}
|
|
44
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Close and forget every attached socket. The set is cleared before close
|
|
67
|
+
* callbacks can run, and one broken socket cannot prevent the others from
|
|
68
|
+
* being invalidated.
|
|
69
|
+
*/
|
|
70
|
+
closeAll(code: number, reason: string): void {
|
|
71
|
+
const clients = [...this.clients.values()];
|
|
72
|
+
this.clients.clear();
|
|
73
|
+
for (const ws of clients) {
|
|
74
|
+
try {
|
|
75
|
+
ws.close?.(code, reason);
|
|
76
|
+
} catch {
|
|
77
|
+
// Best effort per socket; revocation of the remaining clients must
|
|
78
|
+
// continue even when one adapter throws during close.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
/**
|
|
46
84
|
* Broadcast a frame to every attached client. Serializes once. A failing
|
|
47
85
|
* socket is skipped (its `onClose` will prune it) so one dead peer can't
|
|
@@ -50,13 +88,13 @@ export class ClientSet {
|
|
|
50
88
|
broadcast(msg: ServerMessage, onSendError?: (err: unknown) => void): void {
|
|
51
89
|
if (this.clients.size === 0) return;
|
|
52
90
|
const payload = JSON.stringify(shrinkForReplication(msg));
|
|
53
|
-
for (const ws of this.clients) {
|
|
91
|
+
for (const ws of this.clients.values()) {
|
|
54
92
|
try {
|
|
55
93
|
// Bun's ServerWebSocket reports a dropped write by RETURNING 0 (closed
|
|
56
94
|
// connection) rather than throwing, and hono's WSContext.send discards
|
|
57
95
|
// that status — so write through the raw socket where one exists. -1
|
|
58
96
|
// is backpressure: the frame is queued, not lost.
|
|
59
|
-
if (
|
|
97
|
+
if (canSendRaw(ws.raw)) {
|
|
60
98
|
if (ws.raw.send(payload) === 0) {
|
|
61
99
|
onSendError?.(new Error("send dropped: connection closed"));
|
|
62
100
|
}
|
package/src/ws/connection.ts
CHANGED
|
@@ -49,6 +49,9 @@ import type { WSContext } from "./clients.js";
|
|
|
49
49
|
|
|
50
50
|
export { websocket };
|
|
51
51
|
|
|
52
|
+
const CONNECTION_LIMIT_CLOSE_CODE = 4008;
|
|
53
|
+
const CONNECTION_LIMIT_CLOSE_REASON = "Connection limit reached";
|
|
54
|
+
|
|
52
55
|
/**
|
|
53
56
|
* The socket lifecycle handlers for one host, separate from the Hono upgrade
|
|
54
57
|
* that wraps them.
|
|
@@ -68,6 +71,14 @@ export function createWsHandlers(host: WsHost) {
|
|
|
68
71
|
|
|
69
72
|
return {
|
|
70
73
|
async onOpen(_evt: Event, ws: WSContext) {
|
|
74
|
+
if (!host.clients.add(ws)) {
|
|
75
|
+
host.reportRefusedConnection();
|
|
76
|
+
// Hono's WSContext always exposes close(); the local structural socket
|
|
77
|
+
// type keeps it optional because send-only test/dispatch fakes never
|
|
78
|
+
// exercise connection admission.
|
|
79
|
+
ws.close!(CONNECTION_LIMIT_CLOSE_CODE, CONNECTION_LIMIT_CLOSE_REASON);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
71
82
|
host.log.emit({ severityText: "INFO", body: "client connected" });
|
|
72
83
|
const { coordinator, catalog } = host;
|
|
73
84
|
|
|
@@ -123,7 +134,6 @@ export function createWsHandlers(host: WsHost) {
|
|
|
123
134
|
attributes: { error: err instanceof Error ? err.message : String(err) },
|
|
124
135
|
});
|
|
125
136
|
} finally {
|
|
126
|
-
host.clients.add(ws);
|
|
127
137
|
host.sendMessage(
|
|
128
138
|
ws,
|
|
129
139
|
withTurnScope(
|
|
@@ -141,7 +151,6 @@ export function createWsHandlers(host: WsHost) {
|
|
|
141
151
|
return;
|
|
142
152
|
}
|
|
143
153
|
|
|
144
|
-
host.clients.add(ws);
|
|
145
154
|
host.sendMessage(ws, {
|
|
146
155
|
type: "status",
|
|
147
156
|
status: "idle",
|
package/src/ws/host.ts
CHANGED
|
@@ -75,6 +75,8 @@ export interface WsHostOptions {
|
|
|
75
75
|
* fronting the socket with its own limiter wants.
|
|
76
76
|
*/
|
|
77
77
|
wsRate?: { ratePerSecond: number; burst: number };
|
|
78
|
+
/** Maximum WebSocket connections accepted by this host (default 32). */
|
|
79
|
+
wsMaxConnections?: number;
|
|
78
80
|
/**
|
|
79
81
|
* Activity recording (span store + live stream). Optional: a host without
|
|
80
82
|
* one records nothing and never sends activity frames — which is also what
|
|
@@ -124,7 +126,7 @@ export function turnLogAttributes(turn: TurnLogContext): Record<string, string>
|
|
|
124
126
|
*/
|
|
125
127
|
export class WsHost {
|
|
126
128
|
readonly coordinator = new TurnCoordinator();
|
|
127
|
-
readonly clients
|
|
129
|
+
readonly clients: ClientSet;
|
|
128
130
|
readonly registry: BackendRegistry;
|
|
129
131
|
catalog: SessionCatalog;
|
|
130
132
|
appName: string;
|
|
@@ -151,8 +153,12 @@ export class WsHost {
|
|
|
151
153
|
private readonly wsErrors: ReturnType<
|
|
152
154
|
ReturnType<Observability["meter"]>["createCounter"]
|
|
153
155
|
>;
|
|
156
|
+
private readonly connectionsRefused: ReturnType<
|
|
157
|
+
ReturnType<Observability["meter"]>["createCounter"]
|
|
158
|
+
>;
|
|
154
159
|
|
|
155
160
|
constructor(options: WsHostOptions) {
|
|
161
|
+
this.clients = new ClientSet(options.wsMaxConnections);
|
|
156
162
|
this.registry = options.registry;
|
|
157
163
|
this.catalog = options.catalog;
|
|
158
164
|
this.appName = options.appName ?? "Brain UI";
|
|
@@ -181,6 +187,9 @@ export class WsHost {
|
|
|
181
187
|
this.wsErrors = meter.createCounter("ws.errors", {
|
|
182
188
|
description: "Transport errors reported by the socket layer",
|
|
183
189
|
});
|
|
190
|
+
this.connectionsRefused = meter.createCounter("ws.connections.refused", {
|
|
191
|
+
description: "WebSocket connections refused before admission",
|
|
192
|
+
});
|
|
184
193
|
this.coordinator.log = this.log;
|
|
185
194
|
}
|
|
186
195
|
|
|
@@ -206,6 +215,19 @@ export class WsHost {
|
|
|
206
215
|
});
|
|
207
216
|
}
|
|
208
217
|
|
|
218
|
+
/** Record a socket refused because this process has reached its connection cap. */
|
|
219
|
+
reportRefusedConnection(): void {
|
|
220
|
+
this.connectionsRefused.add(1, { reason: "connection_limit" });
|
|
221
|
+
this.log.emit({
|
|
222
|
+
severityText: "WARN",
|
|
223
|
+
body: "websocket connection refused",
|
|
224
|
+
attributes: {
|
|
225
|
+
reason: "connection_limit",
|
|
226
|
+
"connection.limit": this.clients.maxConnections,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
209
231
|
/** A turn began executing: counted, and logged with its correlation ids. */
|
|
210
232
|
reportTurnStarted(turn: TurnLogContext): void {
|
|
211
233
|
this.turnsStarted.add(1);
|