mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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>
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
/**
|
||
* @fileoverview Cross-session federated search route (COD-9).
|
||
*
|
||
* Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search
|
||
* across three v1 sources, returned in the standard ApiResponse envelope:
|
||
* 1. sessions/cases — name, working directory, session id
|
||
* 2. run-summary events — event title/details (from the live run-summary trackers)
|
||
* 3. file paths — per-session attachment history (workspace-relative paths only)
|
||
*
|
||
* This route is a THIN wrapper: it harvests the source arrays from the live
|
||
* server stores (held on the route context) in a bounded way, then delegates
|
||
* grouping/ranking/capping to the pure `searchSources()` core in
|
||
* `src/search-service.ts`. Terminal-buffer scanning and any persisted index are
|
||
* out of scope for v1.
|
||
*
|
||
* Safety: query input is Zod-validated (length-bounded `q`, allowlisted `types`,
|
||
* numeric `limit`); only workspace-relative file paths are ever exposed (the
|
||
* server-private `externalPath` on attachment history is never read here); and
|
||
* the pure core enforces a per-group and total result cap so a broad query
|
||
* cannot return an unbounded payload. No terminal output is read.
|
||
*
|
||
* Endpoints: GET /api/search
|
||
*/
|
||
|
||
import { FastifyInstance } from 'fastify';
|
||
import { canAccessOwned, getAuthUser, parseBody } from '../route-helpers.js';
|
||
import { SearchQuerySchema } from '../schemas.js';
|
||
import {
|
||
searchSources,
|
||
type SearchSources,
|
||
type SessionSearchInput,
|
||
type EventSearchInput,
|
||
type FileSearchInput,
|
||
} from '../../search-service.js';
|
||
import type { SearchSourceType } from '../../types/search.js';
|
||
import type { SessionPort, InfraPort } from '../ports/index.js';
|
||
|
||
/**
|
||
* Per-source harvest caps. These bound how much in-memory data we hand to the
|
||
* pure core BEFORE it applies its own result caps — they keep the harvest itself
|
||
* cheap on large deployments (e.g. 50 sessions × many events). They are
|
||
* deliberately well above the result caps so ranking still sees enough candidates.
|
||
*/
|
||
const MAX_EVENTS_PER_SESSION = 500;
|
||
|
||
interface SessionLike {
|
||
id: string;
|
||
name: string;
|
||
workingDir: string;
|
||
lastActivityAt?: number;
|
||
createdAt?: number;
|
||
attachmentHistory?: Array<{
|
||
id: string;
|
||
fileName: string;
|
||
relativePath?: string;
|
||
timestamp?: number;
|
||
mtimeMs?: number;
|
||
}>;
|
||
}
|
||
|
||
/**
|
||
* 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, 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 & { owner?: string };
|
||
if (canSee && !canSee(s.owner)) continue; // multi-user ownership scope
|
||
const sessionName = s.name ?? '';
|
||
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
|
||
|
||
sessions.push({
|
||
sessionId: s.id,
|
||
sessionName,
|
||
workingDir: s.workingDir ?? '',
|
||
timestamp,
|
||
});
|
||
|
||
// Files: per-session attachment history. Only the workspace-relative path is
|
||
// surfaced; the server-private externalPath is intentionally never read.
|
||
const history = s.attachmentHistory ?? [];
|
||
for (const item of history) {
|
||
files.push({
|
||
sessionId: s.id,
|
||
sessionName,
|
||
fileName: item.fileName,
|
||
relativePath: item.relativePath,
|
||
timestamp: item.timestamp ?? item.mtimeMs ?? timestamp,
|
||
itemId: item.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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 & { 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.
|
||
const evts = summary.events.slice(-MAX_EVENTS_PER_SESSION);
|
||
for (const e of evts) {
|
||
events.push({
|
||
sessionId,
|
||
sessionName,
|
||
eventId: e.id,
|
||
title: e.title,
|
||
details: e.details ?? '',
|
||
timestamp: e.timestamp,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { sessions, events, files };
|
||
}
|
||
|
||
export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
|
||
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(
|
||
types
|
||
.split(',')
|
||
.map((t) => t.trim())
|
||
.filter(Boolean) as SearchSourceType[]
|
||
)
|
||
: null;
|
||
|
||
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.
|
||
const filtered: SearchSources = {
|
||
sessions: !allowed || allowed.has('session') ? sources.sessions : [],
|
||
events: !allowed || allowed.has('event') ? sources.events : [],
|
||
files: !allowed || allowed.has('file') ? sources.files : [],
|
||
};
|
||
|
||
const result = searchSources(q, filtered);
|
||
|
||
// Optional caller-supplied total cap (always on top of the core's hard caps).
|
||
if (limit !== undefined && result.totalResults > limit) {
|
||
let remaining = limit;
|
||
const cappedGroups = [];
|
||
for (const group of result.groups) {
|
||
if (remaining <= 0) break;
|
||
const slice = group.results.slice(0, remaining);
|
||
remaining -= slice.length;
|
||
cappedGroups.push({ type: group.type, results: slice });
|
||
}
|
||
result.groups = cappedGroups;
|
||
result.totalResults = limit;
|
||
result.truncated = true;
|
||
}
|
||
|
||
return { success: true, data: result };
|
||
});
|
||
}
|