sync: update from private repo (a360d15)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
44df3a7da1
commit
483464597a
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<SimpleTask | null>;
|
||||
/** 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<SimpleTask | null>;
|
||||
/** 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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user