mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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:
@@ -22,7 +22,8 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -227,13 +228,17 @@ async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extensi
|
||||
function getKnownSessionWorkingDir(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
req: FastifyRequest
|
||||
): string | undefined {
|
||||
// Multi-user: a non-admin may only reach their OWN session's files. A foreign
|
||||
// (or missing) session is reported identically as 404 so existence isn't leaked.
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) return liveSession.workingDir;
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) return liveSession.workingDir;
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored) return stored.workingDir;
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir;
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
return undefined;
|
||||
@@ -261,10 +266,13 @@ function appendDownloadFlag(url: string): string {
|
||||
|
||||
function getSessionAttachmentHistory(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
req: FastifyRequest
|
||||
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) {
|
||||
if (!canAccessOwned(user, liveSession.owner)) return undefined;
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
||||
@@ -272,7 +280,7 @@ function getSessionAttachmentHistory(
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
|
||||
if (!stored) return undefined;
|
||||
if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined;
|
||||
|
||||
return {
|
||||
workingDir: stored.workingDir,
|
||||
@@ -766,7 +774,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// each entry to current metadata + routes. External entries are re-registered.
|
||||
app.get('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id);
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id, req);
|
||||
if (!sessionHistory) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
||||
return;
|
||||
@@ -794,7 +802,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// size/mtime as the underlying file is rewritten).
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -850,7 +858,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// convert server-side; PDF/PNG/text redirect to the raw route.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -870,7 +878,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Serve a first-page thumbnail of a registered attachment by id.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
@@ -884,7 +892,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
@@ -912,7 +920,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
|
||||
Reference in New Issue
Block a user