@schlessera/brain-ui-server 0.34.0 → 0.35.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 (169) hide show
  1. package/README.md +28 -0
  2. package/dist/activity/notify.d.ts.map +1 -1
  3. package/dist/activity/notify.js +18 -8
  4. package/dist/activity/notify.js.map +1 -1
  5. package/dist/activity/push-sender.d.ts +7 -3
  6. package/dist/activity/push-sender.d.ts.map +1 -1
  7. package/dist/activity/push-sender.js +45 -11
  8. package/dist/activity/push-sender.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +8 -0
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +11 -1
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +52 -2
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts.map +1 -1
  17. package/dist/activity/runtime.js +2 -5
  18. package/dist/activity/runtime.js.map +1 -1
  19. package/dist/activity/sql.d.ts +34 -0
  20. package/dist/activity/sql.d.ts.map +1 -0
  21. package/dist/activity/sql.js +114 -0
  22. package/dist/activity/sql.js.map +1 -0
  23. package/dist/activity/store.d.ts +7 -0
  24. package/dist/activity/store.d.ts.map +1 -1
  25. package/dist/activity/store.js +48 -113
  26. package/dist/activity/store.js.map +1 -1
  27. package/dist/activity/stream.d.ts +3 -1
  28. package/dist/activity/stream.d.ts.map +1 -1
  29. package/dist/activity/stream.js +32 -17
  30. package/dist/activity/stream.js.map +1 -1
  31. package/dist/app-env.d.ts +8 -0
  32. package/dist/app-env.d.ts.map +1 -0
  33. package/dist/app-env.js +2 -0
  34. package/dist/app-env.js.map +1 -0
  35. package/dist/app.d.ts.map +1 -1
  36. package/dist/app.js +28 -3
  37. package/dist/app.js.map +1 -1
  38. package/dist/brain/client.d.ts +4 -2
  39. package/dist/brain/client.d.ts.map +1 -1
  40. package/dist/brain/client.js +60 -20
  41. package/dist/brain/client.js.map +1 -1
  42. package/dist/brain/types.d.ts +9 -0
  43. package/dist/brain/types.d.ts.map +1 -1
  44. package/dist/cron/run-job.d.ts.map +1 -1
  45. package/dist/cron/run-job.js +60 -10
  46. package/dist/cron/run-job.js.map +1 -1
  47. package/dist/db/principals.d.ts +72 -0
  48. package/dist/db/principals.d.ts.map +1 -0
  49. package/dist/db/principals.js +294 -0
  50. package/dist/db/principals.js.map +1 -0
  51. package/dist/files/walker.d.ts.map +1 -1
  52. package/dist/files/walker.js +6 -1
  53. package/dist/files/walker.js.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/middleware/auth.d.ts +37 -9
  59. package/dist/middleware/auth.d.ts.map +1 -1
  60. package/dist/middleware/auth.js +148 -74
  61. package/dist/middleware/auth.js.map +1 -1
  62. package/dist/middleware/passkeys.d.ts +9 -7
  63. package/dist/middleware/passkeys.d.ts.map +1 -1
  64. package/dist/middleware/passkeys.js +25 -7
  65. package/dist/middleware/passkeys.js.map +1 -1
  66. package/dist/middleware/principals.d.ts +18 -0
  67. package/dist/middleware/principals.d.ts.map +1 -0
  68. package/dist/middleware/principals.js +138 -0
  69. package/dist/middleware/principals.js.map +1 -0
  70. package/dist/middleware/require-owner.d.ts +5 -0
  71. package/dist/middleware/require-owner.d.ts.map +1 -0
  72. package/dist/middleware/require-owner.js +10 -0
  73. package/dist/middleware/require-owner.js.map +1 -0
  74. package/dist/observability/loggers.js +1 -1
  75. package/dist/observability/loggers.js.map +1 -1
  76. package/dist/routes/activity.d.ts.map +1 -1
  77. package/dist/routes/activity.js +3 -0
  78. package/dist/routes/activity.js.map +1 -1
  79. package/dist/routes/brain.d.ts.map +1 -1
  80. package/dist/routes/brain.js +102 -63
  81. package/dist/routes/brain.js.map +1 -1
  82. package/dist/routes/files.d.ts.map +1 -1
  83. package/dist/routes/files.js +7 -2
  84. package/dist/routes/files.js.map +1 -1
  85. package/dist/routes/push.d.ts +61 -2
  86. package/dist/routes/push.d.ts.map +1 -1
  87. package/dist/routes/push.js +8 -3
  88. package/dist/routes/push.js.map +1 -1
  89. package/dist/routes/sessions.d.ts.map +1 -1
  90. package/dist/routes/sessions.js +30 -4
  91. package/dist/routes/sessions.js.map +1 -1
  92. package/dist/share/staging.d.ts.map +1 -1
  93. package/dist/share/staging.js +11 -4
  94. package/dist/share/staging.js.map +1 -1
  95. package/dist/ws/bridge.d.ts.map +1 -1
  96. package/dist/ws/bridge.js +6 -2
  97. package/dist/ws/bridge.js.map +1 -1
  98. package/dist/ws/clients.d.ts +9 -2
  99. package/dist/ws/clients.d.ts.map +1 -1
  100. package/dist/ws/clients.js +44 -6
  101. package/dist/ws/clients.js.map +1 -1
  102. package/dist/ws/connection.d.ts +7 -4
  103. package/dist/ws/connection.d.ts.map +1 -1
  104. package/dist/ws/connection.js +83 -12
  105. package/dist/ws/connection.js.map +1 -1
  106. package/dist/ws/dispatch.d.ts +12 -1
  107. package/dist/ws/dispatch.d.ts.map +1 -1
  108. package/dist/ws/dispatch.js +46 -6
  109. package/dist/ws/dispatch.js.map +1 -1
  110. package/dist/ws/history.d.ts.map +1 -1
  111. package/dist/ws/history.js +7 -5
  112. package/dist/ws/history.js.map +1 -1
  113. package/dist/ws/host.d.ts +22 -0
  114. package/dist/ws/host.d.ts.map +1 -1
  115. package/dist/ws/host.js +39 -1
  116. package/dist/ws/host.js.map +1 -1
  117. package/dist/ws/run-session.d.ts +7 -7
  118. package/dist/ws/run-session.d.ts.map +1 -1
  119. package/dist/ws/run-session.js +208 -52
  120. package/dist/ws/run-session.js.map +1 -1
  121. package/dist/ws/shrink.d.ts +3 -1
  122. package/dist/ws/shrink.d.ts.map +1 -1
  123. package/dist/ws/shrink.js +46 -16
  124. package/dist/ws/shrink.js.map +1 -1
  125. package/dist/ws/turns.d.ts +58 -3
  126. package/dist/ws/turns.d.ts.map +1 -1
  127. package/dist/ws/turns.js +101 -2
  128. package/dist/ws/turns.js.map +1 -1
  129. package/migrations/011_principals.sql +13 -0
  130. package/migrations/012_activity_principals.sql +12 -0
  131. package/migrations/013_push_subscription_principals.sql +7 -0
  132. package/migrations/014_invalidate_legacy_sessions.sql +14 -0
  133. package/package.json +3 -3
  134. package/src/activity/notify.ts +16 -7
  135. package/src/activity/push-sender.ts +79 -16
  136. package/src/activity/query.ts +8 -0
  137. package/src/activity/recorder.ts +68 -3
  138. package/src/activity/runtime.ts +2 -5
  139. package/src/activity/sql.ts +122 -0
  140. package/src/activity/store.ts +73 -155
  141. package/src/activity/stream.ts +37 -17
  142. package/src/app-env.ts +8 -0
  143. package/src/app.ts +33 -4
  144. package/src/brain/client.ts +61 -20
  145. package/src/brain/types.ts +10 -0
  146. package/src/cron/run-job.ts +41 -17
  147. package/src/db/principals.ts +452 -0
  148. package/src/files/walker.ts +5 -1
  149. package/src/index.ts +6 -1
  150. package/src/middleware/auth.ts +241 -81
  151. package/src/middleware/passkeys.ts +35 -14
  152. package/src/middleware/principals.ts +189 -0
  153. package/src/middleware/require-owner.ts +13 -0
  154. package/src/observability/loggers.ts +1 -1
  155. package/src/routes/activity.ts +3 -0
  156. package/src/routes/brain.ts +107 -70
  157. package/src/routes/files.ts +10 -5
  158. package/src/routes/push.ts +11 -5
  159. package/src/routes/sessions.ts +45 -20
  160. package/src/share/staging.ts +11 -4
  161. package/src/ws/bridge.ts +14 -2
  162. package/src/ws/clients.ts +52 -7
  163. package/src/ws/connection.ts +98 -20
  164. package/src/ws/dispatch.ts +66 -6
  165. package/src/ws/history.ts +7 -5
  166. package/src/ws/host.ts +59 -1
  167. package/src/ws/run-session.ts +247 -74
  168. package/src/ws/shrink.ts +48 -14
  169. package/src/ws/turns.ts +136 -5
