feat(multiuser): phase 4, event fan-out + stream scoping

Scopes real-time streams and the init snapshot so a multi-user client only
receives what it owns. No-op in single-user mode (identity-less clients).

- WS terminal (ws-routes): owner gate after the session lookup. A non-admin may
  only attach to their own session (close 4003); the global auth hook already
  ran on the upgrade and decorated req.authUser, so an unauthenticated upgrade
  never reaches the handler.
- SSE (sse-stream-manager): per-client identity stored at addClient; broadcast()
  and the terminal-batch flush both enforce a routing hint via canDeliver().
  WebServer.broadcast auto-derives the hint (deriveSseHint): session-scoped event
  families resolve the owner from the payload's session id (fail closed when the
  owner can't be resolved), machine-level families (docker/tunnel/update/system/
  cron) + host-plan telemetry are admin-only, everything else stays global. Raw
  terminal bytes resolve the owner once and are withheld from non-owners.
- getLightState is filtered per connection AFTER the shared cache (sessions,
  respawnStatus, subagents, workflowRuns by owner; scheduledRuns + planUsage
  admin-only); applied to both the SSE init snapshot and GET /api/status.
- file-routes: getKnownSessionWorkingDir + getSessionAttachmentHistory (the
  preview/thumbnail/history helpers that bypass findSessionOrFail) now owner-check
  the session, closing a cross-user file-read path.
- GET /api/search: harvestSources is owner-scoped.

Deferred to a follow-up (documented in docs/multi-user-plan.md): away-digest +
subagent/workflow REST list scoping, push-subscription identity + routing,
per-user screenshot subdirs. The live-event versions of these are already routed
by the SSE hint; only the on-demand REST aggregates remain global for admins-only
follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 04:17:17 +02:00
parent 453605a58f
commit 2a06f7a5a8
7 changed files with 200 additions and 25 deletions
+9 -5
View File
@@ -23,7 +23,7 @@
*/
import { FastifyInstance } from 'fastify';
import { parseBody } from '../route-helpers.js';
import { canAccessOwned, getAuthUser, parseBody } from '../route-helpers.js';
import { SearchQuerySchema } from '../schemas.js';
import {
searchSources,
@@ -62,13 +62,14 @@ interface SessionLike {
* Harvest the three source arrays from the live in-memory stores. Reads only
* bounded, already-loaded data — no disk I/O, no terminal buffers.
*/
function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
const sessions: SessionSearchInput[] = [];
const events: EventSearchInput[] = [];
const files: FileSearchInput[] = [];
for (const raw of ctx.sessions.values()) {
const s = raw as unknown as SessionLike;
const s = raw as unknown as SessionLike & { owner?: string };
if (canSee && !canSee(s.owner)) continue; // multi-user ownership scope
const sessionName = s.name ?? '';
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
@@ -96,7 +97,8 @@ function harvestSources(ctx: SessionPort & InfraPort): SearchSources {
// Events: from the live run-summary trackers, keyed by session id.
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
const session = ctx.sessions.get(sessionId) as unknown as SessionLike | undefined;
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
if (canSee && !canSee(session?.owner)) continue; // multi-user ownership scope
const sessionName = session?.name ?? '';
const summary = tracker.getSummary();
// Newest events are most relevant; cap the per-session harvest.
@@ -120,6 +122,8 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
app.get('/api/search', async (req) => {
// Zod-validate the query. parseBody throws a structured 400 on failure.
const { q, types, limit } = parseBody(SearchQuerySchema, req.query);
const user = getAuthUser(req);
const canSee = (owner?: string) => canAccessOwned(user, owner);
const allowed: Set<SearchSourceType> | null = types
? new Set(
@@ -130,7 +134,7 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
)
: null;
const sources = harvestSources(ctx);
const sources = harvestSources(ctx, canSee);
// Apply the optional source-type filter before searching so excluded
// sources never contribute to (or consume budget in) the result set.