diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 0415c617..f2a509db 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -192,6 +192,13 @@ export class ApiMachineClient { this.machine.metadataVersion = obj.version throw new Error('Metadata version mismatch') } + + if (obj.result === 'error') { + const reason = typeof (obj as { reason?: unknown }).reason === 'string' + ? (obj as { reason?: string }).reason + : 'unknown' + throw new Error(`Machine metadata update failed (${reason})`) + } }) } @@ -241,6 +248,13 @@ export class ApiMachineClient { this.machine.daemonStateVersion = obj.version throw new Error('Daemon state version mismatch') } + + if (obj.result === 'error') { + const reason = typeof (obj as { reason?: unknown }).reason === 'string' + ? (obj as { reason?: string }).reason + : 'unknown' + throw new Error(`Machine state update failed (${reason})`) + } }) } @@ -322,6 +336,10 @@ export class ApiMachineClient { this.socket.on('connect_error', (error) => { logger.debug(`[API MACHINE] Connection error: ${error.message}`) }) + + this.socket.on('error', (payload) => { + logger.debug('[API MACHINE] Socket error:', payload) + }) } private startKeepAlive(): void { diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 137c0ab7..743603fd 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -121,6 +121,10 @@ export class ApiSessionClient extends EventEmitter { this.rpcHandlerManager.onSocketDisconnect() }) + this.socket.on('error', (payload) => { + logger.debug('[API] Socket error:', payload) + }) + const handleTerminalEvent = ( schema: ZodType, handler: (payload: T) => void @@ -491,6 +495,13 @@ export class ApiSessionClient extends EventEmitter { this.metadataVersion = obj.version throw new Error('Metadata version mismatch') } + + if (obj.result === 'error') { + const reason = typeof (obj as { reason?: unknown }).reason === 'string' + ? (obj as { reason?: string }).reason + : 'unknown' + throw new Error(`Metadata update failed (${reason})`) + } }) }) } @@ -543,6 +554,13 @@ export class ApiSessionClient extends EventEmitter { this.agentStateVersion = obj.version throw new Error('Agent state version mismatch') } + + if (obj.result === 'error') { + const reason = typeof (obj as { reason?: unknown }).reason === 'string' + ? (obj as { reason?: string }).reason + : 'unknown' + throw new Error(`Agent state update failed (${reason})`) + } }) }) } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index cb6a39c6..6a0527f4 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -325,6 +325,8 @@ export const MessageContentSchema = z.union([UserMessageSchema, AgentMessageSche export type MessageContent = z.infer +export type SocketErrorReason = 'namespace-missing' | 'access-denied' | 'not-found' + export interface ServerToClientEvents { update: (data: Update) => void 'rpc-request': (data: { method: string; params: string }, callback: (response: string) => void) => void @@ -332,7 +334,7 @@ export interface ServerToClientEvents { 'terminal:write': (data: TerminalWritePayload) => void 'terminal:resize': (data: TerminalResizePayload) => void 'terminal:close': (data: TerminalClosePayload) => void - error: (data: { message: string }) => void + error: (data: { message: string; code?: SocketErrorReason; scope?: 'session' | 'machine'; id?: string }) => void } export interface ClientToServerEvents { @@ -348,6 +350,7 @@ export interface ClientToServerEvents { 'session-end': (data: { sid: string; time: number }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: { result: 'error' + reason?: SocketErrorReason } | { result: 'version-mismatch' version: number @@ -359,6 +362,7 @@ export interface ClientToServerEvents { }) => void) => void 'update-state': (data: { sid: string; expectedVersion: number; agentState: unknown | null }, cb: (answer: { result: 'error' + reason?: SocketErrorReason } | { result: 'version-mismatch' version: number @@ -371,6 +375,7 @@ export interface ClientToServerEvents { 'machine-alive': (data: { machineId: string; time: number }) => void 'machine-update-metadata': (data: { machineId: string; expectedVersion: number; metadata: unknown }, cb: (answer: { result: 'error' + reason?: SocketErrorReason } | { result: 'version-mismatch' version: number @@ -382,6 +387,7 @@ export interface ClientToServerEvents { }) => void) => void 'machine-update-state': (data: { machineId: string; expectedVersion: number; daemonState: unknown | null }, cb: (answer: { result: 'error' + reason?: SocketErrorReason } | { result: 'version-mismatch' version: number diff --git a/server/README.md b/server/README.md index 28b885e9..0fb98fbc 100644 --- a/server/README.md +++ b/server/README.md @@ -17,7 +17,7 @@ See `src/configuration.ts` for all options. ### Required -- `CLI_API_TOKEN` - Shared secret used by CLI and web login. Auto-generated if not set. +- `CLI_API_TOKEN` - Base shared secret used by CLI and web login. Clients append `:` for isolation. ### Optional (Telegram) @@ -45,7 +45,7 @@ hapi server If you only need web + CLI, you can omit TELEGRAM_BOT_TOKEN. To enable Telegram, set TELEGRAM_BOT_TOKEN and WEBAPP_URL, start the server, open `/app` -in the bot chat, and bind the Mini App with CLI_API_TOKEN when prompted. +in the bot chat, and bind the Mini App with `CLI_API_TOKEN:` when prompted. From source: @@ -60,8 +60,8 @@ See `src/web/routes/` for all endpoints. ### Authentication (`src/web/routes/auth.ts`) -- `POST /api/auth` - Get JWT token (Telegram initData or CLI_API_TOKEN). -- `POST /api/bind` - Bind a Telegram account using initData + CLI_API_TOKEN. +- `POST /api/auth` - Get JWT token (Telegram initData or `CLI_API_TOKEN[:namespace]`). +- `POST /api/bind` - Bind a Telegram account using initData + `CLI_API_TOKEN:`. ### Sessions (`src/web/routes/sessions.ts`) @@ -167,7 +167,7 @@ See `src/store/index.ts` for SQLite persistence: - Messages with pagination support. - Machines with daemon state. - Todo extraction from messages. -- Users table for Telegram bindings. +- Users table for Telegram bindings (includes namespace). ## Source structure @@ -181,8 +181,8 @@ See `src/store/index.ts` for SQLite persistence: ## Security model Access is controlled by: -- Telegram initData verification plus bound Telegram users (bound via CLI_API_TOKEN). -- `CLI_API_TOKEN` shared secret for CLI and browser access. +- Telegram initData verification plus bound Telegram users (bound via `CLI_API_TOKEN:`). +- `CLI_API_TOKEN` base secret for CLI and browser access (namespace is appended by clients). Transport security depends on HTTPS in front of the server. diff --git a/server/src/socket/handlers/cli.ts b/server/src/socket/handlers/cli.ts index ad2c6fee..cb2fbbfd 100644 --- a/server/src/socket/handlers/cli.ts +++ b/server/src/socket/handlers/cli.ts @@ -1,11 +1,11 @@ -import type { Server, Socket } from 'socket.io' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { Store } from '../../store' +import type { Store, StoredMachine, StoredSession } from '../../store' import { RpcRegistry } from '../rpcRegistry' import type { SyncEvent } from '../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../sync/todos' import { TerminalRegistry } from '../terminalRegistry' +import type { SocketServer, SocketWithData } from '../socketTypes' type SessionAlivePayload = { sid: string @@ -89,7 +89,7 @@ const terminalErrorSchema = z.object({ }) export type CliHandlersDeps = { - io: Server + io: SocketServer store: Store rpcRegistry: RpcRegistry terminalRegistry: TerminalRegistry @@ -99,21 +99,64 @@ export type CliHandlersDeps = { onWebappEvent?: (event: SyncEvent) => void } -export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void { +type AccessErrorReason = 'namespace-missing' | 'access-denied' | 'not-found' +type AccessResult = + | { ok: true; value: T } + | { ok: false; reason: AccessErrorReason } + +export function registerCliHandlers(socket: SocketWithData, deps: CliHandlersDeps): void { const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent } = deps const terminalNamespace = io.of('/terminal') + const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null + + const resolveSessionAccess = (sessionId: string): AccessResult => { + if (!namespace) { + return { ok: false, reason: 'namespace-missing' } + } + const session = store.getSessionByNamespace(sessionId, namespace) + if (session) { + return { ok: true, value: session } + } + if (store.getSession(sessionId)) { + return { ok: false, reason: 'access-denied' } + } + return { ok: false, reason: 'not-found' } + } + + const resolveMachineAccess = (machineId: string): AccessResult => { + if (!namespace) { + return { ok: false, reason: 'namespace-missing' } + } + const machine = store.getMachineByNamespace(machineId, namespace) + if (machine) { + return { ok: true, value: machine } + } + if (store.getMachine(machineId)) { + return { ok: false, reason: 'access-denied' } + } + return { ok: false, reason: 'not-found' } + } const auth = socket.handshake.auth as Record | undefined const sessionId = typeof auth?.sessionId === 'string' ? auth.sessionId : null - if (sessionId) { + if (sessionId && resolveSessionAccess(sessionId).ok) { socket.join(`session:${sessionId}`) } const machineId = typeof auth?.machineId === 'string' ? auth.machineId : null - if (machineId) { + if (machineId && resolveMachineAccess(machineId).ok) { socket.join(`machine:${machineId}`) } + const emitAccessError = (scope: 'session' | 'machine', id: string, reason: AccessErrorReason) => { + const message = reason === 'access-denied' + ? `${scope} access denied` + : reason === 'not-found' + ? `${scope} not found` + : 'Namespace missing' + socket.emit('error', { message, code: reason, scope, id }) + } + socket.on('rpc-register', (data: unknown) => { const parsed = rpcRegisterSchema.safeParse(data) if (!parsed.success) { @@ -161,11 +204,18 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void })() : raw + const sessionAccess = resolveSessionAccess(sid) + if (!sessionAccess.ok) { + emitAccessError('session', sid, sessionAccess.reason) + return + } + const session = sessionAccess.value + const msg = store.addMessage(sid, content, localId) const todos = extractTodoWriteTodosFromMessageContent(content) if (todos) { - const updated = store.setSessionTodos(sid, todos, msg.createdAt) + const updated = store.setSessionTodos(sid, todos, msg.createdAt, session.namespace) if (updated) { onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } }) } @@ -211,7 +261,13 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void } const { sid, metadata, expectedVersion } = parsed.data - const result = store.updateSessionMetadata(sid, metadata, expectedVersion) + const sessionAccess = resolveSessionAccess(sid) + if (!sessionAccess.ok) { + cb({ result: 'error', reason: sessionAccess.reason }) + return + } + + const result = store.updateSessionMetadata(sid, metadata, expectedVersion, sessionAccess.value.namespace) if (result.result === 'success') { cb({ result: 'success', version: result.version, metadata: result.value }) } else if (result.result === 'version-mismatch') { @@ -245,7 +301,13 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void } const { sid, agentState, expectedVersion } = parsed.data - const result = store.updateSessionAgentState(sid, agentState, expectedVersion) + const sessionAccess = resolveSessionAccess(sid) + if (!sessionAccess.ok) { + cb({ result: 'error', reason: sessionAccess.reason }) + return + } + + const result = store.updateSessionAgentState(sid, agentState, expectedVersion, sessionAccess.value.namespace) if (result.result === 'success') { cb({ result: 'success', version: result.version, agentState: result.value }) } else if (result.result === 'version-mismatch') { @@ -275,6 +337,11 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void if (!data || typeof data.sid !== 'string' || typeof data.time !== 'number') { return } + const sessionAccess = resolveSessionAccess(data.sid) + if (!sessionAccess.ok) { + emitAccessError('session', data.sid, sessionAccess.reason) + return + } onSessionAlive?.(data) }) @@ -282,6 +349,11 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void if (!data || typeof data.sid !== 'string' || typeof data.time !== 'number') { return } + const sessionAccess = resolveSessionAccess(data.sid) + if (!sessionAccess.ok) { + emitAccessError('session', data.sid, sessionAccess.reason) + return + } onSessionEnd?.(data) }) @@ -289,6 +361,11 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void if (!data || typeof data.machineId !== 'string' || typeof data.time !== 'number') { return } + const machineAccess = resolveMachineAccess(data.machineId) + if (!machineAccess.ok) { + emitAccessError('machine', data.machineId, machineAccess.reason) + return + } onMachineAlive?.(data) }) @@ -300,7 +377,13 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void } const { machineId: id, metadata, expectedVersion } = parsed.data - const result = store.updateMachineMetadata(id, metadata, expectedVersion) + const machineAccess = resolveMachineAccess(id) + if (!machineAccess.ok) { + cb({ result: 'error', reason: machineAccess.reason }) + return + } + + const result = store.updateMachineMetadata(id, metadata, expectedVersion, machineAccess.value.namespace) if (result.result === 'success') { cb({ result: 'success', version: result.version, metadata: result.value }) } else if (result.result === 'version-mismatch') { @@ -334,7 +417,13 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void } const { machineId: id, daemonState, expectedVersion } = parsed.data - const result = store.updateMachineDaemonState(id, daemonState, expectedVersion) + const machineAccess = resolveMachineAccess(id) + if (!machineAccess.ok) { + cb({ result: 'error', reason: machineAccess.reason }) + return + } + + const result = store.updateMachineDaemonState(id, daemonState, expectedVersion, machineAccess.value.namespace) if (result.result === 'success') { cb({ result: 'success', version: result.version, daemonState: result.value }) } else if (result.result === 'version-mismatch') { @@ -381,6 +470,11 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void if (payload.sessionId !== entry.sessionId) { return } + const sessionAccess = resolveSessionAccess(payload.sessionId) + if (!sessionAccess.ok) { + emitAccessError('session', payload.sessionId, sessionAccess.reason) + return + } const terminalSocket = terminalNamespace.sockets.get(entry.socketId) if (!terminalSocket) { return diff --git a/server/src/socket/handlers/terminal.test.ts b/server/src/socket/handlers/terminal.test.ts index e3038428..0a9cf238 100644 --- a/server/src/socket/handlers/terminal.test.ts +++ b/server/src/socket/handlers/terminal.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' -import type { Server, Socket } from 'socket.io' import { registerTerminalHandlers } from './terminal' import { TerminalRegistry } from '../terminalRegistry' +import type { SocketServer, SocketWithData } from '../socketTypes' type EmittedEvent = { event: string @@ -74,12 +74,13 @@ function createHarness(options?: { }): Harness { const io = new FakeServer() const terminalSocket = new FakeSocket('terminal-socket') + terminalSocket.data.namespace = 'default' const terminalRegistry = new TerminalRegistry({ idleTimeoutMs: 0 }) const cliNamespace = io.of('/cli') - registerTerminalHandlers(terminalSocket as unknown as Socket, { - io: io as unknown as Server, - getSession: () => ({ active: options?.sessionActive ?? true }), + registerTerminalHandlers(terminalSocket as unknown as SocketWithData, { + io: io as unknown as SocketServer, + getSession: () => ({ active: options?.sessionActive ?? true, namespace: 'default' }), terminalRegistry, maxTerminalsPerSocket: options?.maxTerminalsPerSocket ?? 4, maxTerminalsPerSession: options?.maxTerminalsPerSession ?? 4 @@ -89,6 +90,7 @@ function createHarness(options?: { } function connectCliSocket(cliNamespace: FakeNamespace, cliSocket: FakeSocket, sessionId: string): void { + cliSocket.data.namespace = 'default' cliNamespace.sockets.set(cliSocket.id, cliSocket) const roomId = `session:${sessionId}` const room = cliNamespace.adapter.rooms.get(roomId) ?? new Set() diff --git a/server/src/socket/handlers/terminal.ts b/server/src/socket/handlers/terminal.ts index 393a5461..0f84a2f1 100644 --- a/server/src/socket/handlers/terminal.ts +++ b/server/src/socket/handlers/terminal.ts @@ -1,6 +1,6 @@ -import type { Server, Socket } from 'socket.io' import { z } from 'zod' import type { TerminalRegistry, TerminalRegistryEntry } from '../terminalRegistry' +import type { SocketServer, SocketWithData } from '../socketTypes' const terminalCreateSchema = z.object({ sessionId: z.string().min(1), @@ -25,16 +25,17 @@ const terminalCloseSchema = z.object({ }) export type TerminalHandlersDeps = { - io: Server - getSession: (sessionId: string) => { active: boolean } | null + io: SocketServer + getSession: (sessionId: string) => { active: boolean; namespace: string } | null terminalRegistry: TerminalRegistry maxTerminalsPerSocket: number maxTerminalsPerSession: number } -export function registerTerminalHandlers(socket: Socket, deps: TerminalHandlersDeps): void { +export function registerTerminalHandlers(socket: SocketWithData, deps: TerminalHandlersDeps): void { const { io, getSession, terminalRegistry, maxTerminalsPerSocket, maxTerminalsPerSession } = deps const cliNamespace = io.of('/cli') + const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null const emitTerminalError = (terminalId: string, message: string) => { socket.emit('terminal:error', { terminalId, message }) @@ -48,9 +49,9 @@ export function registerTerminalHandlers(socket: Socket, deps: TerminalHandlersD return entry } - const resolveCliSocket = (entry: TerminalRegistryEntry, reportError: boolean): Socket | null => { + const resolveCliSocket = (entry: TerminalRegistryEntry, reportError: boolean): SocketWithData | null => { const cliSocket = cliNamespace.sockets.get(entry.cliSocketId) - if (!cliSocket) { + if (!cliSocket || cliSocket.data.namespace !== namespace) { terminalRegistry.remove(entry.terminalId) if (reportError) { emitTerminalError(entry.terminalId, 'CLI disconnected.') @@ -62,7 +63,7 @@ export function registerTerminalHandlers(socket: Socket, deps: TerminalHandlersD const emitCloseToCli = (entry: TerminalRegistryEntry): void => { const cliSocket = cliNamespace.sockets.get(entry.cliSocketId) - if (!cliSocket) { + if (!cliSocket || cliSocket.data.namespace !== namespace) { return } cliSocket.emit('terminal:close', { @@ -77,8 +78,9 @@ export function registerTerminalHandlers(socket: Socket, deps: TerminalHandlersD return null } for (const socketId of room) { - if (cliNamespace.sockets.has(socketId)) { - return socketId + const cliSocket = cliNamespace.sockets.get(socketId) + if (cliSocket && cliSocket.data.namespace === namespace) { + return cliSocket.id } } return null @@ -92,7 +94,7 @@ export function registerTerminalHandlers(socket: Socket, deps: TerminalHandlersD const { sessionId, terminalId, cols, rows } = parsed.data const session = getSession(sessionId) - if (!session || !session.active) { + if (!namespace || !session || session.namespace !== namespace || !session.active) { emitTerminalError(terminalId, 'Session is inactive or unavailable.') return } diff --git a/server/src/socket/server.ts b/server/src/socket/server.ts index 13ec9c2d..baec81e2 100644 --- a/server/src/socket/server.ts +++ b/server/src/socket/server.ts @@ -1,18 +1,21 @@ import { Server as Engine } from '@socket.io/bun-engine' -import { Server } from 'socket.io' +import { Server, type DefaultEventsMap } from 'socket.io' import { jwtVerify } from 'jose' import { z } from 'zod' import type { Store } from '../store' import { configuration } from '../configuration' import { safeCompareStrings } from '../utils/crypto' +import { parseAccessToken } from '../utils/accessToken' import { registerCliHandlers } from './handlers/cli' import { registerTerminalHandlers } from './handlers/terminal' import { RpcRegistry } from './rpcRegistry' import type { SyncEvent } from '../sync/syncEngine' import { TerminalRegistry } from './terminalRegistry' +import type { SocketData, SocketServer } from './socketTypes' const jwtPayloadSchema = z.object({ - uid: z.number() + uid: z.number(), + ns: z.string() }) const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000 @@ -30,7 +33,7 @@ function resolveEnvNumber(name: string, fallback: number): number { export type SocketServerDeps = { store: Store jwtSecret: Uint8Array - getSession?: (sessionId: string) => { active: boolean } | null + getSession?: (sessionId: string) => { active: boolean; namespace: string } | null onWebappEvent?: (event: SyncEvent) => void onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void @@ -38,14 +41,14 @@ export type SocketServerDeps = { } export function createSocketServer(deps: SocketServerDeps): { - io: Server + io: SocketServer engine: Engine rpcRegistry: RpcRegistry } { const corsOrigins = configuration.corsOrigins const allowAllOrigins = corsOrigins.includes('*') - const io = new Server({ + const io = new Server({ cors: { origin: (origin, callback) => { if (!origin) { @@ -94,9 +97,11 @@ export function createSocketServer(deps: SocketServerDeps): { cliNs.use((socket, next) => { const auth = socket.handshake.auth as Record | undefined const token = typeof auth?.token === 'string' ? auth.token : null - if (!safeCompareStrings(token, configuration.cliApiToken)) { + const parsedToken = token ? parseAccessToken(token) : null + if (!parsedToken || !safeCompareStrings(parsedToken.baseToken, configuration.cliApiToken)) { return next(new Error('Invalid token')) } + socket.data.namespace = parsedToken.namespace next() }) cliNs.on('connection', (socket) => registerCliHandlers(socket, { @@ -124,6 +129,7 @@ export function createSocketServer(deps: SocketServerDeps): { return next(new Error('Invalid token payload')) } socket.data.userId = parsed.data.uid + socket.data.namespace = parsed.data.ns next() return } catch { diff --git a/server/src/socket/socketTypes.ts b/server/src/socket/socketTypes.ts new file mode 100644 index 00000000..2c3662f9 --- /dev/null +++ b/server/src/socket/socketTypes.ts @@ -0,0 +1,9 @@ +import type { DefaultEventsMap, Server, Socket } from 'socket.io' + +export type SocketData = { + namespace?: string + userId?: number +} + +export type SocketServer = Server +export type SocketWithData = Socket diff --git a/server/src/sse/sseManager.test.ts b/server/src/sse/sseManager.test.ts new file mode 100644 index 00000000..e624cee8 --- /dev/null +++ b/server/src/sse/sseManager.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'bun:test' +import { SSEManager } from './sseManager' +import type { SyncEvent } from '../sync/syncEngine' + +describe('SSEManager namespace filtering', () => { + it('routes events to matching namespace', () => { + const manager = new SSEManager(0) + const receivedAlpha: SyncEvent[] = [] + const receivedBeta: SyncEvent[] = [] + + manager.subscribe({ + id: 'alpha', + namespace: 'alpha', + all: true, + send: (event) => { + receivedAlpha.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'beta', + namespace: 'beta', + all: true, + send: (event) => { + receivedBeta.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.broadcast({ type: 'session-updated', sessionId: 's1', namespace: 'alpha' }) + + expect(receivedAlpha).toHaveLength(1) + expect(receivedBeta).toHaveLength(0) + }) + + it('broadcasts connection-changed to all namespaces', () => { + const manager = new SSEManager(0) + const received: Array<{ id: string; event: SyncEvent }> = [] + + manager.subscribe({ + id: 'alpha', + namespace: 'alpha', + all: true, + send: (event) => { + received.push({ id: 'alpha', event }) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'beta', + namespace: 'beta', + all: true, + send: (event) => { + received.push({ id: 'beta', event }) + }, + sendHeartbeat: () => {} + }) + + manager.broadcast({ type: 'connection-changed', data: { status: 'connected' } }) + + expect(received).toHaveLength(2) + expect(received.map((entry) => entry.id).sort()).toEqual(['alpha', 'beta']) + }) +}) diff --git a/server/src/sse/sseManager.ts b/server/src/sse/sseManager.ts index 3fe03d53..5f6e2c2a 100644 --- a/server/src/sse/sseManager.ts +++ b/server/src/sse/sseManager.ts @@ -2,6 +2,7 @@ import type { SyncEvent } from '../sync/syncEngine' export type SSESubscription = { id: string + namespace: string all: boolean sessionId: string | null machineId: string | null @@ -23,6 +24,7 @@ export class SSEManager { subscribe(options: { id: string + namespace: string all?: boolean sessionId?: string | null machineId?: string | null @@ -31,6 +33,7 @@ export class SSEManager { }): SSESubscription { const subscription: SSEConnection = { id: options.id, + namespace: options.namespace, all: Boolean(options.all), sessionId: options.sessionId ?? null, machineId: options.machineId ?? null, @@ -42,6 +45,7 @@ export class SSEManager { this.ensureHeartbeat() return { id: subscription.id, + namespace: subscription.namespace, all: subscription.all, sessionId: subscription.sessionId, machineId: subscription.machineId @@ -96,6 +100,13 @@ export class SSEManager { } private shouldSend(connection: SSEConnection, event: SyncEvent): boolean { + if (event.type !== 'connection-changed') { + const eventNamespace = event.namespace + if (!eventNamespace || eventNamespace !== connection.namespace) { + return false + } + } + if (event.type === 'message-received') { return Boolean(event.sessionId && connection.sessionId === event.sessionId) } diff --git a/server/src/store/index.ts b/server/src/store/index.ts index 5c504242..fd44c272 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto' export type StoredSession = { id: string tag: string | null + namespace: string machineId: string | null createdAt: number updatedAt: number @@ -22,6 +23,7 @@ export type StoredSession = { export type StoredMachine = { id: string + namespace: string createdAt: number updatedAt: number metadata: unknown | null @@ -46,6 +48,7 @@ export type StoredUser = { id: number platform: string platformUserId: string + namespace: string createdAt: number } @@ -57,6 +60,7 @@ export type VersionedUpdateResult = type DbSessionRow = { id: string tag: string | null + namespace: string machine_id: string | null created_at: number updated_at: number @@ -73,6 +77,7 @@ type DbSessionRow = { type DbMachineRow = { id: string + namespace: string created_at: number updated_at: number metadata: string | null @@ -97,6 +102,7 @@ type DbUserRow = { id: number platform: string platform_user_id: string + namespace: string created_at: number } @@ -113,6 +119,7 @@ function toStoredSession(row: DbSessionRow): StoredSession { return { id: row.id, tag: row.tag, + namespace: row.namespace, machineId: row.machine_id, createdAt: row.created_at, updatedAt: row.updated_at, @@ -131,6 +138,7 @@ function toStoredSession(row: DbSessionRow): StoredSession { function toStoredMachine(row: DbMachineRow): StoredMachine { return { id: row.id, + namespace: row.namespace, createdAt: row.created_at, updatedAt: row.updated_at, metadata: safeJsonParse(row.metadata), @@ -159,6 +167,7 @@ function toStoredUser(row: DbUserRow): StoredUser { id: row.id, platform: row.platform, platformUserId: row.platform_user_id, + namespace: row.namespace, createdAt: row.created_at } } @@ -206,6 +215,7 @@ export class Store { CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, tag TEXT, + namespace TEXT NOT NULL DEFAULT 'default', machine_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, @@ -220,9 +230,11 @@ export class Store { seq INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag); + CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace); CREATE TABLE IF NOT EXISTS machines ( id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, metadata TEXT, @@ -233,6 +245,7 @@ export class Store { active_at INTEGER, seq INTEGER DEFAULT 0 ); + CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, @@ -250,27 +263,44 @@ export class Store { id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, platform_user_id TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT 'default', created_at INTEGER NOT NULL, UNIQUE(platform, platform_user_id) ); CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform); + CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace); `) const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> const sessionColumnNames = new Set(sessionColumns.map((c) => c.name)) + if (!sessionColumnNames.has('namespace')) { + this.db.exec("ALTER TABLE sessions ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'") + } if (!sessionColumnNames.has('todos')) { this.db.exec('ALTER TABLE sessions ADD COLUMN todos TEXT') } if (!sessionColumnNames.has('todos_updated_at')) { this.db.exec('ALTER TABLE sessions ADD COLUMN todos_updated_at INTEGER') } + + const machineColumns = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }> + const machineColumnNames = new Set(machineColumns.map((c) => c.name)) + if (!machineColumnNames.has('namespace')) { + this.db.exec("ALTER TABLE machines ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'") + } + + const userColumns = this.db.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }> + const userColumnNames = new Set(userColumns.map((c) => c.name)) + if (!userColumnNames.has('namespace')) { + this.db.exec("ALTER TABLE users ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'") + } } - getOrCreateSession(tag: string, metadata: unknown, agentState: unknown): StoredSession { + getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): StoredSession { const existing = this.db.prepare( - 'SELECT * FROM sessions WHERE tag = ? ORDER BY created_at DESC LIMIT 1' - ).get(tag) as DbSessionRow | undefined + 'SELECT * FROM sessions WHERE tag = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1' + ).get(tag, namespace) as DbSessionRow | undefined if (existing) { return toStoredSession(existing) @@ -284,13 +314,13 @@ export class Store { this.db.prepare(` INSERT INTO sessions ( - id, tag, machine_id, created_at, updated_at, + id, tag, namespace, machine_id, created_at, updated_at, metadata, metadata_version, agent_state, agent_state_version, todos, todos_updated_at, active, active_at, seq ) VALUES ( - @id, @tag, NULL, @created_at, @updated_at, + @id, @tag, @namespace, NULL, @created_at, @updated_at, @metadata, 1, @agent_state, 1, NULL, NULL, @@ -299,6 +329,7 @@ export class Store { `).run({ id, tag, + namespace, created_at: now, updated_at: now, metadata: metadataJson, @@ -312,7 +343,12 @@ export class Store { return row } - updateSessionMetadata(id: string, metadata: unknown, expectedVersion: number): VersionedUpdateResult { + updateSessionMetadata( + id: string, + metadata: unknown, + expectedVersion: number, + namespace: string + ): VersionedUpdateResult { try { const now = Date.now() const json = JSON.stringify(metadata) @@ -322,14 +358,16 @@ export class Store { metadata_version = metadata_version + 1, updated_at = @updated_at, seq = seq + 1 - WHERE id = @id AND metadata_version = @expectedVersion - `).run({ id, metadata: json, updated_at: now, expectedVersion }) + WHERE id = @id AND namespace = @namespace AND metadata_version = @expectedVersion + `).run({ id, metadata: json, updated_at: now, expectedVersion, namespace }) if (result.changes === 1) { return { result: 'success', version: expectedVersion + 1, value: metadata } } - const current = this.db.prepare('SELECT metadata, metadata_version FROM sessions WHERE id = ?').get(id) as + const current = this.db.prepare( + 'SELECT metadata, metadata_version FROM sessions WHERE id = ? AND namespace = ?' + ).get(id, namespace) as | { metadata: string | null; metadata_version: number } | undefined if (!current) { @@ -345,7 +383,12 @@ export class Store { } } - updateSessionAgentState(id: string, agentState: unknown, expectedVersion: number): VersionedUpdateResult { + updateSessionAgentState( + id: string, + agentState: unknown, + expectedVersion: number, + namespace: string + ): VersionedUpdateResult { try { const now = Date.now() const json = agentState === null || agentState === undefined ? null : JSON.stringify(agentState) @@ -355,14 +398,16 @@ export class Store { agent_state_version = agent_state_version + 1, updated_at = @updated_at, seq = seq + 1 - WHERE id = @id AND agent_state_version = @expectedVersion - `).run({ id, agent_state: json, updated_at: now, expectedVersion }) + WHERE id = @id AND namespace = @namespace AND agent_state_version = @expectedVersion + `).run({ id, agent_state: json, updated_at: now, expectedVersion, namespace }) if (result.changes === 1) { return { result: 'success', version: expectedVersion + 1, value: agentState === undefined ? null : agentState } } - const current = this.db.prepare('SELECT agent_state, agent_state_version FROM sessions WHERE id = ?').get(id) as + const current = this.db.prepare( + 'SELECT agent_state, agent_state_version FROM sessions WHERE id = ? AND namespace = ?' + ).get(id, namespace) as | { agent_state: string | null; agent_state_version: number } | undefined if (!current) { @@ -378,7 +423,7 @@ export class Store { } } - setSessionTodos(id: string, todos: unknown, todosUpdatedAt: number): boolean { + setSessionTodos(id: string, todos: unknown, todosUpdatedAt: number, namespace: string): boolean { try { const json = todos === null || todos === undefined ? null : JSON.stringify(todos) const result = this.db.prepare(` @@ -387,12 +432,15 @@ export class Store { todos_updated_at = @todos_updated_at, updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END, seq = seq + 1 - WHERE id = @id AND (todos_updated_at IS NULL OR todos_updated_at < @todos_updated_at) + WHERE id = @id + AND namespace = @namespace + AND (todos_updated_at IS NULL OR todos_updated_at < @todos_updated_at) `).run({ id, todos: json, todos_updated_at: todosUpdatedAt, - updated_at: todosUpdatedAt + updated_at: todosUpdatedAt, + namespace }) return result.changes === 1 @@ -406,15 +454,33 @@ export class Store { return row ? toStoredSession(row) : null } + getSessionByNamespace(id: string, namespace: string): StoredSession | null { + const row = this.db.prepare( + 'SELECT * FROM sessions WHERE id = ? AND namespace = ?' + ).get(id, namespace) as DbSessionRow | undefined + return row ? toStoredSession(row) : null + } + getSessions(): StoredSession[] { const rows = this.db.prepare('SELECT * FROM sessions ORDER BY updated_at DESC').all() as DbSessionRow[] return rows.map(toStoredSession) } - getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown): StoredMachine { + getSessionsByNamespace(namespace: string): StoredSession[] { + const rows = this.db.prepare( + 'SELECT * FROM sessions WHERE namespace = ? ORDER BY updated_at DESC' + ).all(namespace) as DbSessionRow[] + return rows.map(toStoredSession) + } + + getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown, namespace: string): StoredMachine { const existing = this.db.prepare('SELECT * FROM machines WHERE id = ?').get(id) as DbMachineRow | undefined if (existing) { - return toStoredMachine(existing) + const stored = toStoredMachine(existing) + if (stored.namespace !== namespace) { + throw new Error('Machine namespace mismatch') + } + return stored } const now = Date.now() @@ -423,18 +489,19 @@ export class Store { this.db.prepare(` INSERT INTO machines ( - id, created_at, updated_at, + id, namespace, created_at, updated_at, metadata, metadata_version, daemon_state, daemon_state_version, active, active_at, seq ) VALUES ( - @id, @created_at, @updated_at, + @id, @namespace, @created_at, @updated_at, @metadata, 1, @daemon_state, 1, 0, NULL, 0 ) `).run({ id, + namespace, created_at: now, updated_at: now, metadata: metadataJson, @@ -448,7 +515,12 @@ export class Store { return row } - updateMachineMetadata(id: string, metadata: unknown, expectedVersion: number): VersionedUpdateResult { + updateMachineMetadata( + id: string, + metadata: unknown, + expectedVersion: number, + namespace: string + ): VersionedUpdateResult { try { const now = Date.now() const json = JSON.stringify(metadata) @@ -458,14 +530,16 @@ export class Store { metadata_version = metadata_version + 1, updated_at = @updated_at, seq = seq + 1 - WHERE id = @id AND metadata_version = @expectedVersion - `).run({ id, metadata: json, updated_at: now, expectedVersion }) + WHERE id = @id AND namespace = @namespace AND metadata_version = @expectedVersion + `).run({ id, metadata: json, updated_at: now, expectedVersion, namespace }) if (result.changes === 1) { return { result: 'success', version: expectedVersion + 1, value: metadata } } - const current = this.db.prepare('SELECT metadata, metadata_version FROM machines WHERE id = ?').get(id) as + const current = this.db.prepare( + 'SELECT metadata, metadata_version FROM machines WHERE id = ? AND namespace = ?' + ).get(id, namespace) as | { metadata: string | null; metadata_version: number } | undefined if (!current) { @@ -481,7 +555,12 @@ export class Store { } } - updateMachineDaemonState(id: string, daemonState: unknown, expectedVersion: number): VersionedUpdateResult { + updateMachineDaemonState( + id: string, + daemonState: unknown, + expectedVersion: number, + namespace: string + ): VersionedUpdateResult { try { const now = Date.now() const json = daemonState === null || daemonState === undefined ? null : JSON.stringify(daemonState) @@ -493,14 +572,16 @@ export class Store { active = 1, active_at = @active_at, seq = seq + 1 - WHERE id = @id AND daemon_state_version = @expectedVersion - `).run({ id, daemon_state: json, updated_at: now, active_at: now, expectedVersion }) + WHERE id = @id AND namespace = @namespace AND daemon_state_version = @expectedVersion + `).run({ id, daemon_state: json, updated_at: now, active_at: now, expectedVersion, namespace }) if (result.changes === 1) { return { result: 'success', version: expectedVersion + 1, value: daemonState === undefined ? null : daemonState } } - const current = this.db.prepare('SELECT daemon_state, daemon_state_version FROM machines WHERE id = ?').get(id) as + const current = this.db.prepare( + 'SELECT daemon_state, daemon_state_version FROM machines WHERE id = ? AND namespace = ?' + ).get(id, namespace) as | { daemon_state: string | null; daemon_state_version: number } | undefined if (!current) { @@ -521,11 +602,25 @@ export class Store { return row ? toStoredMachine(row) : null } + getMachineByNamespace(id: string, namespace: string): StoredMachine | null { + const row = this.db.prepare( + 'SELECT * FROM machines WHERE id = ? AND namespace = ?' + ).get(id, namespace) as DbMachineRow | undefined + return row ? toStoredMachine(row) : null + } + getMachines(): StoredMachine[] { const rows = this.db.prepare('SELECT * FROM machines ORDER BY updated_at DESC').all() as DbMachineRow[] return rows.map(toStoredMachine) } + getMachinesByNamespace(namespace: string): StoredMachine[] { + const rows = this.db.prepare( + 'SELECT * FROM machines WHERE namespace = ? ORDER BY updated_at DESC' + ).all(namespace) as DbMachineRow[] + return rows.map(toStoredMachine) + } + addMessage(sessionId: string, content: unknown, localId?: string): StoredMessage { const now = Date.now() @@ -607,17 +702,25 @@ export class Store { return rows.map(toStoredUser) } - addUser(platform: string, platformUserId: string): StoredUser { + getUsersByPlatformAndNamespace(platform: string, namespace: string): StoredUser[] { + const rows = this.db.prepare( + 'SELECT * FROM users WHERE platform = ? AND namespace = ? ORDER BY created_at ASC' + ).all(platform, namespace) as DbUserRow[] + return rows.map(toStoredUser) + } + + addUser(platform: string, platformUserId: string, namespace: string): StoredUser { const now = Date.now() this.db.prepare(` INSERT OR IGNORE INTO users ( - platform, platform_user_id, created_at + platform, platform_user_id, namespace, created_at ) VALUES ( - @platform, @platform_user_id, @created_at + @platform, @platform_user_id, @namespace, @created_at ) `).run({ platform, platform_user_id: platformUserId, + namespace, created_at: now }) diff --git a/server/src/store/namespace.test.ts b/server/src/store/namespace.test.ts new file mode 100644 index 00000000..66fe6e07 --- /dev/null +++ b/server/src/store/namespace.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from './index' + +describe('Store namespace filtering', () => { + it('filters sessions by namespace', () => { + const store = new Store(':memory:') + const sessionAlpha = store.getOrCreateSession('tag', { path: '/alpha' }, null, 'alpha') + const sessionBeta = store.getOrCreateSession('tag', { path: '/beta' }, null, 'beta') + + const sessionsAlpha = store.getSessionsByNamespace('alpha') + const ids = sessionsAlpha.map((session) => session.id) + + expect(ids).toContain(sessionAlpha.id) + expect(ids).not.toContain(sessionBeta.id) + }) + + it('filters machines by namespace and blocks mismatches', () => { + const store = new Store(':memory:') + const machineAlpha = store.getOrCreateMachine('machine-1', { host: 'alpha' }, null, 'alpha') + store.getOrCreateMachine('machine-2', { host: 'beta' }, null, 'beta') + + const machinesAlpha = store.getMachinesByNamespace('alpha') + const ids = machinesAlpha.map((machine) => machine.id) + + expect(ids).toContain(machineAlpha.id) + expect(ids).not.toContain('machine-2') + expect(() => store.getOrCreateMachine('machine-1', { host: 'beta' }, null, 'beta')).toThrow() + }) +}) diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 5f6173fa..b4f00d4b 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -72,6 +72,7 @@ const machineMetadataSchema = z.object({ export interface Session { id: string + namespace: string seq: number createdAt: number updatedAt: number @@ -90,6 +91,7 @@ export interface Session { export interface Machine { id: string + namespace: string seq: number createdAt: number updatedAt: number @@ -147,6 +149,7 @@ export type SyncEventType = export interface SyncEvent { type: SyncEventType + namespace?: string sessionId?: string machineId?: string data?: unknown @@ -202,9 +205,12 @@ export class SyncEngine { } private emit(event: SyncEvent): void { + const namespace = this.resolveNamespace(event) + const enrichedEvent = namespace ? { ...event, namespace } : event + for (const listener of this.listeners) { try { - listener(event) + listener(enrichedEvent) } catch (error) { console.error('[SyncEngine] Listener error:', error) } @@ -213,12 +219,14 @@ export class SyncEngine { const webappEvent: SyncEvent = event.type === 'message-received' ? { type: event.type, + namespace, sessionId: event.sessionId, machineId: event.machineId, message: event.message } : { type: event.type, + namespace, sessionId: event.sessionId, machineId: event.machineId } @@ -226,6 +234,19 @@ export class SyncEngine { this.sseManager.broadcast(webappEvent) } + private resolveNamespace(event: SyncEvent): string | undefined { + if (event.namespace) { + return event.namespace + } + if (event.sessionId) { + return this.sessions.get(event.sessionId)?.namespace + } + if (event.machineId) { + return this.machines.get(event.machineId)?.namespace + } + return undefined + } + getConnectionStatus(): ConnectionStatus { return this.connectionStatus } @@ -234,10 +255,22 @@ export class SyncEngine { return Array.from(this.sessions.values()) } + getSessionsByNamespace(namespace: string): Session[] { + return this.getSessions().filter((session) => session.namespace === namespace) + } + getSession(sessionId: string): Session | undefined { return this.sessions.get(sessionId) } + getSessionByNamespace(sessionId: string, namespace: string): Session | undefined { + const session = this.sessions.get(sessionId) + if (!session || session.namespace !== namespace) { + return undefined + } + return session + } + getActiveSessions(): Session[] { return this.getSessions().filter(s => s.active) } @@ -246,14 +279,30 @@ export class SyncEngine { return Array.from(this.machines.values()) } + getMachinesByNamespace(namespace: string): Machine[] { + return this.getMachines().filter((machine) => machine.namespace === namespace) + } + getMachine(machineId: string): Machine | undefined { return this.machines.get(machineId) } + getMachineByNamespace(machineId: string, namespace: string): Machine | undefined { + const machine = this.machines.get(machineId) + if (!machine || machine.namespace !== namespace) { + return undefined + } + return machine + } + getOnlineMachines(): Machine[] { return this.getMachines().filter(m => m.active) } + getOnlineMachinesByNamespace(namespace: string): Machine[] { + return this.getMachinesByNamespace(namespace).filter((machine) => machine.active) + } + getSessionMessages(sessionId: string): DecryptedMessage[] { return this.sessionMessages.get(sessionId) || [] } @@ -320,6 +369,12 @@ export class SyncEngine { return } + if (event.type === 'message-received' && event.sessionId) { + if (!this.sessions.has(event.sessionId)) { + this.refreshSession(event.sessionId) + } + } + this.emit(event) } @@ -453,7 +508,7 @@ export class SyncEngine { const message = messages[i] const todos = extractTodoWriteTodosFromMessageContent(message.content) if (todos) { - const updated = this.store.setSessionTodos(sessionId, todos, message.createdAt) + const updated = this.store.setSessionTodos(sessionId, todos, message.createdAt, stored.namespace) if (updated) { stored = this.store.getSession(sessionId) ?? stored } @@ -480,6 +535,7 @@ export class SyncEngine { const session: Session = { id: stored.id, + namespace: stored.namespace, seq: stored.seq, createdAt: stored.createdAt, updatedAt: stored.updatedAt, @@ -530,6 +586,7 @@ export class SyncEngine { const machine: Machine = { id: stored.id, + namespace: stored.namespace, seq: stored.seq, createdAt: stored.createdAt, updatedAt: stored.updatedAt, @@ -558,13 +615,13 @@ export class SyncEngine { } } - getOrCreateSession(tag: string, metadata: unknown, agentState: unknown): Session { - const stored = this.store.getOrCreateSession(tag, metadata, agentState) + getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): Session { + const stored = this.store.getOrCreateSession(tag, metadata, agentState, namespace) return this.refreshSession(stored.id) ?? (() => { throw new Error('Failed to load session') })() } - getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown): Machine { - const stored = this.store.getOrCreateMachine(id, metadata, daemonState) + getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown, namespace: string): Machine { + const stored = this.store.getOrCreateMachine(id, metadata, daemonState, namespace) return this.refreshMachine(stored.id) ?? (() => { throw new Error('Failed to load machine') })() } diff --git a/server/src/telegram/bot.ts b/server/src/telegram/bot.ts index 64f88c84..d251e1d6 100644 --- a/server/src/telegram/bot.ts +++ b/server/src/telegram/bot.ts @@ -167,10 +167,17 @@ export class HappyBot { return } + const namespace = this.getNamespaceForChatId(ctx.from?.id ?? null) + if (!namespace) { + await ctx.answerCallbackQuery('Telegram account is not bound') + return + } + const data = ctx.callbackQuery.data const callbackContext: CallbackContext = { syncEngine: this.syncEngine, + namespace, answerCallback: async (text?: string) => { await ctx.answerCallbackQuery(text) }, @@ -220,8 +227,8 @@ export class HappyBot { /** * Get bound Telegram chat IDs from storage. */ - private getBoundChatIds(): number[] { - const users = this.store.getUsersByPlatform('telegram') + private getBoundChatIds(namespace: string): number[] { + const users = this.store.getUsersByPlatformAndNamespace('telegram', namespace) const ids = new Set() for (const user of users) { const chatId = Number(user.platformUserId) @@ -232,6 +239,14 @@ export class HappyBot { return Array.from(ids) } + private getNamespaceForChatId(chatId: number | null | undefined): string | null { + if (!chatId) { + return null + } + const stored = this.store.getUser('telegram', String(chatId)) + return stored?.namespace ?? null + } + /** * Send a push notification when agent is ready for input. */ @@ -259,7 +274,7 @@ export class HappyBot { const keyboard = new InlineKeyboard() .webApp('Open Session', url) - const chatIds = this.getBoundChatIds() + const chatIds = this.getBoundChatIds(session.namespace) if (chatIds.length === 0) { return } @@ -338,7 +353,7 @@ export class HappyBot { const text = formatSessionNotification(session) const keyboard = createNotificationKeyboard(session, this.miniAppUrl) - const chatIds = this.getBoundChatIds() + const chatIds = this.getBoundChatIds(session.namespace) if (chatIds.length === 0) { return } diff --git a/server/src/telegram/callbacks.ts b/server/src/telegram/callbacks.ts index 9b0a91f4..4c125608 100644 --- a/server/src/telegram/callbacks.ts +++ b/server/src/telegram/callbacks.ts @@ -20,6 +20,7 @@ export const ACTIONS = { */ export interface CallbackContext { syncEngine: SyncEngine + namespace: string answerCallback: (text?: string) => Promise editMessage: (text: string, keyboard?: InlineKeyboard) => Promise } @@ -30,7 +31,7 @@ async function getSessionOrAnswer( sessionPrefix: string, options?: { requireActive?: boolean } ): Promise { - const session = findSessionByPrefix(syncEngine.getSessions(), sessionPrefix) + const session = findSessionByPrefix(syncEngine.getSessionsByNamespace(ctx.namespace), sessionPrefix) if (!session) { await ctx.answerCallback('Session not found') return null diff --git a/server/src/utils/accessToken.test.ts b/server/src/utils/accessToken.test.ts new file mode 100644 index 00000000..4a1e4e9a --- /dev/null +++ b/server/src/utils/accessToken.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test' +import { DEFAULT_NAMESPACE, parseAccessToken } from './accessToken' + +describe('parseAccessToken', () => { + it('defaults namespace when missing', () => { + const parsed = parseAccessToken('token') + expect(parsed).toEqual({ baseToken: 'token', namespace: DEFAULT_NAMESPACE }) + }) + + it('parses namespace suffix', () => { + const parsed = parseAccessToken('token:alice') + expect(parsed).toEqual({ baseToken: 'token', namespace: 'alice' }) + }) + + it('rejects empty namespace', () => { + expect(parseAccessToken('token:')).toBeNull() + }) + + it('rejects missing base token', () => { + expect(parseAccessToken(':alice')).toBeNull() + }) + + it('rejects whitespace inside namespace', () => { + expect(parseAccessToken('token: alice')).toBeNull() + }) +}) diff --git a/server/src/utils/accessToken.ts b/server/src/utils/accessToken.ts new file mode 100644 index 00000000..ca779ba7 --- /dev/null +++ b/server/src/utils/accessToken.ts @@ -0,0 +1,34 @@ +export const DEFAULT_NAMESPACE = 'default' + +export type ParsedAccessToken = { + baseToken: string + namespace: string +} + +export function parseAccessToken(raw: string): ParsedAccessToken | null { + if (!raw) { + return null + } + + const trimmed = raw.trim() + if (!trimmed) { + return null + } + + const separatorIndex = trimmed.lastIndexOf(':') + if (separatorIndex === -1) { + return { baseToken: trimmed, namespace: DEFAULT_NAMESPACE } + } + + const baseToken = trimmed.slice(0, separatorIndex) + const namespace = trimmed.slice(separatorIndex + 1) + if (!baseToken || !namespace) { + return null + } + + if (baseToken.trim() !== baseToken || namespace.trim() !== namespace) { + return null + } + + return { baseToken, namespace } +} diff --git a/server/src/web/middleware/auth.ts b/server/src/web/middleware/auth.ts index 675c8a84..175d7d23 100644 --- a/server/src/web/middleware/auth.ts +++ b/server/src/web/middleware/auth.ts @@ -5,11 +5,13 @@ import { jwtVerify } from 'jose' export type WebAppEnv = { Variables: { userId: number + namespace: string } } const jwtPayloadSchema = z.object({ - uid: z.number() + uid: z.number(), + ns: z.string() }) export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler { @@ -37,6 +39,7 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler SyncEngine | null): Hono { - const app = new Hono() +type CliEnv = { + Variables: { + namespace: string + } +} + +function resolveSessionForNamespace( + engine: SyncEngine, + sessionId: string, + namespace: string +): { ok: true; session: Session } | { ok: false; status: 403 | 404; error: string } { + const session = engine.getSessionByNamespace(sessionId, namespace) + if (session) { + return { ok: true, session } + } + if (engine.getSession(sessionId)) { + return { ok: false, status: 403, error: 'Session access denied' } + } + return { ok: false, status: 404, error: 'Session not found' } +} + +function resolveMachineForNamespace( + engine: SyncEngine, + machineId: string, + namespace: string +): { ok: true; machine: Machine } | { ok: false; status: 403 | 404; error: string } { + const machine = engine.getMachineByNamespace(machineId, namespace) + if (machine) { + return { ok: true, machine } + } + if (engine.getMachine(machineId)) { + return { ok: false, status: 403, error: 'Machine access denied' } + } + return { ok: false, status: 404, error: 'Machine not found' } +} + +export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() app.use('*', async (c, next) => { const raw = c.req.header('authorization') @@ -38,10 +75,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { } const token = parsed.data.replace(/^Bearer\s+/i, '') - if (!safeCompareStrings(token, configuration.cliApiToken)) { + const parsedToken = parseAccessToken(token) + if (!parsedToken || !safeCompareStrings(parsedToken.baseToken, configuration.cliApiToken)) { return c.json({ error: 'Invalid token' }, 401) } + c.set('namespace', parsedToken.namespace) return await next() }) @@ -56,7 +95,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { return c.json({ error: 'Invalid body' }, 400) } - const session = engine.getOrCreateSession(parsed.data.tag, parsed.data.metadata, parsed.data.agentState ?? null) + const namespace = c.get('namespace') + const session = engine.getOrCreateSession(parsed.data.tag, parsed.data.metadata, parsed.data.agentState ?? null, namespace) return c.json({ session }) }) @@ -66,11 +106,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { return c.json({ error: 'Not ready' }, 503) } const sessionId = c.req.param('id') - const session = engine.getSession(sessionId) - if (!session) { - return c.json({ error: 'Session not found' }, 404) + const namespace = c.get('namespace') + const resolved = resolveSessionForNamespace(engine, sessionId, namespace) + if (!resolved.ok) { + return c.json({ error: resolved.error }, resolved.status) } - return c.json({ session }) + return c.json({ session: resolved.session }) }) app.get('/sessions/:id/messages', (c) => { @@ -79,9 +120,10 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { return c.json({ error: 'Not ready' }, 503) } const sessionId = c.req.param('id') - const session = engine.getSession(sessionId) - if (!session) { - return c.json({ error: 'Session not found' }, 404) + const namespace = c.get('namespace') + const resolved = resolveSessionForNamespace(engine, sessionId, namespace) + if (!resolved.ok) { + return c.json({ error: resolved.error }, resolved.status) } const parsed = getMessagesQuerySchema.safeParse(c.req.query()) @@ -105,7 +147,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { return c.json({ error: 'Invalid body' }, 400) } - const machine = engine.getOrCreateMachine(parsed.data.id, parsed.data.metadata, parsed.data.daemonState ?? null) + const namespace = c.get('namespace') + const existing = engine.getMachine(parsed.data.id) + if (existing && existing.namespace !== namespace) { + return c.json({ error: 'Machine access denied' }, 403) + } + const machine = engine.getOrCreateMachine(parsed.data.id, parsed.data.metadata, parsed.data.daemonState ?? null, namespace) return c.json({ machine }) }) @@ -115,11 +162,12 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { return c.json({ error: 'Not ready' }, 503) } const machineId = c.req.param('id') - const machine = engine.getMachine(machineId) - if (!machine) { - return c.json({ error: 'Machine not found' }, 404) + const namespace = c.get('namespace') + const resolved = resolveMachineForNamespace(engine, machineId, namespace) + if (!resolved.ok) { + return c.json({ error: resolved.error }, resolved.status) } - return c.json({ machine }) + return c.json({ machine: resolved.machine }) }) return app diff --git a/server/src/web/routes/events.ts b/server/src/web/routes/events.ts index cf640dc9..337afd6b 100644 --- a/server/src/web/routes/events.ts +++ b/server/src/web/routes/events.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' import { randomUUID } from 'node:crypto' import type { SSEManager } from '../../sse/sseManager' +import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' function parseOptionalId(value: string | undefined): string | null { @@ -18,7 +19,10 @@ function parseBoolean(value: string | undefined): boolean { return value === 'true' || value === '1' } -export function createEventsRoutes(getSseManager: () => SSEManager | null): Hono { +export function createEventsRoutes( + getSseManager: () => SSEManager | null, + getSyncEngine: () => SyncEngine | null +): Hono { const app = new Hono() app.get('/events', (c) => { @@ -32,10 +36,37 @@ export function createEventsRoutes(getSseManager: () => SSEManager | null): Hono const sessionId = parseOptionalId(query.sessionId) const machineId = parseOptionalId(query.machineId) const subscriptionId = randomUUID() + const namespace = c.get('namespace') + + if (sessionId || machineId) { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not connected' }, 503) + } + if (sessionId) { + const session = engine.getSession(sessionId) + if (!session) { + return c.json({ error: 'Session not found' }, 404) + } + if (session.namespace !== namespace) { + return c.json({ error: 'Session access denied' }, 403) + } + } + if (machineId) { + const machine = engine.getMachine(machineId) + if (!machine) { + return c.json({ error: 'Machine not found' }, 404) + } + if (machine.namespace !== namespace) { + return c.json({ error: 'Machine access denied' }, 403) + } + } + } return streamSSE(c, async (stream) => { manager.subscribe({ id: subscriptionId, + namespace, all, sessionId, machineId, diff --git a/server/src/web/routes/guards.ts b/server/src/web/routes/guards.ts index 4aa7bd13..e6c4c2d6 100644 --- a/server/src/web/routes/guards.ts +++ b/server/src/web/routes/guards.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono' -import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { Machine, Session, SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' export function requireSyncEngine( @@ -19,10 +19,14 @@ export function requireSession( sessionId: string, options?: { requireActive?: boolean } ): Session | Response { + const namespace = c.get('namespace') const session = engine.getSession(sessionId) if (!session) { return c.json({ error: 'Session not found' }, 404) } + if (session.namespace !== namespace) { + return c.json({ error: 'Session access denied' }, 403) + } if (options?.requireActive && !session.active) { return c.json({ error: 'Session is inactive' }, 409) } @@ -43,3 +47,18 @@ export function requireSessionFromParam( return { sessionId, session } } +export function requireMachine( + c: Context, + engine: SyncEngine, + machineId: string +): Machine | Response { + const namespace = c.get('namespace') + const machine = engine.getMachine(machineId) + if (!machine) { + return c.json({ error: 'Machine not found' }, 404) + } + if (machine.namespace !== namespace) { + return c.json({ error: 'Machine access denied' }, 403) + } + return machine +} diff --git a/server/src/web/routes/machines.ts b/server/src/web/routes/machines.ts index 9043d2de..62436c1d 100644 --- a/server/src/web/routes/machines.ts +++ b/server/src/web/routes/machines.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { z } from 'zod' import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' +import { requireMachine } from './guards' const spawnBodySchema = z.object({ directory: z.string().min(1), @@ -24,7 +25,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Not connected' }, 503) } - const machines = engine.getOnlineMachines() + const namespace = c.get('namespace') + const machines = engine.getOnlineMachinesByNamespace(namespace) return c.json({ machines }) }) @@ -35,9 +37,9 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } const machineId = c.req.param('id') - const machine = engine.getMachine(machineId) - if (!machine) { - return c.json({ error: 'Machine not found' }, 404) + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine } const body = await c.req.json().catch(() => null) @@ -64,9 +66,9 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } const machineId = c.req.param('id') - const machine = engine.getMachine(machineId) - if (!machine) { - return c.json({ error: 'Machine not found' }, 404) + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine } const body = await c.req.json().catch(() => null) diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index dbca1f27..24ae1c25 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -78,7 +78,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const getPendingCount = (s: Session) => s.agentState?.requests ? Object.keys(s.agentState.requests).length : 0 - const sessions = engine.getSessions() + const namespace = c.get('namespace') + const sessions = engine.getSessionsByNamespace(namespace) .sort((a, b) => { // Active sessions first if (a.active !== b.active) { diff --git a/server/src/web/server.ts b/server/src/web/server.ts index 309c3902..a135dd81 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -77,7 +77,7 @@ function createWebApp(options: { app.route('/api', createBindRoutes(options.jwtSecret, options.store)) app.use('/api/*', createAuthMiddleware(options.jwtSecret)) - app.route('/api', createEventsRoutes(options.getSseManager)) + app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine)) app.route('/api', createSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) diff --git a/web/README.md b/web/README.md index 0bfe2e08..65ef38e4 100644 --- a/web/README.md +++ b/web/README.md @@ -15,7 +15,7 @@ React Mini App / PWA for monitoring and controlling hapi sessions. ## Runtime behavior - When opened inside Telegram, auth uses Telegram WebApp init data. -- When opened in a normal browser, you can log in with the shared `CLI_API_TOKEN`. +- When opened in a normal browser, you can log in with `CLI_API_TOKEN:` (or `CLI_API_TOKEN` for the default namespace). - The login screen includes a top-right server picker; if unset, the app uses the same origin it was loaded from. - Live updates come from the server via SSE. diff --git a/web/src/components/LoginPrompt.tsx b/web/src/components/LoginPrompt.tsx index 185a5ee4..b49297ae 100644 --- a/web/src/components/LoginPrompt.tsx +++ b/web/src/components/LoginPrompt.tsx @@ -97,7 +97,7 @@ export function LoginPrompt(props: LoginPromptProps) { ? 'Enter your access token to bind this Telegram account' : 'Enter your access token to continue' const submitLabel = isBindMode ? 'Bind' : 'Sign In' - const helpText = 'Use the CLI_API_TOKEN from your server configuration' + const helpText = 'Use CLI_API_TOKEN: from your server configuration (omit : for default)' return (
@@ -177,7 +177,7 @@ export function LoginPrompt(props: LoginPromptProps) { type="password" value={accessToken} onChange={(e) => setAccessToken(e.target.value)} - placeholder={isBindMode ? 'CLI_API_TOKEN' : 'Access Token'} + placeholder={isBindMode ? 'CLI_API_TOKEN:' : 'CLI_API_TOKEN[:namespace]'} autoComplete="current-password" disabled={isLoading} className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent disabled:opacity-50" diff --git a/web/src/types/api.ts b/web/src/types/api.ts index c705332b..d26f2bb5 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -198,9 +198,9 @@ export type SlashCommandsResponse = { } export type SyncEvent = - | { type: 'session-added'; sessionId: string; data?: unknown } - | { type: 'session-updated'; sessionId: string; data?: unknown } - | { type: 'session-removed'; sessionId: string } - | { type: 'message-received'; sessionId: string; message: DecryptedMessage } - | { type: 'machine-updated'; machineId: string; data?: unknown } - | { type: 'connection-changed'; data?: { status: string } } + | { type: 'session-added'; sessionId: string; data?: unknown; namespace?: string } + | { type: 'session-updated'; sessionId: string; data?: unknown; namespace?: string } + | { type: 'session-removed'; sessionId: string; namespace?: string } + | { type: 'message-received'; sessionId: string; message: DecryptedMessage; namespace?: string } + | { type: 'machine-updated'; machineId: string; data?: unknown; namespace?: string } + | { type: 'connection-changed'; data?: { status: string }; namespace?: string }