diff --git a/src/bridge/console-ws-api.test.ts b/src/bridge/console-ws-api.test.ts index 164d1cb..5912ecc 100644 --- a/src/bridge/console-ws-api.test.ts +++ b/src/bridge/console-ws-api.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import Database from 'better-sqlite3'; import express from 'express'; import request from 'supertest'; -import { decideAccess, handleConsoleSocket, createConsoleStatusRouter } from './console-ws-api.js'; +import { decideAccess, handleConsoleSocket, createConsoleStatusRouter, createConsoleSessionRouter } from './console-ws-api.js'; import { runMigrations } from '../db/migrate.js'; import { createAccessResolver } from '../ssh/access.js'; import { createGrantsRepo } from '../ssh/grants-repo.js'; @@ -353,4 +353,57 @@ describe('createConsoleStatusRouter', () => { const res = await request(app).get('/api/local/tasks/t1/console/status'); expect(res.status).toBe(401); }); + + it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => { + let seenUser: any; + const app = express(); + app.use('/api', createConsoleStatusRouter({ + registry: { get: () => null } as any, + requireAuth: (_req: any, _res: any, next: any) => next(), + resolveTask: async (_id: string, user: any) => { seenUser = user; return null; }, + authActive: false, + })); + const res = await request(app).get('/api/local/tasks/t1/console/status'); + // 200 active=false (task not visible shape), NOT 401 — the synthetic user + // cleared the auth guard. admin role makes null-owner no-auth tasks visible. + expect(res.status).toBe(200); + expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] }); + }); +}); + +describe('createConsoleSessionRouter', () => { + it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => { + let seenUser: any; + const app = express(); + app.use(express.json()); + app.use('/api', createConsoleSessionRouter({ + sub: {} as any, + preflight: {} as any, + requireAuth: (_req: any, _res: any, next: any) => next(), + resolveTask: async (_id: string, user: any) => { seenUser = user; return null; }, + authActive: false, + })); + const res = await request(app) + .post('/api/local/tasks/t1/console/session') + .send({ connection_id: 'c1' }); + // 404 task_not_found (resolveTask returned null), NOT 401 — proves the + // synthetic user passed the auth guard before task resolution. + expect(res.status).toBe(404); + expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] }); + }); + + it('still returns 401 when auth is active and there is no user', async () => { + const app = express(); + app.use(express.json()); + app.use('/api', createConsoleSessionRouter({ + sub: {} as any, + preflight: {} as any, + requireAuth: (_req: any, _res: any, next: any) => next(), + resolveTask: async () => null, + })); + const res = await request(app) + .post('/api/local/tasks/t1/console/session') + .send({ connection_id: 'c1' }); + expect(res.status).toBe(401); + }); }); diff --git a/src/bridge/console-ws-api.ts b/src/bridge/console-ws-api.ts index 0fe39a8..3f886e1 100644 --- a/src/bridge/console-ws-api.ts +++ b/src/bridge/console-ws-api.ts @@ -13,6 +13,20 @@ import { openConsoleSession } from '../engine/tools/ssh-console.js'; export interface SimpleUser { id: string; role: 'admin' | 'user' | string } export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string } +/** + * Resolve the request user for the Console REST endpoints. In no-auth + * single-user mode (`authActive === false`) there is no `req.user`, so a + * stable `local` admin user is synthesized — admin role is required for the + * no-auth task (owner_id NULL) to pass `buildVisibilityWhere` in resolveTask. + * Mirrors the WS upgrade path and notifications-api's no-auth synthetic user. + */ +function resolveConsoleUser(req: Request, authActive: boolean): SimpleUser | null { + const u = (req.user as SimpleUser | undefined) ?? null; + if (u) return u; + if (!authActive) return { id: 'local', role: 'admin', orgIds: [] } as SimpleUser & { orgIds: string[] }; + return null; +} + export type AccessDecision = | { allowed: true; canWrite: boolean } | { allowed: false; reason: 'unauthenticated' | 'task_not_visible' | 'no_session' | 'no_grant' }; @@ -245,6 +259,10 @@ export function createConsoleStatusRouter(deps: { registry: SessionRegistry; requireAuth: any; resolveTask: (taskId: string, user: SimpleUser) => Promise; + /** No-auth single-user mode: synthesize a local admin user so the Console + * status poll works without login (admin role makes null-owner no-auth + * tasks visible via buildVisibilityWhere). Defaults to true. */ + authActive?: boolean; }): Router { const r = Router(); r.get( @@ -252,7 +270,7 @@ export function createConsoleStatusRouter(deps: { deps.requireAuth, async (req: Request, res: Response) => { const taskId = req.params.taskId!; - const user = (req.user as SimpleUser | undefined) ?? null; + const user = resolveConsoleUser(req, deps.authActive ?? true); if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; @@ -366,6 +384,9 @@ export function createConsoleSessionRouter(deps: { preflight: OpenConsoleDeps['preflight']; requireAuth: any; resolveTask: (taskId: string, user: SimpleUser) => Promise; + /** No-auth single-user mode: synthesize a local admin user so users can + * open Console sessions without login. Defaults to true. */ + authActive?: boolean; }): Router { const r = Router(); r.post( @@ -373,7 +394,7 @@ export function createConsoleSessionRouter(deps: { deps.requireAuth, async (req: Request, res: Response) => { const taskId = req.params.taskId!; - const user = (req.user as SimpleUser | undefined) ?? null; + const user = resolveConsoleUser(req, deps.authActive ?? true); if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; diff --git a/src/bridge/local-tasks-api.test.ts b/src/bridge/local-tasks-api.test.ts index 1186afa..eec007e 100644 --- a/src/bridge/local-tasks-api.test.ts +++ b/src/bridge/local-tasks-api.test.ts @@ -97,6 +97,48 @@ describe('POST /api/local/tasks with visibility', () => { }); }); +describe('POST /api/local/tasks in no-auth mode (synthetic local owner)', () => { + let tempDir = ''; + let repo: Repository; + let app: express.Application; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'lt-api-noauth-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + app = express(); + app.use(express.json()); + // No user middleware: simulates a no-auth deployment where req.user is + // undefined. mountLocalTasksApi is told auth is off so it registers the + // task under the stable 'local' owner instead of NULL. + mountLocalTasksApi(app, { + repo, + worktreeDir: join(tempDir, 'workspaces'), + authActive: false, + }); + }); + + afterEach(() => { + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('registers the task and its spawned job under owner "local" (not NULL)', async () => { + const res = await request(app).post('/api/local/tasks').send({ + body: 'hello no-auth', + piece: 'chat', + }); + expect(res.status).toBe(201); + expect(res.body.task.ownerId).toBe('local'); + + // The spawned job must inherit 'local' so per-task reflection / visibility + // lookups (which key on jobs.owner_id) line up in no-auth. + const row = repo.getDb() + .prepare(`SELECT owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`) + .get(localTaskRepoName(res.body.task.id)) as { owner_id: string | null } | undefined; + expect(row?.owner_id).toBe('local'); + }); +}); + describe('DELETE /api/local/tasks/:id owner-or-admin', () => { let tempDir = ''; let repo: Repository; diff --git a/src/bridge/local-tasks-api.ts b/src/bridge/local-tasks-api.ts index b72ee2b..f2af72a 100644 --- a/src/bridge/local-tasks-api.ts +++ b/src/bridge/local-tasks-api.ts @@ -36,10 +36,23 @@ export interface LocalTasksApiOptions { * without a server restart. Clamped to [1, 1000] MB. Default: 50. */ getMaxUploadMb?: () => number; + /** + * Whether the auth subsystem is wired. When `false` (no-auth single-user + * deployment) requests carry no `req.user`, so new tasks/jobs are registered + * under the stable `local` owner instead of NULL — keeping per-task + * reflection / visibility lookups (which key on owner_id) consistent with the + * rest of the no-auth path. Defaults to `true` (owner stays req.user.id). + */ + authActive?: boolean; } export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void { const { repo, worktreeDir, sessRepo } = opts; + // No-auth single-user mode: register new tasks/jobs under the 'local' owner + // (the same namespace pieces/memory/reflection use) rather than letting the + // missing req.user fall through to a NULL owner. In auth mode this is + // undefined, so `req.user.id ?? noAuthOwner` keeps the real owner. + const noAuthOwner: string | undefined = (opts.authActive ?? true) ? undefined : 'local'; const resolveUploadLimit = (): string => { const raw = opts.getMaxUploadMb?.() ?? 50; @@ -161,7 +174,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) outputFormat, askPolicy, priority, - ownerId: req.user?.id, + ownerId: req.user?.id ?? noAuthOwner, visibility, visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null, browserSessionProfileId, diff --git a/src/bridge/reflection-api.test.ts b/src/bridge/reflection-api.test.ts index 5eff613..db080b9 100644 --- a/src/bridge/reflection-api.test.ts +++ b/src/bridge/reflection-api.test.ts @@ -379,6 +379,42 @@ describe('reflection-api', () => { expect(res.body.pieceEdited).toBe(true); }); + it('no-auth: returns the snapshot for a NULL-owner task job via the synthetic local user', async () => { + // Reproduces the real no-auth deployment: task jobs are created with + // owner_id = NULL (req.user is undefined), while reflection snapshots are + // written under the 'local' namespace (reflectionOwner = job.ownerId ?? + // 'local'). The badge query filtered `owner_id = 'local'`, which never + // matches a NULL owner, so the 🧠 badge stayed silently empty even though + // reflection ran and wrote to data/users/local/.reflection-history/. + const db = repo.getDb(); + const now = new Date().toISOString(); + db.prepare(` + INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, + instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('j-task9-001', 'local/task-9', 1, 'succeeded', 'chat', 'default', 'auto', + 'No-auth task', 1, 1, 0, 0, 'agent', now, now, null); + + const deps = { dataDir: tmpDir }; + const { snapshotId } = await writeSnapshot( + deps, + { 'pref.md': 'before' }, + { 'pref.md': 'after' }, + makeMeta({ originalJobId: 'j-task9-001', userId: 'local', memoryChanges: 2, pieceEdited: false }), + undefined, + undefined, + new Date('2026-05-11T16:00:00Z'), + ); + + // No x-test-user-id header → the auth gate injects the synthetic 'local' user. + const res = await request(app).get('/api/local/reflection/latest-for-task/9'); + + expect(res.status).toBe(200); + expect(res.body).not.toBeNull(); + expect(res.body.snapshotId).toBe(snapshotId); + expect(res.body.memoryChanges).toBe(2); + }); + it('returns null when the task job is owned by another user', async () => { // Insert a job owned by OTHER_ID for task 8 const db = repo.getDb(); diff --git a/src/bridge/reflection-api.ts b/src/bridge/reflection-api.ts index 5eb2c08..64e98ed 100644 --- a/src/bridge/reflection-api.ts +++ b/src/bridge/reflection-api.ts @@ -265,6 +265,19 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router { rows = db .prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`) .all(repoName) as JobRow[]; + } else if (!authActive) { + // No-auth single-user mode: task jobs are created with owner_id = NULL + // (req.user is undefined), but the synthetic user id is 'local' and the + // reflection writer stores snapshots under 'local' (reflectionOwner = + // job.ownerId ?? 'local'). Match NULL-owner jobs so the badge links them + // — without this the `owner_id = 'local'` filter never matched the NULL + // owner and the 🧠 badge stayed silently empty. Safe: no-auth has exactly + // one user, and listSnapshots below already reads the 'local' namespace. + rows = db + .prepare( + `SELECT id, owner_id FROM jobs WHERE repo = ? AND (owner_id = ? OR owner_id IS NULL) ORDER BY created_at DESC`, + ) + .all(repoName, u.id) as JobRow[]; } else { rows = db .prepare( diff --git a/src/bridge/scheduled-tasks-api.test.ts b/src/bridge/scheduled-tasks-api.test.ts index ce6bcc2..38279fa 100644 --- a/src/bridge/scheduled-tasks-api.test.ts +++ b/src/bridge/scheduled-tasks-api.test.ts @@ -108,6 +108,40 @@ describe('POST /api/scheduled-tasks with visibility', () => { }); }); +describe('POST /api/scheduled-tasks in no-auth mode (synthetic local owner)', () => { + let nTempDir = ''; + let nRepo: Repository; + let nApp: express.Application; + + beforeEach(() => { + nTempDir = mkdtempSync(join(tmpdir(), 'sched-noauth-api-')); + nRepo = new Repository(join(nTempDir, 'db.sqlite')); + const nScheduler = new Scheduler(nRepo, join(nTempDir, 'workspaces')); + nApp = express(); + nApp.use(express.json()); + // No user middleware: no-auth deployment (req.user undefined). authActive:false + // tells the router to register the scheduled task under the 'local' owner so + // the jobs the scheduler later spawns inherit it (not NULL). + mountScheduledTasksApi(nApp, nRepo, nScheduler, { authActive: false }); + }); + + afterEach(() => { + nRepo.close(); + rmSync(nTempDir, { recursive: true, force: true }); + }); + + it('registers the scheduled task under owner "local" instead of NULL', async () => { + const res = await request(nApp).post('/api/scheduled-tasks').send({ + body: 'nightly thing', + scheduleType: 'daily', + hour: 9, + minute: 0, + }); + expect(res.status).toBe(201); + expect(res.body.task.ownerId).toBe('local'); + }); +}); + describe('POST /api/scheduled-tasks', () => { it('should create a daily schedule', async () => { const res = await request(app) diff --git a/src/bridge/scheduled-tasks-api.ts b/src/bridge/scheduled-tasks-api.ts index a5dd096..cf69298 100644 --- a/src/bridge/scheduled-tasks-api.ts +++ b/src/bridge/scheduled-tasks-api.ts @@ -11,6 +11,14 @@ export interface ScheduledTasksApiOptions { * field is silently dropped (legacy / no-auth deployments). */ sessRepo?: BrowserSessionRepo; + /** + * Whether the auth subsystem is wired. When `false` (no-auth single-user + * deployment) requests carry no `req.user`, so new scheduled tasks (and the + * jobs the scheduler spawns from them) are owned by the stable `local` user + * instead of NULL — keeping per-task reflection / visibility consistent with + * the rest of the no-auth path. Defaults to `true`. + */ + authActive?: boolean; } export function mountScheduledTasksApi( @@ -20,6 +28,7 @@ export function mountScheduledTasksApi( apiOpts: ScheduledTasksApiOptions = {}, ): void { const { sessRepo } = apiOpts; + const noAuthOwner: string | null = (apiOpts.authActive ?? true) ? null : 'local'; /** * Validate and resolve a browserSessionProfileId from a request body. @@ -133,7 +142,7 @@ export function mountScheduledTasksApi( return; } } - const ownerId = (req.user as Express.User | undefined)?.id ?? null; + const ownerId = (req.user as Express.User | undefined)?.id ?? noAuthOwner; const profileBinding = resolveBrowserSessionProfileId( req.body?.browserSessionProfileId, diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 51769bc..7b71884 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -585,6 +585,7 @@ export function createCoreServer(opts: CoreServerOptions): { const sshDeps: SshApiDeps = { db: repo.getDb(), + authActive, requireAuth: authActive ? requireAuth : (_req, _res, next) => next(), requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(), getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, @@ -712,9 +713,11 @@ export function createCoreServer(opts: CoreServerOptions): { registry: sessionRegistry, resolveUserFromUpgrade: async (req) => { if (!authenticateUpgrade) { - // Auth disabled: no user available — reject WS attaches. - // (In auth-off mode the WS layer simply isn't usable.) - return null; + // No-auth single-user mode: synthesize a stable `local` admin + // user so the Console terminal WS attaches (admin role makes + // the null-owner no-auth task visible in resolveTask). Mirrors + // the Console REST routers and notifications-api. + return { id: 'local', role: 'admin' }; } const u = await authenticateUpgrade(req); return u ? { id: u.id, role: u.role } : null; @@ -778,6 +781,7 @@ export function createCoreServer(opts: CoreServerOptions): { '/api', createConsoleStatusRouter({ registry: sessionRegistry, + authActive, requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(), resolveTask: consoleDeps.resolveTask, }), @@ -793,6 +797,7 @@ export function createCoreServer(opts: CoreServerOptions): { createConsoleSessionRouter({ sub: sshSubsystem, preflight: sshPreflight, + authActive, requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(), resolveTask: consoleDeps.resolveTask, }), @@ -822,6 +827,7 @@ export function createCoreServer(opts: CoreServerOptions): { mountLocalTasksApi(app, { repo, worktreeDir, + authActive, generateTitle: opts.generateTitle, selectPiece: opts.selectPiece, pieceExists: opts.piecesDir @@ -936,7 +942,7 @@ export function createCoreServer(opts: CoreServerOptions): { if (opts.scheduler) { app.use('/api/scheduled-tasks', express.json()); - mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo }); + mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo, authActive }); } // Browser session API diff --git a/src/bridge/ssh-api.test.ts b/src/bridge/ssh-api.test.ts index 5cc9be5..44ddedd 100644 --- a/src/bridge/ssh-api.test.ts +++ b/src/bridge/ssh-api.test.ts @@ -65,6 +65,10 @@ function makeHarness(opts: { tester?: SshTester; forceUnlockLimit?: { windowMs: number; maxRequests: number }; onAccessRevoked?: SshApiDeps['onAccessRevoked']; + /** No-auth single-user mode: requireAuth/Admin become passthroughs and the + * user resolvers read req.user, mirroring how server.ts wires SshApiDeps + * when the auth subsystem is disabled. */ + authActive?: boolean; } = {}): Harness { const db = makeDb(); const connectionRepo = createConnectionRepo(db); @@ -78,14 +82,15 @@ function makeHarness(opts: { const userId = opts.userId ?? 'alice'; const isAdmin = !!opts.isAdmin; const isAnon = !!opts.isAnon; + const authActive = opts.authActive ?? true; const requireAuth: express.RequestHandler = (_req, res, next) => { - if (isAnon) { res.status(401).json({ error: 'unauthorized' }); return; } + if (authActive && isAnon) { res.status(401).json({ error: 'unauthorized' }); return; } next(); }; const requireAdmin: express.RequestHandler = (_req, res, next) => { - if (isAnon) { res.status(401).json({ error: 'unauthorized' }); return; } - if (!isAdmin) { res.status(403).json({ error: 'admin_required' }); return; } + if (authActive && isAnon) { res.status(401).json({ error: 'unauthorized' }); return; } + if (authActive && !isAdmin) { res.status(403).json({ error: 'admin_required' }); return; } next(); }; @@ -118,9 +123,13 @@ function makeHarness(opts: { db, requireAuth, requireAdmin, - getUserId: () => (isAnon ? null : userId), - isAdmin: () => isAdmin, - getOrgIds: () => opts.orgIds ?? [], + authActive, + getUserId: (req) => + authActive ? (isAnon ? null : userId) : ((req.user as { id?: string } | undefined)?.id ?? null), + isAdmin: (req) => + authActive ? isAdmin : ((req.user as { role?: string } | undefined)?.role === 'admin'), + getOrgIds: (req) => + authActive ? (opts.orgIds ?? []) : ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []), connectionRepo, grantsRepo, auditRepo, @@ -199,6 +208,55 @@ describe('SSH API: auth gating', () => { }); }); +// ────────────────────────────────────────────────────────────────────── +// No-auth single-user mode: SSH must work without a logged-in user. The +// router synthesizes a 'local' owner so per-user connection storage has a +// stable key (mirrors notes-api / memory-api / user-folder-api). +// ────────────────────────────────────────────────────────────────────── + +describe('SSH API: no-auth local fallback (authActive=false)', () => { + it('POST /api/ssh/connections succeeds without auth and owns the connection as local', async () => { + const h = makeHarness({ authActive: false }); + const res = await request(h.app).post('/api/ssh/connections').send({ + label: 'prod', + host: 'srv.example.com', + port: 22, + username: 'deploy', + privateKeyPem: SAMPLE_PEM, + remotePathPrefix: '/home/deploy', + }); + expect(res.status).toBe(201); + expect(res.body.connection.ownerId).toBe('local'); + }); + + it('GET /api/ssh/connections returns 200 (not 401) without auth and lists the local connection', async () => { + const h = makeHarness({ authActive: false }); + const id = await createOwnedConnection(h); + const res = await request(h.app).get('/api/ssh/connections'); + expect(res.status).toBe(200); + expect(res.body.connections.find((c: { id: string }) => c.id === id)).toBeTruthy(); + }); + + it('admin globals endpoint is reachable without auth in no-auth mode', async () => { + const h = makeHarness({ authActive: false }); + const res = await request(h.app) + .post('/api/ssh/admin/globals') + .send({ + reason: 'no-auth admin reachability', + label: 'shared', + host: 'shared.example.com', + port: 22, + username: 'ops', + privateKeyPem: SAMPLE_PEM, + remotePathPrefix: '/srv', + }); + // Not 401/403 — the synthetic local user passes the admin gate. The exact + // 2xx/4xx depends on payload validation, which other tests cover. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); +}); + describe('SSH API: maintenance mode', () => { it('blocks user POST /connections with 503 + Retry-After', async () => { const h = makeHarness(); diff --git a/src/bridge/ssh-api.ts b/src/bridge/ssh-api.ts index 64544a8..c12dfec 100644 --- a/src/bridge/ssh-api.ts +++ b/src/bridge/ssh-api.ts @@ -25,7 +25,7 @@ * the pending host-key token (token only returned on test / observation). */ -import { Router, type Request, type Response, type RequestHandler } from 'express'; +import { Router, type Request, type Response, type NextFunction, type RequestHandler } from 'express'; import type Database from 'better-sqlite3'; import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js'; @@ -78,6 +78,14 @@ export interface SshApiDeps { getUserId(req: Request): string | null; isAdmin(req: Request): boolean; getOrgIds(req: Request): string[]; + /** + * Whether the bridge wired the auth subsystem. When `false` (no-auth + * single-user deployment) requests carry no `req.user`, so the router + * synthesizes a stable `local` owner — otherwise every endpoint 401s on + * the null userId and SSH is unusable. Mirrors notes-api / memory-api / + * user-folder-api. Defaults to `true` for existing (auth-only) callers. + */ + authActive?: boolean; connectionRepo: SshConnectionRepo; grantsRepo: SshGrantsRepo; @@ -279,9 +287,27 @@ function jsonError(res: Response, status: number, error: string, detail?: unknow // User router — /api/ssh/connections + /api/ssh/grants // ────────────────────────────────────────────────────────────────────── +/** + * No-auth single-user mode: synthesize a `local` user so the per-user + * connection store has a stable owner and `getUserId`/`isAdmin` resolve. + * Role `admin` keeps the connection-management surface fully usable for the + * lone local operator (in single-user mode the admin/grant checks only ever + * compare against `local`-owned rows, so this never widens cross-user access). + * No-op when auth is active (Passport has already populated `req.user`). + */ +function makeSshLocalUserMiddleware(authActive: boolean): RequestHandler { + return (req: Request, _res: Response, next: NextFunction) => { + if (!authActive && !(req as { user?: unknown }).user) { + (req as { user?: unknown }).user = { id: 'local', role: 'admin', orgIds: [] }; + } + next(); + }; +} + export function createSshUserRouter(deps: SshApiDeps): Router { const router = Router(); const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000; + router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); // GET /api/ssh/connections — list owned (and globals visible via grant). // For Phase 5 we return: @@ -801,6 +827,7 @@ export function createSshUserRouter(deps: SshApiDeps): Router { export function createSshAdminRouter(deps: SshApiDeps): Router { const router = Router(); + router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); // GET /api/ssh/admin/connections router.get('/connections', deps.requireAdmin, (_req, res) => {