@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.
Files changed (95) hide show
  1. package/README.md +46 -5
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +49 -25
  4. package/dist/app.js.map +1 -1
  5. package/dist/config/env.d.ts +6 -5
  6. package/dist/config/env.d.ts.map +1 -1
  7. package/dist/config/env.js +13 -7
  8. package/dist/config/env.js.map +1 -1
  9. package/dist/files/walker.d.ts.map +1 -1
  10. package/dist/files/walker.js +70 -10
  11. package/dist/files/walker.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/middleware/auth.d.ts +25 -5
  17. package/dist/middleware/auth.d.ts.map +1 -1
  18. package/dist/middleware/auth.js +255 -47
  19. package/dist/middleware/auth.js.map +1 -1
  20. package/dist/middleware/body-limit.d.ts +15 -0
  21. package/dist/middleware/body-limit.d.ts.map +1 -0
  22. package/dist/middleware/body-limit.js +41 -0
  23. package/dist/middleware/body-limit.js.map +1 -0
  24. package/dist/middleware/origin.d.ts +20 -12
  25. package/dist/middleware/origin.d.ts.map +1 -1
  26. package/dist/middleware/origin.js +71 -25
  27. package/dist/middleware/origin.js.map +1 -1
  28. package/dist/middleware/passkeys.d.ts +5 -1
  29. package/dist/middleware/passkeys.d.ts.map +1 -1
  30. package/dist/middleware/passkeys.js +67 -26
  31. package/dist/middleware/passkeys.js.map +1 -1
  32. package/dist/routes/brain.d.ts.map +1 -1
  33. package/dist/routes/brain.js +7 -2
  34. package/dist/routes/brain.js.map +1 -1
  35. package/dist/routes/files.js +2 -2
  36. package/dist/routes/files.js.map +1 -1
  37. package/dist/routes/models.d.ts.map +1 -1
  38. package/dist/routes/models.js +22 -10
  39. package/dist/routes/models.js.map +1 -1
  40. package/dist/routes/pi-auth.d.ts.map +1 -1
  41. package/dist/routes/pi-auth.js +12 -4
  42. package/dist/routes/pi-auth.js.map +1 -1
  43. package/dist/routes/push.d.ts.map +1 -1
  44. package/dist/routes/push.js +12 -4
  45. package/dist/routes/push.js.map +1 -1
  46. package/dist/routes/render.d.ts +6 -0
  47. package/dist/routes/render.d.ts.map +1 -1
  48. package/dist/routes/render.js +8 -11
  49. package/dist/routes/render.js.map +1 -1
  50. package/dist/routes/share.d.ts +2 -0
  51. package/dist/routes/share.d.ts.map +1 -1
  52. package/dist/routes/share.js +30 -31
  53. package/dist/routes/share.js.map +1 -1
  54. package/dist/routes/skills.d.ts.map +1 -1
  55. package/dist/routes/skills.js +22 -8
  56. package/dist/routes/skills.js.map +1 -1
  57. package/dist/routes/web-search.d.ts.map +1 -1
  58. package/dist/routes/web-search.js +7 -2
  59. package/dist/routes/web-search.js.map +1 -1
  60. package/dist/share/staging.d.ts.map +1 -1
  61. package/dist/share/staging.js +2 -1
  62. package/dist/share/staging.js.map +1 -1
  63. package/dist/ws/clients.d.ts +13 -4
  64. package/dist/ws/clients.d.ts.map +1 -1
  65. package/dist/ws/clients.js +41 -5
  66. package/dist/ws/clients.js.map +1 -1
  67. package/dist/ws/connection.d.ts.map +1 -1
  68. package/dist/ws/connection.js +10 -2
  69. package/dist/ws/connection.js.map +1 -1
  70. package/dist/ws/host.d.ts +5 -0
  71. package/dist/ws/host.d.ts.map +1 -1
  72. package/dist/ws/host.js +18 -1
  73. package/dist/ws/host.js.map +1 -1
  74. package/package.json +3 -3
  75. package/src/app.ts +55 -23
  76. package/src/config/env.ts +18 -7
  77. package/src/files/walker.ts +66 -10
  78. package/src/index.ts +8 -0
  79. package/src/middleware/auth.ts +311 -48
  80. package/src/middleware/body-limit.ts +47 -0
  81. package/src/middleware/origin.ts +92 -24
  82. package/src/middleware/passkeys.ts +80 -29
  83. package/src/routes/brain.ts +7 -3
  84. package/src/routes/files.ts +2 -2
  85. package/src/routes/models.ts +17 -10
  86. package/src/routes/pi-auth.ts +10 -4
  87. package/src/routes/push.ts +10 -4
  88. package/src/routes/render.ts +14 -10
  89. package/src/routes/share.ts +35 -34
  90. package/src/routes/skills.ts +18 -8
  91. package/src/routes/web-search.ts +6 -2
  92. package/src/share/staging.ts +2 -1
  93. package/src/ws/clients.ts +45 -7
  94. package/src/ws/connection.ts +11 -2
  95. package/src/ws/host.ts +23 -1
@@ -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 body = (await c.req.json().catch(() => null)) as {
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 body = (await c.req.json().catch(() => null)) as {
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 body = (await c.req.json().catch(() => null)) as { content?: unknown } | null;
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 body = (await c.req.json().catch(() => null)) as { enabled?: unknown } | null;
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
  }
@@ -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 body = (await c.req.json().catch(() => null)) as {
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;
@@ -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 = shareStagingRoot(brainPath);
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
- raw?: { send?: (data: string) => number } | undefined;
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
- private readonly clients = new Set<WSContext>();
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): void {
30
- this.clients.add(ws);
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 (typeof ws.raw?.send === "function") {
97
+ if (canSendRaw(ws.raw)) {
60
98
  if (ws.raw.send(payload) === 0) {
61
99
  onSendError?.(new Error("send dropped: connection closed"));
62
100
  }
@@ -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 = new ClientSet();
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);