package/src/ws/bridge.ts CHANGED
@@ -114,8 +114,20 @@ export function makeBridge(
114
114
  // The decision stamps the wait/execution boundary on the tool span
115
115
  // (grant) or lands the denied outcome (deny) before the backend's
116
116
  // own error tool_result can mislabel it — write-once protects it.
117
- const recorded = (decision: PermissionDecision) => {
118
- recorder?.onApprovalDecision(req.toolUseId, decision.behavior === "allow");
117
+ const recorded = (
118
+ decision: PermissionDecision,
119
+ response?: { principalId: string; always?: boolean }
120
+ ) => {
121
+ recorder?.onApprovalDecision(
122
+ req.toolUseId,
123
+ decision.behavior === "deny"
124
+ ? "deny"
125
+ : response?.always
126
+ ? "always_allow"
127
+ : "allow",
128
+ req.kind ?? "tool",
129
+ response?.principalId
130
+ );
119
131
  resolve(decision);
120
132
  };
121
133
  coordinator.pendingApprovals.set(req.toolUseId, {
package/src/ws/clients.ts CHANGED
@@ -12,6 +12,12 @@ export type WSContext = {
12
12
  raw?: unknown;
13
13
  };
14
14
 
15
+ interface AttachedClient {
16
+ ws: WSContext;
17
+ principalId: string;
18
+ onRemove?: () => void;
19
+ }
20
+
15
21
  function canSendRaw(raw: unknown): raw is { send: (data: string) => unknown } {
16
22
  return (
17
23
  typeof raw === "object" && raw !== null && "send" in raw && typeof raw.send === "function"
@@ -34,19 +40,27 @@ export class ClientSet {
34
40
  // Hono creates a new WSContext for every Bun open/message/close callback.
35
41
  // The raw socket remains stable across those wrappers, so it is the identity
36
42
  // that admission and removal must share.
37
- private readonly clients = new Map<unknown, WSContext>();
43
+ private readonly clients = new Map<unknown, AttachedClient>();
38
44
 
39
- constructor(readonly maxConnections = 32) {}
45
+ constructor(
46
+ readonly maxConnections = 32,
47
+ private readonly invalidateAuthorizations: (
48
+ principalIds: ReadonlySet<string>
49
+ ) => void = () => {}
50
+ ) {}
40
51
 
41
- add(ws: WSContext): boolean {
52
+ add(ws: WSContext, principalId: string, options?: { onRemove?: () => void }): boolean {
42
53
  const identity = ws.raw ?? ws;
43
54
  if (!this.clients.has(identity) && this.clients.size >= this.maxConnections) return false;
44
- this.clients.set(identity, ws);
55
+ this.clients.set(identity, { ws, principalId, ...options });
45
56
  return true;
46
57
  }
47
58
 
48
59
  remove(ws: WSContext): void {
49
- this.clients.delete(ws.raw ?? ws);
60
+ const identity = ws.raw ?? ws;
61
+ const client = this.clients.get(identity);
62
+ this.clients.delete(identity);
63
+ client?.onRemove?.();
50
64
  }
51
65
 
52
66
  count(): number {
@@ -62,6 +76,32 @@ export class ClientSet {
62
76
  return this.clients.size > 0;
63
77
  }
64
78
 
79
+ /**
80
+ * Close and forget only sockets admitted for one principal.
81
+ */
82
+ closeFor(principalId: string, code: number, reason: string): void {
83
+ const closing: AttachedClient[] = [];
84
+ // Same boundary as closeAll: invalidate through the coordinator FIRST,
85
+ // while the socket-owned references still make every pending dispatch
86
+ // discoverable. Releasing them first would let a frame parsed before this
87
+ // call start a turn after it — measured as 0 starts before this became a
88
+ // registry, 1 after.
89
+ this.invalidateAuthorizations(new Set([principalId]));
90
+ for (const [identity, client] of this.clients) {
91
+ if (client.principalId !== principalId) continue;
92
+ this.clients.delete(identity);
93
+ client.onRemove?.();
94
+ closing.push(client);
95
+ }
96
+ for (const { ws } of closing) {
97
+ try {
98
+ ws.close?.(code, reason);
99
+ } catch {
100
+ // Best effort per socket; revocation of sibling sockets must continue.
101
+ }
102
+ }
103
+ }
104
+
65
105
  /**
66
106
  * Close and forget every attached socket. The set is cleared before close
67
107
  * callbacks can run, and one broken socket cannot prevent the others from
@@ -69,8 +109,13 @@ export class ClientSet {
69
109
  */
70
110
  closeAll(code: number, reason: string): void {
71
111
  const clients = [...this.clients.values()];
112
+ // Forced closure is also an authorization boundary. Invalidate through
113
+ // the coordinator while socket-owned references still keep every pending
114
+ // dispatch discoverable; the loop below remains transport cleanup only.
115
+ this.invalidateAuthorizations(new Set(clients.map((client) => client.principalId)));
72
116
  this.clients.clear();
73
- for (const ws of clients) {
117
+ for (const client of clients) client.onRemove?.();
118
+ for (const { ws } of clients) {
74
119
  try {
75
120
  ws.close?.(code, reason);
76
121
  } catch {
@@ -88,7 +133,7 @@ export class ClientSet {
88
133
  broadcast(msg: ServerMessage, onSendError?: (err: unknown) => void): void {
89
134
  if (this.clients.size === 0) return;
90
135
  const payload = JSON.stringify(shrinkForReplication(msg));
91
- for (const ws of this.clients.values()) {
136
+ for (const { ws } of this.clients.values()) {
92
137
  try {
93
138
  // Bun's ServerWebSocket reports a dropped write by RETURNING 0 (closed
94
139
  // connection) rather than throwing, and hono's WSContext.send discards
@@ -1,8 +1,11 @@
1
1
  import { upgradeWebSocket, websocket } from "hono/bun";
2
+ import type { MiddlewareHandler } from "hono";
2
3
  import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
3
4
  import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
4
5
  import { withTurnScope } from "./frames.js";
5
6
  import type { WSContext as WSContextType } from "./clients.js";
7
+ import type { Principal } from "../db/principals.js";
8
+ import type { AppEnv } from "../app-env.js";
6
9
 
7
10
  /**
8
11
  * Re-send every pending approval and ask-user card to a client that just
@@ -51,6 +54,8 @@ export { websocket };
51
54
 
52
55
  const CONNECTION_LIMIT_CLOSE_CODE = 4008;
53
56
  const CONNECTION_LIMIT_CLOSE_REASON = "Connection limit reached";
57
+ const REVOKED_BEFORE_ADMISSION_CLOSE_CODE = 1008;
58
+ const REVOKED_BEFORE_ADMISSION_CLOSE_REASON = "Sessions invalidated";
54
59
 
55
60
  /**
56
61
  * The socket lifecycle handlers for one host, separate from the Hono upgrade
@@ -61,17 +66,35 @@ const CONNECTION_LIMIT_CLOSE_REASON = "Connection limit reached";
61
66
  * production runs, which is what makes an assertion about a dropped frame an
62
67
  * assertion about the shipped code rather than about a re-implementation.
63
68
  */
64
- export function createWsHandlers(host: WsHost) {
69
+ export function createWsHandlers(host: WsHost, principal: Principal) {
65
70
  // One bucket per connection, created here so it lives and dies with the
66
71
  // socket rather than in a map keyed by something a peer controls.
67
72
  const limiter = host.newRateLimiter();
73
+ // Authentication can await before it reaches this handler. Re-read durable
74
+ // validity as the coordinator constructs and synchronously registers the
75
+ // context, so there is no unregistered authorization state to lose.
76
+ const authorization = host.coordinator.openAuthorization({
77
+ principalId: principal.id,
78
+ expiresAt: principal.expiresAt,
79
+ valid: host.isPrincipalValid(principal),
80
+ });
68
81
  // Per-connection negotiation state: what revision this client declared.
69
82
  // Lives with the socket, like the limiter.
70
- const connection: ConnectionState = {};
71
-
83
+ const connection: ConnectionState = {
84
+ principal,
85
+ authorization,
86
+ };
72
87
  return {
73
88
  async onOpen(_evt: Event, ws: WSContext) {
74
- if (!host.clients.add(ws)) {
89
+ if (!connection.authorization.valid) {
90
+ connection.authorization.release();
91
+ ws.close!(REVOKED_BEFORE_ADMISSION_CLOSE_CODE, REVOKED_BEFORE_ADMISSION_CLOSE_REASON);
92
+ return;
93
+ }
94
+ if (
95
+ !host.clients.add(ws, principal.id, { onRemove: connection.authorization.release })
96
+ ) {
97
+ connection.authorization.release();
75
98
  host.reportRefusedConnection();
76
99
  // Hono's WSContext always exposes close(); the local structural socket
77
100
  // type keeps it optional because send-only test/dispatch fakes never
@@ -121,12 +144,17 @@ export function createWsHandlers(host: WsHost) {
121
144
  turn
122
145
  )
123
146
  );
147
+ // Snapshot-on-connect owns a lease separate from the socket: a close
148
+ // while history loads must not make this work invisible to revocation.
149
+ const releaseAuthorization = connection.authorization.retain();
124
150
  try {
125
151
  const backend = await host.registry.getBackendForSession(
126
152
  catalog.getStoredBackendId(sid) ?? turn.backend.id
127
153
  );
128
154
  const history = await backend.getHistory(sid);
129
- if (history.length > 0) sendSessionHistory(ws, sid, history);
155
+ if (connection.authorization.valid && history.length > 0) {
156
+ sendSessionHistory(ws, sid, history);
157
+ }
130
158
  } catch (err) {
131
159
  host.log.emit({
132
160
  severityText: "ERROR",
@@ -134,19 +162,22 @@ export function createWsHandlers(host: WsHost) {
134
162
  attributes: { error: err instanceof Error ? err.message : String(err) },
135
163
  });
136
164
  } finally {
137
- host.sendMessage(
138
- ws,
139
- withTurnScope(
140
- {
141
- type: "status",
142
- status: "thinking",
143
- detail: "Session in progress",
144
- sessionId: sid,
145
- },
146
- turn
147
- )
148
- );
149
- resendPendingInteractive(host, ws);
165
+ releaseAuthorization();
166
+ if (connection.authorization.valid) {
167
+ host.sendMessage(
168
+ ws,
169
+ withTurnScope(
170
+ {
171
+ type: "status",
172
+ status: "thinking",
173
+ detail: "Session in progress",
174
+ sessionId: sid,
175
+ },
176
+ turn
177
+ )
178
+ );
179
+ resendPendingInteractive(host, ws);
180
+ }
150
181
  }
151
182
  return;
152
183
  }
@@ -160,6 +191,16 @@ export function createWsHandlers(host: WsHost) {
160
191
  },
161
192
 
162
193
  onMessage(evt: MessageEvent, ws: WSContext) {
194
+ if (
195
+ connection.authorization.valid &&
196
+ connection.authorization.expiresAt <= Date.now()
197
+ ) {
198
+ host.expireAuthorizationContexts();
199
+ }
200
+ if (!connection.authorization.valid) {
201
+ host.reportDroppedFrame("revoked_principal");
202
+ return;
203
+ }
163
204
  // Boundary validation (rev 2): byte cap + JSON decode + schema, in one
164
205
  // place. No more casting client JSON to ClientMessage.
165
206
  //
@@ -197,6 +238,12 @@ export function createWsHandlers(host: WsHost) {
197
238
  host.sendMessage(ws, { type: "error", code: "PARSE_ERROR", message: parsed.error });
198
239
  return;
199
240
  }
241
+ // Parsing is synchronous, but dispatch deliberately starts on a
242
+ // microtask. Keep this shared authority discoverable across that gap —
243
+ // including after an ordinary disconnect removes the admitted client.
244
+ // runSession adds its own reference before this one is released, so a
245
+ // chat startup remains continuously revocable through routing/billing.
246
+ const releaseAuthorization = connection.authorization.retain();
200
247
  // handleClientMessage is async — a rejection must not escape as an
201
248
  // unhandled rejection with no frame sent.
202
249
  void Promise.resolve()
@@ -217,10 +264,19 @@ export function createWsHandlers(host: WsHost) {
217
264
  code: "INTERNAL_ERROR",
218
265
  message: "Failed to handle message",
219
266
  });
267
+ })
268
+ .finally(() => {
269
+ releaseAuthorization();
220
270
  });
221
271
  },
222
272
 
223
273
  onClose(evt: CloseEvent, ws: WSContext) {
274
+ // Closure is not authorization: a frame parsed before this callback can
275
+ // still be mid-dispatch, and an activity_subscribe landing after the
276
+ // cleanup below would re-register the dead socket and keep the activity
277
+ // poller awake. Authorization stays whatever it is; this says the
278
+ // transport is gone.
279
+ connection.closed = true;
224
280
  host.log.emit({ severityText: "INFO", body: "client disconnected" });
225
281
  // No onError here on purpose: hono's Bun adapter never dispatches it
226
282
  // (only open/message/close reach these handlers), so a transport failure
@@ -232,6 +288,7 @@ export function createWsHandlers(host: WsHost) {
232
288
  host.reportAbnormalClose(code);
233
289
  }
234
290
  host.clients.remove(ws);
291
+ connection.authorization.release();
235
292
  host.activity?.stream.dropConnection(ws);
236
293
  // Turns keep running in the background. Once the LAST client leaves,
237
294
  // reject only the requests that need a live client RIGHT NOW (location,
@@ -244,10 +301,31 @@ export function createWsHandlers(host: WsHost) {
244
301
  host.coordinator.drainClientBoundForTurn(turn, "Client disconnected");
245
302
  }
246
303
  },
304
+
305
+ /** The HTTP upgrade failed, so no socket lifecycle callback will clean up. */
306
+ onUpgradeFailed() {
307
+ connection.authorization.release();
308
+ },
247
309
  };
248
310
  }
249
311
 
250
312
  /** Build the Hono WebSocket upgrade handler bound to one host. */
251
- export function createWsUpgrade(host: WsHost) {
252
- return upgradeWebSocket(() => createWsHandlers(host));
313
+ export function createWsUpgrade(host: WsHost): MiddlewareHandler<AppEnv> {
314
+ return async (c, next) => {
315
+ const principal = c.get("principal");
316
+ if (!principal) {
317
+ return c.json({ error: "Authentication required" }, 401);
318
+ }
319
+ const handlers = createWsHandlers(host, principal);
320
+ const upgrade = upgradeWebSocket(() => handlers);
321
+ try {
322
+ return await upgrade(c, async () => {
323
+ handlers.onUpgradeFailed();
324
+ await next();
325
+ });
326
+ } catch (err) {
327
+ handlers.onUpgradeFailed();
328
+ throw err;
329
+ }
330
+ };
253
331
  }
@@ -1,6 +1,7 @@
1
1
  import { PROTOCOL_REV_CLIENT_ECHO } from "@schlessera/brain-ui-sdk/protocol";
2
2
  import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
3
3
  import type { WSContext } from "./clients.js";
4
+ import type { AuthorizationContext } from "./turns.js";
4
5
  import { locationErrorText } from "./frames.js";
5
6
  import { sendSessionHistory } from "./history.js";
6
7
  import { validateAttachments } from "./attachments.js";
@@ -9,6 +10,16 @@ import type { WsHost } from "./host.js";
9
10
 
10
11
  /** Per-connection negotiation state, owned by the socket handler. */
11
12
  export interface ConnectionState {
13
+ /**
14
+ * The transport is gone. Set by onClose, and separate from `authorization`
15
+ * because a disconnect is not a revocation: work already underway keeps its
16
+ * authority, but nothing may register new per-connection state afterwards.
17
+ */
18
+ closed?: boolean;
19
+ /** Principal resolved once at the WebSocket upgrade boundary. */
20
+ principal: import("../db/principals.js").Principal;
21
+ /** Mutable in-memory decision invalidated synchronously by revocation. */
22
+ authorization: AuthorizationContext;
12
23
  /** Revision the client declared via `client_hello`; absent means rev 2. */
13
24
  protocolRev?: number;
14
25
  }
@@ -39,8 +50,21 @@ export async function handleClientMessage(
39
50
  host: WsHost,
40
51
  ws: WSContext,
41
52
  msg: ClientMessage,
42
- connection: ConnectionState = {}
53
+ connection: ConnectionState
43
54
  ): Promise<void> {
55
+ // The socket callback schedules dispatch on a microtask. Revocation may land
56
+ // after parsing but before this function begins, so repeat the in-memory
57
+ // check at the actual dispatch boundary.
58
+ if (
59
+ connection.authorization.valid &&
60
+ connection.authorization.expiresAt <= Date.now()
61
+ ) {
62
+ host.expireAuthorizationContexts();
63
+ }
64
+ if (!connection.authorization.valid) {
65
+ host.reportDroppedFrame("revoked_principal");
66
+ return;
67
+ }
44
68
  const { coordinator, catalog } = host;
45
69
  const requireEcho = (connection.protocolRev ?? 2) >= PROTOCOL_REV_CLIENT_ECHO;
46
70
  switch (msg.type) {
@@ -64,11 +88,13 @@ export async function handleClientMessage(
64
88
  return;
65
89
  }
66
90
  await handleChatMessage(host, ws, {
91
+ authorization: connection.authorization,
67
92
  text: msg.text,
68
93
  sessionId: msg.sessionId,
69
94
  attachments: attachmentResult.attachments,
70
95
  providerId: msg.providerId,
71
96
  client: msg.client,
97
+ draftId: msg.draftId,
72
98
  });
73
99
  break;
74
100
  }
@@ -77,6 +103,9 @@ export async function handleClientMessage(
77
103
  const pending = coordinator.pendingAskUser.get(msg.requestId);
78
104
  if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
79
105
  coordinator.pendingAskUser.delete(msg.requestId);
106
+ pending.turn.recorder?.recordAskUserResponse(
107
+ connection.authorization.principalId
108
+ );
80
109
  pending.resolve({ answers: msg.answers, annotations: msg.annotations });
81
110
  }
82
111
  break;
@@ -86,6 +115,10 @@ export async function handleClientMessage(
86
115
  const pending = coordinator.pendingAskUser.get(msg.requestId);
87
116
  if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
88
117
  coordinator.pendingAskUser.delete(msg.requestId);
118
+ pending.turn.recorder?.recordCancellation(
119
+ connection.authorization.principalId,
120
+ "ask_user"
121
+ );
89
122
  pending.reject(new Error(msg.reason || "User cancelled the question"));
90
123
  }
91
124
  break;
@@ -147,7 +180,11 @@ export async function handleClientMessage(
147
180
  pending.resolve(
148
181
  msg.updatedInput
149
182
  ? { behavior: "allow", updatedInput: msg.updatedInput }
150
- : { behavior: "allow" }
183
+ : { behavior: "allow" },
184
+ {
185
+ principalId: connection.authorization.principalId,
186
+ ...(msg.always && pending.request.kind !== "command" ? { always: true } : {}),
187
+ }
151
188
  );
152
189
  }
153
190
  break;
@@ -157,7 +194,10 @@ export async function handleClientMessage(
157
194
  const pending = coordinator.pendingApprovals.get(msg.toolUseId);
158
195
  if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
159
196
  coordinator.pendingApprovals.delete(msg.toolUseId);
160
- pending.resolve({ behavior: "deny", message: msg.message });
197
+ pending.resolve(
198
+ { behavior: "deny", message: msg.message },
199
+ { principalId: connection.authorization.principalId }
200
+ );
161
201
  }
162
202
  break;
163
203
  }
@@ -165,7 +205,18 @@ export async function handleClientMessage(
165
205
  case "cancel": {
166
206
  if (msg.sessionId) {
167
207
  const turn = coordinator.bySession.get(msg.sessionId);
168
- if (turn) coordinator.cancelTurn(turn, "Cancelled by user");
208
+ if (turn) {
209
+ coordinator.recordCancellation(turn, connection.authorization.principalId);
210
+ coordinator.cancelTurn(turn, "Cancelled by user");
211
+ }
212
+ const starting = coordinator.startingBySession.get(msg.sessionId);
213
+ if (starting) {
214
+ starting.cancelled = true;
215
+ // Releasing here, not in runSession: a start cancelled before it
216
+ // becomes a turn never reaches the loop that would drain its queue.
217
+ for (const entry of starting.queue.splice(0)) entry.releaseAuthorization();
218
+ host.sendToClients({ type: "status", status: "idle", sessionId: msg.sessionId, detail: "Cancelled before starting" });
219
+ }
169
220
  return;
170
221
  }
171
222
  // No sessionId: cancel the sole running session; ambiguous if several run.
@@ -178,7 +229,9 @@ export async function handleClientMessage(
178
229
  });
179
230
  return;
180
231
  }
181
- coordinator.cancelTurn([...coordinator.running][0], "Cancelled by user");
232
+ const turn = [...coordinator.running][0]!;
233
+ coordinator.recordCancellation(turn, connection.authorization.principalId);
234
+ coordinator.cancelTurn(turn, "Cancelled by user");
182
235
  break;
183
236
  }
184
237
 
@@ -186,7 +239,13 @@ export async function handleClientMessage(
186
239
  // View-scoped opt-in: without a subscription this connection never
187
240
  // receives an activity frame. No turn correlation — subscriptions are
188
241
  // connection state, not turn state.
189
- host.activity?.stream.handleSubscribe(ws, msg);
242
+ //
243
+ // Dispatch starts on a microtask, so this can run after onClose already
244
+ // dropped the connection's subscriptions. Registering here would resurrect
245
+ // a dead socket in the registry and keep the activity poller alive for the
246
+ // life of the process.
247
+ if (connection.closed) break;
248
+ host.activity?.stream.handleSubscribe(ws, msg, connection.authorization.principalId);
190
249
  break;
191
250
  }
192
251
 
@@ -207,6 +266,7 @@ export async function handleClientMessage(
207
266
  try {
208
267
  const backend = await host.registry.getBackendForSession(catalog.getStoredBackendId(msg.sessionId));
209
268
  const messages = await backend.getHistory(msg.sessionId);
269
+ if (!connection.authorization.valid) return;
210
270
  sendSessionHistory(ws, msg.sessionId, messages);
211
271
  // A resume of a RUNNING session (reattach) must not report idle: idle
212
272
  // would clear the client's running badge and finish its streaming
package/src/ws/history.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { SessionHistoryMessage } from "@schlessera/brain-ui-sdk/protocol";
2
2
  import { type WSContext, sendTo } from "./clients.js";
3
- import { HISTORY_CHUNK_BYTES, shrinkForReplication } from "./shrink.js";
3
+ import { HISTORY_CHUNK_BYTES, jsonBytes, shrinkForReplication } from "./shrink.js";
4
4
 
5
5
  /**
6
6
  * Send a structured history to one socket as one or more `session_history`
@@ -20,8 +20,7 @@ export function sendSessionHistory(
20
20
  sessionId: string,
21
21
  messages: SessionHistoryMessage[]
22
22
  ): void {
23
- const bounded = messages.map((m) => shrinkForReplication(m));
24
- if (bounded.length === 0) {
23
+ if (messages.length === 0) {
25
24
  sendTo(ws, { type: "session_history", sessionId, messages: [] });
26
25
  return;
27
26
  }
@@ -41,8 +40,11 @@ export function sendSessionHistory(
41
40
  batchBytes = 0;
42
41
  };
43
42
 
44
- for (const message of bounded) {
45
- const size = JSON.stringify(message).length;
43
+ for (const original of messages) {
44
+ // Reserve the frame wrapper separately: bounding a message to the entire
45
+ // frame cap caused sendTo to shrink its parent history array a second time.
46
+ const message = shrinkForReplication(original, HISTORY_CHUNK_BYTES);
47
+ const size = jsonBytes(message) + 1; // comma between messages
46
48
  if (batch.length > 0 && batchBytes + size > HISTORY_CHUNK_BYTES) flush();
47
49
  batch.push(message);
48
50
  batchBytes += size;
package/src/ws/host.ts CHANGED
@@ -7,11 +7,15 @@ import { createSilentObservability, type Observability } from "../observability/
7
7
  import { FrameRateLimiter } from "./rate-limit.js";
8
8
  import type { ActivityStore } from "../activity/store.js";
9
9
  import type { ActivityStream } from "../activity/stream.js";
10
+ import type { PushSender } from "../activity/push-sender.js";
11
+ import type { Principal } from "../db/principals.js";
10
12
 
11
13
  /** The activity record and its live stream, when the host records activity. */
12
14
  export interface ActivityRuntime {
13
15
  store: ActivityStore;
14
16
  stream: ActivityStream;
17
+ /** Push bindings invalidated through the same principal-revocation boundary. */
18
+ pushSender?: Pick<PushSender, "unbindPrincipal">;
15
19
  /** Read seam for the agent-facing query tool (bridge.queryActivity). */
16
20
  query?: (query: import("@schlessera/brain-ui-sdk/server").ActivityQuery) => Record<string, unknown>;
17
21
  }
@@ -21,6 +25,9 @@ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
21
25
 
22
26
  /** Default cap on concurrently RUNNING sessions (MAX_CONCURRENT_SESSIONS). */
23
27
  const DEFAULT_MAX_CONCURRENT_SESSIONS = 3;
28
+ const AUTHORIZATION_EXPIRY_SWEEP_MS = 1_000;
29
+ const SESSION_EXPIRED_CLOSE_CODE = 1008;
30
+ const SESSION_EXPIRED_CLOSE_REASON = "Session expired";
24
31
 
25
32
  /**
26
33
  * Budget for the host-side follow-up queue of ONE session (backends without a
@@ -77,6 +84,13 @@ export interface WsHostOptions {
77
84
  wsRate?: { ratePerSecond: number; burst: number };
78
85
  /** Maximum WebSocket connections accepted by this host (default 32). */
79
86
  wsMaxConnections?: number;
87
+ /**
88
+ * Final synchronous check before a resolved principal becomes discoverable
89
+ * to in-memory revocation. Production re-reads the principal row here; a
90
+ * host without durable authentication (including focused tests) accepts the
91
+ * already-resolved principal.
92
+ */
93
+ isPrincipalValid?: (principal: Principal) => boolean;
80
94
  /**
81
95
  * Activity recording (span store + live stream). Optional: a host without
82
96
  * one records nothing and never sends activity frames — which is also what
@@ -134,6 +148,7 @@ export class WsHost {
134
148
  maxConcurrentSessions: () => number;
135
149
  readonly observability: Observability;
136
150
  readonly wsRate: { ratePerSecond: number; burst: number } | null;
151
+ readonly isPrincipalValid: (principal: Principal) => boolean;
137
152
  readonly activity: ActivityRuntime | null;
138
153
  readonly toolPermissions: ToolPermissions | null;
139
154
  /** Scoped instruments, resolved once — `[ws]` is the existing log prefix. */
@@ -156,9 +171,12 @@ export class WsHost {
156
171
  private readonly connectionsRefused: ReturnType<
157
172
  ReturnType<Observability["meter"]>["createCounter"]
158
173
  >;
174
+ private readonly authorizationExpiryTimer: ReturnType<typeof setInterval>;
159
175
 
160
176
  constructor(options: WsHostOptions) {
161
- this.clients = new ClientSet(options.wsMaxConnections);
177
+ this.clients = new ClientSet(options.wsMaxConnections, (principalIds) => {
178
+ this.coordinator.invalidateAuthorizations(principalIds);
179
+ });
162
180
  this.registry = options.registry;
163
181
  this.catalog = options.catalog;
164
182
  this.appName = options.appName ?? "Brain UI";
@@ -168,6 +186,7 @@ export class WsHost {
168
186
  this.observability = options.observability ?? createSilentObservability();
169
187
  this.wsRate =
170
188
  options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
189
+ this.isPrincipalValid = options.isPrincipalValid ?? (() => true);
171
190
  this.activity = options.activity ?? null;
172
191
  this.toolPermissions = options.toolPermissions ?? null;
173
192
  this.log = this.observability.logger("ws");
@@ -191,6 +210,11 @@ export class WsHost {
191
210
  description: "WebSocket connections refused before admission",
192
211
  });
193
212
  this.coordinator.log = this.log;
213
+ this.authorizationExpiryTimer = setInterval(
214
+ () => this.expireAuthorizationContexts(),
215
+ AUTHORIZATION_EXPIRY_SWEEP_MS
216
+ );
217
+ this.authorizationExpiryTimer.unref?.();
194
218
  }
195
219
 
196
220
  /** A metering bucket for one new connection, or null when metering is off. */
@@ -300,6 +324,40 @@ export class WsHost {
300
324
  });
301
325
  }
302
326
 
327
+ /**
328
+ * Apply principal-store revocation to every in-memory authority boundary.
329
+ * Running turns are marked but deliberately not aborted.
330
+ */
331
+ revokePrincipals(principalIds: readonly string[], code: number, reason: string): void {
332
+ const revoked = new Set(principalIds);
333
+ if (revoked.size === 0) return;
334
+ const affectedRunning = this.coordinator.revokePrincipals(revoked);
335
+ for (const principalId of revoked) {
336
+ this.clients.closeFor(principalId, code, reason);
337
+ this.activity?.stream.dropFor(principalId);
338
+ this.activity?.pushSender?.unbindPrincipal(principalId);
339
+ }
340
+ for (const turn of affectedRunning) {
341
+ turn.recorder?.recordPrincipalRevocation(turn.principalId);
342
+ }
343
+ }
344
+
345
+ /** Route expiry through the same full boundary as explicit revocation. */
346
+ expireAuthorizationContexts(now = Date.now()): void {
347
+ const expired = new Set<string>();
348
+ this.coordinator.collectExpiredPrincipalIds(now, expired);
349
+ this.revokePrincipals(
350
+ [...expired],
351
+ SESSION_EXPIRED_CLOSE_CODE,
352
+ SESSION_EXPIRED_CLOSE_REASON
353
+ );
354
+ }
355
+
356
+ /** Stop host-owned timers during application/test teardown. */
357
+ close(): void {
358
+ clearInterval(this.authorizationExpiryTimer);
359
+ }
360
+
303
361
  /** Send a frame to one specific socket (size-bounded). */
304
362
  sendMessage(ws: WSContext, msg: ServerMessage): void {
305
363
  sendTo(ws, msg);