@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.
- package/README.md +28 -0
- package/dist/activity/notify.d.ts.map +1 -1
- package/dist/activity/notify.js +18 -8
- package/dist/activity/notify.js.map +1 -1
- package/dist/activity/push-sender.d.ts +7 -3
- package/dist/activity/push-sender.d.ts.map +1 -1
- package/dist/activity/push-sender.js +45 -11
- package/dist/activity/push-sender.js.map +1 -1
- package/dist/activity/query.d.ts.map +1 -1
- package/dist/activity/query.js +8 -0
- package/dist/activity/query.js.map +1 -1
- package/dist/activity/recorder.d.ts +11 -1
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +52 -2
- package/dist/activity/recorder.js.map +1 -1
- package/dist/activity/runtime.d.ts.map +1 -1
- package/dist/activity/runtime.js +2 -5
- package/dist/activity/runtime.js.map +1 -1
- package/dist/activity/sql.d.ts +34 -0
- package/dist/activity/sql.d.ts.map +1 -0
- package/dist/activity/sql.js +114 -0
- package/dist/activity/sql.js.map +1 -0
- package/dist/activity/store.d.ts +7 -0
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +48 -113
- package/dist/activity/store.js.map +1 -1
- package/dist/activity/stream.d.ts +3 -1
- package/dist/activity/stream.d.ts.map +1 -1
- package/dist/activity/stream.js +32 -17
- package/dist/activity/stream.js.map +1 -1
- package/dist/app-env.d.ts +8 -0
- package/dist/app-env.d.ts.map +1 -0
- package/dist/app-env.js +2 -0
- package/dist/app-env.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +28 -3
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +4 -2
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +60 -20
- package/dist/brain/client.js.map +1 -1
- package/dist/brain/types.d.ts +9 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/cron/run-job.d.ts.map +1 -1
- package/dist/cron/run-job.js +60 -10
- package/dist/cron/run-job.js.map +1 -1
- package/dist/db/principals.d.ts +72 -0
- package/dist/db/principals.d.ts.map +1 -0
- package/dist/db/principals.js +294 -0
- package/dist/db/principals.js.map +1 -0
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -1
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +37 -9
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +148 -74
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +9 -7
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +25 -7
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/principals.d.ts +18 -0
- package/dist/middleware/principals.d.ts.map +1 -0
- package/dist/middleware/principals.js +138 -0
- package/dist/middleware/principals.js.map +1 -0
- package/dist/middleware/require-owner.d.ts +5 -0
- package/dist/middleware/require-owner.d.ts.map +1 -0
- package/dist/middleware/require-owner.js +10 -0
- package/dist/middleware/require-owner.js.map +1 -0
- package/dist/observability/loggers.js +1 -1
- package/dist/observability/loggers.js.map +1 -1
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +3 -0
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +102 -63
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/push.d.ts +61 -2
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +8 -3
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +30 -4
- package/dist/routes/sessions.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +11 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +6 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +9 -2
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +44 -6
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +7 -4
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +83 -12
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +12 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +46 -6
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/history.d.ts.map +1 -1
- package/dist/ws/history.js +7 -5
- package/dist/ws/history.js.map +1 -1
- package/dist/ws/host.d.ts +22 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +39 -1
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts +7 -7
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +208 -52
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/shrink.d.ts +3 -1
- package/dist/ws/shrink.d.ts.map +1 -1
- package/dist/ws/shrink.js +46 -16
- package/dist/ws/shrink.js.map +1 -1
- package/dist/ws/turns.d.ts +58 -3
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +101 -2
- package/dist/ws/turns.js.map +1 -1
- package/migrations/011_principals.sql +13 -0
- package/migrations/012_activity_principals.sql +12 -0
- package/migrations/013_push_subscription_principals.sql +7 -0
- package/migrations/014_invalidate_legacy_sessions.sql +14 -0
- package/package.json +3 -3
- package/src/activity/notify.ts +16 -7
- package/src/activity/push-sender.ts +79 -16
- package/src/activity/query.ts +8 -0
- package/src/activity/recorder.ts +68 -3
- package/src/activity/runtime.ts +2 -5
- package/src/activity/sql.ts +122 -0
- package/src/activity/store.ts +73 -155
- package/src/activity/stream.ts +37 -17
- package/src/app-env.ts +8 -0
- package/src/app.ts +33 -4
- package/src/brain/client.ts +61 -20
- package/src/brain/types.ts +10 -0
- package/src/cron/run-job.ts +41 -17
- package/src/db/principals.ts +452 -0
- package/src/files/walker.ts +5 -1
- package/src/index.ts +6 -1
- package/src/middleware/auth.ts +241 -81
- package/src/middleware/passkeys.ts +35 -14
- package/src/middleware/principals.ts +189 -0
- package/src/middleware/require-owner.ts +13 -0
- package/src/observability/loggers.ts +1 -1
- package/src/routes/activity.ts +3 -0
- package/src/routes/brain.ts +107 -70
- package/src/routes/files.ts +10 -5
- package/src/routes/push.ts +11 -5
- package/src/routes/sessions.ts +45 -20
- package/src/share/staging.ts +11 -4
- package/src/ws/bridge.ts +14 -2
- package/src/ws/clients.ts +52 -7
- package/src/ws/connection.ts +98 -20
- package/src/ws/dispatch.ts +66 -6
- package/src/ws/history.ts +7 -5
- package/src/ws/host.ts +59 -1
- package/src/ws/run-session.ts +247 -74
- package/src/ws/shrink.ts +48 -14
- 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 = (
|
|
118
|
-
|
|
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,
|
|
43
|
+
private readonly clients = new Map<unknown, AttachedClient>();
|
|
38
44
|
|
|
39
|
-
constructor(
|
|
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
|
-
|
|
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
|
|
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
|
package/src/ws/connection.ts
CHANGED
|
@@ -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 (!
|
|
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)
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
}
|
package/src/ws/dispatch.ts
CHANGED
|
@@ -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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
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);
|