@animalabs/connectome-host 0.3.1 → 0.3.5

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.
@@ -63,6 +63,16 @@ import {
63
63
  DEFAULT_CONFIG_PATH,
64
64
  } from '../mcpl-config.js';
65
65
  import { loadRecipe } from '../recipe.js';
66
+ import {
67
+ ObserverRegistry,
68
+ ObserverSessions,
69
+ sessionTokenFromRequest,
70
+ traceRequiredScope,
71
+ filterEntryForScopes,
72
+ scopeWelcome,
73
+ type ObserverScope,
74
+ type ObserverHelloIdentity,
75
+ } from './web-ui-observers.js';
66
76
 
67
77
  /**
68
78
  * Minimal slice of AppContext the module needs. Defined locally to avoid
@@ -108,15 +118,44 @@ export interface WebUiModuleConfig {
108
118
  * the entire host is firewalled off from browsers.
109
119
  */
110
120
  allowedOrigins?: string[];
121
+ /**
122
+ * Path to the observer grant file (data/observers.json — see connectome
123
+ * docs/observability.md). When set AND the file holds at least one grant,
124
+ * key-authenticated observers may connect: static assets are served
125
+ * without basic auth (the app shell carries no data), /ws upgrades are
126
+ * accepted unauthenticated and must present a signed observer-hello
127
+ * before anything is sent, and every frame is filtered by the grant's
128
+ * scope mask. With no grants the webui behaves exactly as before.
129
+ */
130
+ observersPath?: string;
131
+ }
132
+
133
+ /** Data stashed on the Bun WS upgrade. */
134
+ interface WsData {
135
+ id: number;
136
+ /** True when the upgrade carried valid basic auth (full access). */
137
+ authed: boolean;
138
+ /** Host header of the upgrade request — the observer statement binds it. */
139
+ host: string;
111
140
  }
112
141
 
113
142
  /** Per-connection state. */
114
143
  interface ClientState {
115
144
  /** Stable id matching ws.data.id; used for routing fleet IPC responses. */
116
145
  id: number;
117
- ws: ServerWebSocket<{ id: number }>;
146
+ ws: ServerWebSocket<WsData>;
118
147
  /** True after we've sent the welcome message. */
119
148
  welcomed: boolean;
149
+ /**
150
+ * Authorization state. 'full' = basic-auth (or open loopback) — behavior
151
+ * identical to pre-observer builds, scopes null. 'pending' = upgraded
152
+ * without auth, must observer-hello before any data flows. 'observer' =
153
+ * key-authenticated; `scopes` is the grant's mask.
154
+ */
155
+ auth: 'full' | 'pending' | 'observer';
156
+ scopes: Set<ObserverScope> | null;
157
+ /** Kills pending connections that never complete the hello. */
158
+ authTimer?: ReturnType<typeof setTimeout>;
120
159
  /** Open peek subscriptions for this client, keyed by scope. Each entry
121
160
  * carries its detacher so unsubscribe and disconnect both clean up
122
161
  * without the framework leaking listeners. */
@@ -132,6 +171,161 @@ const WELCOME_HISTORY_LIMIT = 200;
132
171
  const HISTORY_PAGE_DEFAULT = 200;
133
172
  const HISTORY_PAGE_MAX = 500;
134
173
 
174
+ interface CoverageSummary {
175
+ id: string;
176
+ level: number;
177
+ tokens?: number;
178
+ sourceIds?: string[];
179
+ mergedInto?: string;
180
+ }
181
+
182
+ interface CoverageChunk {
183
+ index: number;
184
+ tokens?: number;
185
+ compressed?: boolean;
186
+ summaryId?: string;
187
+ messages?: Array<{ id?: string }>;
188
+ }
189
+
190
+ interface CoverageStrategy {
191
+ summaries?: CoverageSummary[];
192
+ chunks?: CoverageChunk[];
193
+ compressionQueue?: number[];
194
+ mergeQueue?: Array<{ level: number; sourceIds: string[] }>;
195
+ resolutions?: Map<string, number>;
196
+ pendingCompression?: Promise<void> | null;
197
+ }
198
+
199
+ export interface ContextCoverageSnapshot {
200
+ agent: string;
201
+ branch: string;
202
+ generatedAt: string;
203
+ supported: boolean;
204
+ totals: {
205
+ chunks: number;
206
+ compressedChunks: number;
207
+ coveredMessages: number;
208
+ coveredTokens: number;
209
+ summaries: number;
210
+ };
211
+ levels: Array<{
212
+ level: number;
213
+ summaries: number;
214
+ frontier: number;
215
+ tokens: number;
216
+ coveredChunks: number;
217
+ coveredMessages: number;
218
+ coveredTokens: number;
219
+ }>;
220
+ chunks: Array<{
221
+ index: number;
222
+ messages: number;
223
+ tokens: number;
224
+ compressed: boolean;
225
+ summaryId: string | null;
226
+ maxLevel: number;
227
+ selectedMin: number;
228
+ selectedMax: number;
229
+ queued: boolean;
230
+ }>;
231
+ queue: {
232
+ inFlight: boolean;
233
+ pending: string | null;
234
+ l1: number[];
235
+ merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
236
+ };
237
+ }
238
+
239
+ /** Build a text-free projection of the autobiographical summary pyramid. */
240
+ export function buildContextCoverageSnapshot(
241
+ agentName: string,
242
+ cm: {
243
+ currentBranch: () => { name: string };
244
+ getStrategy: () => unknown;
245
+ getPendingWork?: () => { description?: string } | null;
246
+ },
247
+ ): ContextCoverageSnapshot {
248
+ const strategy = cm.getStrategy() as CoverageStrategy;
249
+ const summaries = Array.isArray(strategy.summaries) ? strategy.summaries : [];
250
+ const chunks = Array.isArray(strategy.chunks) ? strategy.chunks : [];
251
+ const compressionQueue = Array.isArray(strategy.compressionQueue) ? strategy.compressionQueue : [];
252
+ const mergeQueue = Array.isArray(strategy.mergeQueue) ? strategy.mergeQueue : [];
253
+ const resolutions = strategy.resolutions instanceof Map ? strategy.resolutions : new Map<string, number>();
254
+ const summaryById = new Map(summaries.map(summary => [summary.id, summary]));
255
+ const queuedChunks = new Set(compressionQueue);
256
+
257
+ const projectedChunks = chunks.map((chunk) => {
258
+ let maxLevel = 0;
259
+ let current = chunk.summaryId ? summaryById.get(chunk.summaryId) : undefined;
260
+ const seen = new Set<string>();
261
+ while (current && !seen.has(current.id)) {
262
+ seen.add(current.id);
263
+ maxLevel = Math.max(maxLevel, current.level);
264
+ current = current.mergedInto ? summaryById.get(current.mergedInto) : undefined;
265
+ }
266
+
267
+ const selected = (chunk.messages ?? [])
268
+ .map(message => typeof message.id === 'string' ? (resolutions.get(message.id) ?? 0) : 0);
269
+ return {
270
+ index: chunk.index,
271
+ messages: chunk.messages?.length ?? 0,
272
+ tokens: Math.max(0, chunk.tokens ?? 0),
273
+ compressed: chunk.compressed === true,
274
+ summaryId: chunk.summaryId ?? null,
275
+ maxLevel,
276
+ selectedMin: selected.length > 0 ? Math.min(...selected) : 0,
277
+ selectedMax: selected.length > 0 ? Math.max(...selected) : 0,
278
+ queued: queuedChunks.has(chunk.index),
279
+ };
280
+ });
281
+
282
+ const levelNumbers = [...new Set(summaries.map(summary => summary.level))]
283
+ .filter(level => Number.isFinite(level) && level > 0)
284
+ .sort((a, b) => a - b);
285
+ const levels = levelNumbers.map((level) => {
286
+ const atLevel = summaries.filter(summary => summary.level === level);
287
+ const covered = projectedChunks.filter(chunk => chunk.maxLevel >= level);
288
+ return {
289
+ level,
290
+ summaries: atLevel.length,
291
+ frontier: atLevel.filter(summary => !summary.mergedInto).length,
292
+ tokens: atLevel.reduce((total, summary) => total + Math.max(0, summary.tokens ?? 0), 0),
293
+ coveredChunks: covered.length,
294
+ coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
295
+ coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
296
+ };
297
+ });
298
+ const covered = projectedChunks.filter(chunk => chunk.maxLevel > 0);
299
+ const pending = cm.getPendingWork?.()?.description ?? null;
300
+
301
+ return {
302
+ agent: agentName,
303
+ branch: cm.currentBranch().name,
304
+ generatedAt: new Date().toISOString(),
305
+ supported: Array.isArray(strategy.summaries) && Array.isArray(strategy.chunks),
306
+ totals: {
307
+ chunks: projectedChunks.length,
308
+ compressedChunks: projectedChunks.filter(chunk => chunk.compressed).length,
309
+ coveredMessages: covered.reduce((total, chunk) => total + chunk.messages, 0),
310
+ coveredTokens: covered.reduce((total, chunk) => total + chunk.tokens, 0),
311
+ summaries: summaries.length,
312
+ },
313
+ levels,
314
+ chunks: projectedChunks,
315
+ queue: {
316
+ inFlight: strategy.pendingCompression != null,
317
+ pending,
318
+ l1: [...compressionQueue],
319
+ merges: mergeQueue.map(merge => ({
320
+ targetLevel: merge.level,
321
+ sourceCount: merge.sourceIds.length,
322
+ firstSource: merge.sourceIds[0] ?? null,
323
+ lastSource: merge.sourceIds[merge.sourceIds.length - 1] ?? null,
324
+ })),
325
+ },
326
+ };
327
+ }
328
+
135
329
  /**
136
330
  * Structural view of the windowed-read facade added to
137
331
  * @animalabs/context-manager alongside this protocol version. Typed
@@ -174,6 +368,10 @@ interface SharedServerState {
174
368
  allowedOrigins: string[];
175
369
  clients: Map<number, ClientState>;
176
370
  nextClientId: number;
371
+ /** Observer grant registry (hot-reloaded) — null when not configured. */
372
+ observers: ObserverRegistry | null;
373
+ /** Short-lived HTTP session tokens minted after observer WS auth. */
374
+ observerSessions: ObserverSessions;
177
375
  /** Aggregate session usage published to clients: parent's own totals plus
178
376
  * the most-recent `usage:updated` totals reported by each fleet child.
179
377
  * Recomputed from `parentUsage` + `childUsage` on every relevant event. */
@@ -260,6 +458,8 @@ export class WebUiModule implements Module {
260
458
  allowedOrigins: this.config.allowedOrigins ?? defaultAllowedOrigins(port),
261
459
  clients: new Map(),
262
460
  nextClientId: 1,
461
+ observers: this.config.observersPath ? new ObserverRegistry(this.config.observersPath) : null,
462
+ observerSessions: new ObserverSessions(),
263
463
  latestUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
264
464
  parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
265
465
  childUsage: new Map(),
@@ -271,14 +471,15 @@ export class WebUiModule implements Module {
271
471
  fleetEventDetacher: null,
272
472
  messageListenerDetacher: null,
273
473
  };
474
+ state.observers?.start();
274
475
  state.server = Bun.serve({
275
476
  port,
276
477
  hostname: host,
277
478
  fetch: (req, server) => this.handleHttp(req, server),
278
479
  websocket: {
279
- open: (ws) => this.onWsOpen(ws as ServerWebSocket<{ id: number }>),
280
- message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<{ id: number }>, msg),
281
- close: (ws) => this.onWsClose(ws as ServerWebSocket<{ id: number }>),
480
+ open: (ws) => this.onWsOpen(ws as ServerWebSocket<WsData>),
481
+ message: (ws, msg) => this.onWsMessage(ws as ServerWebSocket<WsData>, msg),
482
+ close: (ws) => this.onWsClose(ws as ServerWebSocket<WsData>),
282
483
  },
283
484
  });
284
485
  // When port=0 is passed (test setups, ephemeral binds), the OS picks a
@@ -352,7 +553,10 @@ export class WebUiModule implements Module {
352
553
  const entry = toWireEntry(ev.message as MessageLike, cm0.getMessageCount() - 1);
353
554
  const push: WebUiServerMessage = { type: 'message-appended', entry };
354
555
  for (const c of ss.clients.values()) {
355
- if (c.welcomed) this.send(c, push);
556
+ if (!c.welcomed) continue;
557
+ if (c.scopes === null) { this.send(c, push); continue; }
558
+ const filtered = filterEntryForScopes(entry, c.scopes);
559
+ if (filtered) this.send(c, { type: 'message-appended', entry: filtered });
356
560
  }
357
561
  } catch (err) {
358
562
  // Never let a viewer-side projection error break the store's
@@ -577,6 +781,17 @@ export class WebUiModule implements Module {
577
781
  }
578
782
  }
579
783
 
784
+ // -------------------------------------------------------------------------
785
+ // Observer scope filtering — event-family masks (docs/observability.md §4).
786
+ // Full clients (scopes === null) bypass everything: historical behavior.
787
+ // Pure projections live in web-ui-observers.ts (unit-tested there).
788
+ // -------------------------------------------------------------------------
789
+
790
+ private clientAllowsTrace(client: ClientState, event: { type: string }): boolean {
791
+ if (client.scopes === null) return true;
792
+ return client.scopes.has(traceRequiredScope(event));
793
+ }
794
+
580
795
  private fanOutTrace(event: TraceEvent): void {
581
796
  // Update cached usage snapshot first so welcomes for late-connecting
582
797
  // clients get a current value.
@@ -615,8 +830,10 @@ export class WebUiModule implements Module {
615
830
  : null;
616
831
  for (const client of sharedServer!.clients.values()) {
617
832
  if (!client.welcomed) continue;
618
- this.send(client, traceMsg);
619
- if (usageMsg) this.send(client, usageMsg);
833
+ if (this.clientAllowsTrace(client, event)) this.send(client, traceMsg);
834
+ if (usageMsg && (client.scopes === null || client.scopes.has('health'))) {
835
+ this.send(client, usageMsg);
836
+ }
620
837
  }
621
838
  }
622
839
 
@@ -733,6 +950,10 @@ export class WebUiModule implements Module {
733
950
  private async handleHttp(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
734
951
  const url = new URL(req.url);
735
952
 
953
+ // Observer feature gate: live only when grants exist. With no grants
954
+ // every path below reduces to the historical basic-auth-only behavior.
955
+ const observersActive = sharedServer!.observers?.active() ?? false;
956
+
736
957
  // WebSocket upgrade
737
958
  if (url.pathname === '/ws') {
738
959
  // Origin check FIRST — drive-by CSRF on a localhost-bound WS is the
@@ -740,15 +961,61 @@ export class WebUiModule implements Module {
740
961
  // `new WebSocket(...)` the way they do on fetch, so without an
741
962
  // explicit check, any tab the operator opens could connect here.
742
963
  if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
743
- if (!this.checkAuth(req)) return this.unauthorized();
964
+ const basicOk = this.checkAuth(req);
965
+ // Without basic auth the upgrade is allowed only when observer grants
966
+ // exist — and then NOTHING is sent until a signed observer-hello
967
+ // verifies (in-band auth; see onWsOpen/onWsMessage).
968
+ if (!basicOk && !observersActive) return this.unauthorized();
744
969
  const id = sharedServer!.nextClientId++;
745
- const ok = server.upgrade(req, { data: { id } });
970
+ const ok = server.upgrade(req, {
971
+ data: { id, authed: basicOk, host: req.headers.get('host') ?? '' } satisfies WsData,
972
+ });
746
973
  if (!ok) return new Response('Upgrade failed', { status: 400 });
747
974
  // Bun returns undefined on success; the response is taken over by the upgrade.
748
975
  return new Response(null, { status: 101 });
749
976
  }
750
977
 
751
- if (!this.checkAuth(req)) return this.unauthorized();
978
+ // HTTP auth: basic auth grants everything (historical behavior); an
979
+ // observer session cookie (minted after WS key auth) grants by scope;
980
+ // when observers are active the static app shell is public — it carries
981
+ // no data, and key-only devices must be able to load the SPA to
982
+ // authenticate at all.
983
+ const basicOk = this.checkAuth(req);
984
+ const sessionScopes = basicOk
985
+ ? null
986
+ : sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
987
+ const httpAllowed = (scope: ObserverScope): boolean =>
988
+ basicOk || (sessionScopes?.has(scope) ?? false);
989
+ // /auth/basic: deliberate basic-auth challenge point. fetch() never
990
+ // triggers the browser's native credential prompt, but a top-level
991
+ // navigation here does — the SPA's "sign in with password" fallback for
992
+ // devices without an observer grant. Once credentials are cached for
993
+ // the realm, the WS upgrade carries them and the client is 'full'.
994
+ if (url.pathname === '/auth/basic') {
995
+ if (basicOk) return new Response(null, { status: 302, headers: { location: '/' } });
996
+ return this.unauthorized();
997
+ }
998
+ const isStatic = !url.pathname.startsWith('/debug/')
999
+ && url.pathname !== '/curve'
1000
+ && url.pathname !== '/healthz'
1001
+ && !url.pathname.startsWith('/files/');
1002
+ if (!basicOk && !(observersActive && isStatic) && !sessionScopes) {
1003
+ return this.unauthorized();
1004
+ }
1005
+
1006
+ // Per-route scope gates for observer sessions (basic auth passes all).
1007
+ if ((url.pathname.startsWith('/debug/') || url.pathname === '/curve') && !httpAllowed('debug')) {
1008
+ return this.unauthorized();
1009
+ }
1010
+ if (url.pathname === '/healthz' && !httpAllowed('health')) {
1011
+ return this.unauthorized();
1012
+ }
1013
+ if (url.pathname.startsWith('/files/') && !basicOk) {
1014
+ // Workspace files stay basic-auth-only: mount contents are outside the
1015
+ // observer scope model (they are the agent's working files, not wire
1016
+ // events). Revisit if a 'files' scope is ever warranted.
1017
+ return this.unauthorized();
1018
+ }
752
1019
 
753
1020
  // Debug: the membrane-normalized request that WOULD be emitted if the
754
1021
  // agent were activated right now — no inference, no state mutation.
@@ -763,6 +1030,12 @@ export class WebUiModule implements Module {
763
1030
  if (url.pathname === '/debug/context/curve') {
764
1031
  return this.handleContextCurve(url);
765
1032
  }
1033
+ if (url.pathname === '/debug/context/coverage') {
1034
+ return this.handleContextCoverage(url);
1035
+ }
1036
+ if (url.pathname === '/debug/context/maintenance') {
1037
+ return this.handleContextMaintenance();
1038
+ }
766
1039
  if (url.pathname === '/curve') {
767
1040
  return new Response(CURVE_PAGE_HTML, {
768
1041
  headers: { 'content-type': 'text/html; charset=utf-8' },
@@ -798,6 +1071,39 @@ export class WebUiModule implements Module {
798
1071
  return this.serveStatic(requested);
799
1072
  }
800
1073
 
1074
+ /**
1075
+ * Counts-only state and bounded history for periodic context maintenance.
1076
+ * Authentication is enforced by handleHttp before this method is reached.
1077
+ * The framework snapshot deliberately contains no message or summary text.
1078
+ */
1079
+ private handleContextMaintenance(): Response {
1080
+ const app = sharedServer?.app;
1081
+ if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1082
+ const framework = app.framework as unknown as {
1083
+ getContextMaintenanceSnapshot?: () => Record<string, unknown>;
1084
+ };
1085
+ if (typeof framework.getContextMaintenanceSnapshot !== 'function') {
1086
+ return Response.json(
1087
+ { error: 'framework lacks context-maintenance diagnostics' },
1088
+ { status: 501 },
1089
+ );
1090
+ }
1091
+ return Response.json(framework.getContextMaintenanceSnapshot());
1092
+ }
1093
+
1094
+ /** Summary-tree coverage and queued work, with no message or summary text. */
1095
+ private handleContextCoverage(url: URL): Response {
1096
+ const app = sharedServer?.app;
1097
+ if (!app) return Response.json({ error: 'app not bound yet' }, { status: 503 });
1098
+ const agentName = url.searchParams.get('agent') || app.recipe.agent.name || 'agent';
1099
+ const agent = app.framework.getAgent(agentName);
1100
+ if (!agent) {
1101
+ return Response.json({ error: `Agent not found: ${agentName}` }, { status: 404 });
1102
+ }
1103
+ const cm = agent.getContextManager();
1104
+ return Response.json(buildContextCoverageSnapshot(agentName, cm));
1105
+ }
1106
+
801
1107
  /**
802
1108
  * Debug endpoint: return the membrane-normalized request the framework would
803
1109
  * hand to the model if the agent were activated right now. Delegates to
@@ -1116,16 +1422,60 @@ export class WebUiModule implements Module {
1116
1422
  // WebSocket lifecycle
1117
1423
  // -------------------------------------------------------------------------
1118
1424
 
1119
- private onWsOpen(ws: ServerWebSocket<{ id: number }>): void {
1425
+ private onWsOpen(ws: ServerWebSocket<WsData>): void {
1120
1426
  const id = ws.data.id;
1121
- const client: ClientState = { id, ws, welcomed: false, peeks: new Map() };
1427
+ const client: ClientState = {
1428
+ id, ws, welcomed: false, peeks: new Map(),
1429
+ auth: ws.data.authed ? 'full' : 'pending',
1430
+ scopes: null,
1431
+ };
1122
1432
  sharedServer!.clients.set(id, client);
1123
1433
 
1434
+ if (client.auth === 'pending') {
1435
+ // In-band observer auth: tell the client what host its statement must
1436
+ // bind, and give it a bounded window to present a verifiable hello.
1437
+ // No data flows on this connection until then.
1438
+ this.send(client, { type: 'observer-auth-required', host: ws.data.host });
1439
+ client.authTimer = setTimeout(() => {
1440
+ if (client.auth === 'pending') ws.close(4401, 'observer auth timeout');
1441
+ }, 15_000);
1442
+ return;
1443
+ }
1444
+
1124
1445
  if (sharedServer?.app) void this.sendWelcome(client);
1125
1446
  // Else: park until setApp() flushes welcomes.
1126
1447
  }
1127
1448
 
1128
- private onWsMessage(ws: ServerWebSocket<{ id: number }>, raw: string | Buffer): void {
1449
+ private handleObserverHello(client: ClientState, identity: ObserverHelloIdentity): void {
1450
+ const registry = sharedServer!.observers;
1451
+ const result = registry?.verifyHello(identity, client.ws.data.host) ?? null;
1452
+ if (!result) {
1453
+ this.send(client, { type: 'error', message: 'observer auth failed' });
1454
+ client.ws.close(4401, 'observer auth failed');
1455
+ return;
1456
+ }
1457
+ if (client.authTimer) clearTimeout(client.authTimer);
1458
+ client.auth = 'observer';
1459
+ client.scopes = result.scopes;
1460
+ this.send(client, {
1461
+ type: 'observer-ack',
1462
+ scopes: [...result.scopes],
1463
+ sessionToken: sharedServer!.observerSessions.mint(result.scopes),
1464
+ label: result.grant.label,
1465
+ });
1466
+ console.error(`[webui-observers] observer connected: ${result.grant.label} scopes=[${[...result.scopes].join(',')}]`);
1467
+ if (sharedServer?.app) void this.sendWelcome(client);
1468
+ }
1469
+
1470
+ /** Client message types a scoped (non-full) observer may send. */
1471
+ private observerMaySend(client: ClientState, type: string): boolean {
1472
+ if (client.auth !== 'observer') return false;
1473
+ if (type === 'ping') return true;
1474
+ if (type === 'request-history') return client.scopes?.has('messages') ?? false;
1475
+ return false; // observers are read-only: no user-message/command/mcpl/fleet
1476
+ }
1477
+
1478
+ private onWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
1129
1479
  const id = ws.data.id;
1130
1480
  const client = sharedServer!.clients.get(id);
1131
1481
  if (!client) return;
@@ -1142,6 +1492,20 @@ export class WebUiModule implements Module {
1142
1492
  return;
1143
1493
  }
1144
1494
 
1495
+ // Observer auth interlock: a pending connection may ONLY hello; a
1496
+ // key-authenticated observer is read-only within its scopes. Full
1497
+ // (basic-auth) clients skip both gates — historical behavior.
1498
+ if (parsed.type === 'observer-hello') {
1499
+ if (client.auth === 'pending') this.handleObserverHello(client, parsed.identity);
1500
+ // hello on an already-authenticated connection is a no-op
1501
+ return;
1502
+ }
1503
+ if (client.auth === 'pending') return; // nothing else before auth
1504
+ if (client.auth === 'observer' && !this.observerMaySend(client, parsed.type)) {
1505
+ this.send(client, { type: 'error', message: `forbidden for observer scope (${parsed.type})` });
1506
+ return;
1507
+ }
1508
+
1145
1509
  if (!sharedServer?.app) {
1146
1510
  this.send(client, { type: 'error', message: 'host not ready' });
1147
1511
  return;
@@ -1825,10 +2189,11 @@ export class WebUiModule implements Module {
1825
2189
  }
1826
2190
  }
1827
2191
 
1828
- private onWsClose(ws: ServerWebSocket<{ id: number }>): void {
2192
+ private onWsClose(ws: ServerWebSocket<WsData>): void {
1829
2193
  const id = ws.data.id;
1830
2194
  const client = sharedServer?.clients.get(id);
1831
2195
  if (client) {
2196
+ if (client.authTimer) clearTimeout(client.authTimer);
1832
2197
  for (const detach of client.peeks.values()) {
1833
2198
  try { detach(); } catch { /* ignore */ }
1834
2199
  }
@@ -1969,9 +2334,12 @@ export class WebUiModule implements Module {
1969
2334
 
1970
2335
  private async sendWelcome(client: ClientState): Promise<void> {
1971
2336
  if (!sharedServer?.app) return;
2337
+ // Never send data to a connection that hasn't authenticated. Parked
2338
+ // pending connections get their welcome from handleObserverHello.
2339
+ if (client.auth === 'pending') return;
1972
2340
 
1973
2341
  const welcome = await this.buildWelcome();
1974
- this.send(client, welcome);
2342
+ this.send(client, client.scopes === null ? welcome : scopeWelcome(welcome, client.scopes));
1975
2343
  client.welcomed = true;
1976
2344
  // Live trace forwarding is driven by the single fan-out listener
1977
2345
  // installed in setApp(); membership is implicit in `sharedServer!.clients`.
@@ -2003,10 +2371,16 @@ export class WebUiModule implements Module {
2003
2371
  resolveBlobs: false,
2004
2372
  alignToBodyGroups: true,
2005
2373
  });
2374
+ let entries = coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex);
2375
+ if (client.scopes !== null) {
2376
+ entries = entries
2377
+ .map((e) => filterEntryForScopes(e, client.scopes!))
2378
+ .filter((e): e is WelcomeMessageEntry => e !== null);
2379
+ }
2006
2380
  this.send(client, {
2007
2381
  type: 'history-page',
2008
2382
  corrId: req.corrId,
2009
- entries: coalesceAndFlatten(win.messages as unknown as MessageLike[], win.startIndex),
2383
+ entries,
2010
2384
  startIndex: win.startIndex,
2011
2385
  totalCount: win.totalCount,
2012
2386
  });