feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { mountAdminApi } from './admin-api.js';
import { unlinkSync } from 'fs';
describe('Admin API', () => {
let app: express.Application;
let repo: Repository;
const dbPath = './_test_admin_api.db';
beforeEach(() => {
repo = new Repository(dbPath);
runMigrations(repo.getDb());
app = express();
app.use(express.json());
// Mock admin auth
app.use((req, _res, next) => {
(req as any).user = { id: 'admin-1', role: 'admin', status: 'active' };
(req as any).isAuthenticated = () => true;
next();
});
mountAdminApi(app, repo);
});
afterEach(() => {
repo.close();
try { unlinkSync(dbPath); } catch { /* ignore */ }
});
it('GET /api/admin/users returns user list', async () => {
repo.createUser({ email: '[email protected]', name: 'A', role: 'user', status: 'active' });
const res = await request(app).get('/api/admin/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].email).toBe('[email protected]');
});
it('GET /api/admin/users returns empty orgs array for user with no Gitea orgs', async () => {
repo.createUser({ email: '[email protected]', name: 'NoOrgs', role: 'user', status: 'active' });
const res = await request(app).get('/api/admin/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].orgs).toEqual([]);
});
it('GET /api/admin/users returns populated orgs for user with Gitea orgs', async () => {
const user = repo.createUser({ email: '[email protected]', name: 'WithOrgs', role: 'user', status: 'active' });
repo.replaceUserGiteaOrgs(user.id, [
{ orgId: '10', orgName: 'acme' },
{ orgId: '11', orgName: 'beta' },
]);
const res = await request(app).get('/api/admin/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].orgs).toHaveLength(2);
const names = res.body[0].orgs.map((o: { orgName: string }) => o.orgName).sort();
expect(names).toEqual(['acme', 'beta']);
expect(res.body[0].orgs[0]).toHaveProperty('fetchedAt');
});
it('PATCH /api/admin/users/:id updates user', async () => {
const user = repo.createUser({ email: '[email protected]', name: 'B', role: 'user', status: 'pending' });
const res = await request(app)
.patch(`/api/admin/users/${user.id}`)
.send({ status: 'active' });
expect(res.status).toBe(200);
expect(res.body.status).toBe('active');
});
it('DELETE /api/admin/users/:id deletes user', async () => {
const user = repo.createUser({ email: '[email protected]', name: 'C', role: 'user', status: 'active' });
const res = await request(app).delete(`/api/admin/users/${user.id}`);
expect(res.status).toBe(204);
expect(repo.getUserById(user.id)).toBeNull();
});
it('PATCH /api/admin/users/:id returns 404 for unknown user', async () => {
const res = await request(app)
.patch('/api/admin/users/nonexistent')
.send({ status: 'active' });
expect(res.status).toBe(404);
});
it('DELETE /api/admin/users/:id returns 404 for unknown user', async () => {
const res = await request(app).delete('/api/admin/users/nonexistent');
expect(res.status).toBe(404);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { Repository } from '../db/repository.js';
import { requireAdmin } from './auth.js';
const passthrough: RequestHandler = (_req, _res, next) => next();
export function mountAdminApi(app: Application, repo: Repository, authActive = true): void {
const guard = authActive ? requireAdmin : passthrough;
app.get('/api/admin/users', guard, (_req: Request, res: Response) => {
const users = authActive ? repo.listUsers() : [];
const enriched = users.map(u => ({
...u,
orgs: repo.listUserGiteaOrgs(u.id),
}));
res.json(enriched);
});
app.patch('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
const { status, role } = req.body;
const user = repo.getUserById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
repo.updateUser(id, { status, role });
// Invalidate sessions on status/role change
if (status === 'disabled' || status === 'pending' || role) {
repo.deleteSessionsByUserId(id);
}
const updated = repo.getUserById(id);
res.json(updated);
});
app.delete('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
const user = repo.getUserById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
repo.deleteSessionsByUserId(id);
repo.deleteUser(id);
res.status(204).end();
});
}
@@ -0,0 +1,402 @@
/**
* Phase 2b — admin REST API integration tests for budget / rate / usage.
*
* Covers:
* - POST accepts tokensBudget + rateLimitRpm in the body
* - POST rejects malformed budget (NaN, negative, string)
* - PATCH updates fields independently (tokensBudget, rateLimitRpm,
* allowedModels) and supports null reset
* - PATCH rejects config-import keys with 400
* - GET /:id/usage returns currentPeriod stats + history
* - GET /:id/usage reports zero counters when there's no usage row
* - GET /:id/usage reports remaining=0 when over budget
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express, { type Request, type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
import { currentPeriodYearMonth } from '../gateway/period.js';
import { hashKey } from '../gateway/key-format.js';
function buildAdminApp(repo: Repository): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
const guard: RequestHandler = (req, _res, next) => {
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
});
app.use('/api/admin/gateway/keys', router);
return app;
}
describe('admin-gateway-api Phase 2b', () => {
let tmpDir: string;
let repo: Repository;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-2b-'));
repo = new Repository(join(tmpDir, 'test.db'));
app = buildAdminApp(repo);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
describe('POST /', () => {
it('accepts tokensBudget + rateLimitRpm and round-trips them on GET', async () => {
const created = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1_000_000,
rateLimitRpm: 60,
});
expect(created.status).toBe(201);
expect(created.body.tokensBudget).toBe(1_000_000);
expect(created.body.rateLimitRpm).toBe(60);
expect(typeof created.body.key).toBe('string');
const get = await request(app).get(`/api/admin/gateway/keys/${created.body.id}`);
expect(get.status).toBe(200);
expect(get.body.tokensBudget).toBe(1_000_000);
expect(get.body.rateLimitRpm).toBe(60);
expect(get.body.key).toBeUndefined();
});
it('defaults to null when budget/rate omitted', async () => {
const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' });
expect(created.body.tokensBudget).toBeNull();
expect(created.body.rateLimitRpm).toBeNull();
});
it.each([
['negative budget', { team: 'alpha', tokensBudget: -1 }],
['zero budget', { team: 'alpha', tokensBudget: 0 }],
['string budget', { team: 'alpha', tokensBudget: 'lots' }],
['negative rpm', { team: 'alpha', rateLimitRpm: -5 }],
['zero rpm', { team: 'alpha', rateLimitRpm: 0 }],
])('rejects %s with 400', async (_name, body) => {
const res = await request(app).post('/api/admin/gateway/keys').send(body);
expect(res.status).toBe(400);
});
// F7: hard caps to keep INTEGER arithmetic exact in SQLite.
it('rejects tokensBudget above 1e12 with 400 (F7: INT overflow guard)', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1e20,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/tokensBudget exceeds maximum/);
});
it('rejects tokensBudget == max+1 with 400', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1_000_000_000_001,
});
expect(res.status).toBe(400);
});
it('accepts tokensBudget == max with 201', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1_000_000_000_000,
});
expect(res.status).toBe(201);
expect(res.body.tokensBudget).toBe(1_000_000_000_000);
});
it('floors fractional tokensBudget to an integer (1.7 → 1)', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1.7,
});
expect(res.status).toBe(201);
expect(res.body.tokensBudget).toBe(1);
});
it('rejects rateLimitRpm above 1e6 with 400', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
rateLimitRpm: 1_000_001,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/rateLimitRpm exceeds maximum/);
});
it('accepts rateLimitRpm at the cap', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
rateLimitRpm: 1_000_000,
});
expect(res.status).toBe(201);
expect(res.body.rateLimitRpm).toBe(1_000_000);
});
});
describe('PATCH /:id', () => {
async function seedAdminKey(): Promise<string> {
const created = await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
tokensBudget: 1000,
rateLimitRpm: 30,
allowedModels: ['qwen3:8b'],
});
return created.body.id as string;
}
it('updates tokensBudget only, leaving others alone', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 5000 });
expect(patched.status).toBe(200);
expect(patched.body.tokensBudget).toBe(5000);
expect(patched.body.rateLimitRpm).toBe(30);
expect(patched.body.allowedModels).toEqual(['qwen3:8b']);
});
it('updates allowedModels alone', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ allowedModels: ['qwen3:14b'] });
expect(patched.body.allowedModels).toEqual(['qwen3:14b']);
});
it('explicit null clears tokensBudget back to unlimited', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: null });
expect(patched.body.tokensBudget).toBeNull();
});
it('explicit null clears allowedModels', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ allowedModels: null });
expect(patched.body.allowedModels).toBeNull();
});
it('rejects an empty body with 400', async () => {
const id = await seedAdminKey();
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({});
expect(res.status).toBe(400);
});
it('returns 404 for unknown id', async () => {
const res = await request(app).patch(`/api/admin/gateway/keys/nope`).send({ tokensBudget: 1 });
expect(res.status).toBe(404);
});
it('PATCH rejects tokensBudget above 1e12 with 400 (F7)', async () => {
const id = await seedAdminKey();
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 1e20 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/tokensBudget exceeds maximum/);
});
it('rejects PATCH on revoked key with 409 (F6: consistency with rotate)', async () => {
const id = await seedAdminKey();
// Revoke first.
const revokeRes = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`).send({});
expect(revokeRes.status).toBe(200);
// Now PATCH must fail with 409.
const patchRes = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 9999 });
expect(patchRes.status).toBe(409);
expect(patchRes.body.error).toMatch(/revoked/);
expect(patchRes.body.revokedAt).toBeTruthy();
// Original value still intact (PATCH was rejected before write).
const get = await request(app).get(`/api/admin/gateway/keys/${id}`);
expect(get.body.tokensBudget).toBe(1000);
});
it('still allows PATCH on an active key (regression guard for F6)', async () => {
const id = await seedAdminKey();
const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 7777 });
expect(patched.status).toBe(200);
expect(patched.body.tokensBudget).toBe(7777);
});
it('PATCH and concurrent revoke are atomic — revoked-mid-patch returns 409 (TOCTOU guard)', async () => {
// Phase 3a follow-up regression guard. Pre-fix the read (revoked
// check) and the UPDATE lived outside any transaction, so this
// sequence:
// 1. PATCH handler reads row → not revoked
// 2. concurrent admin revokes
// 3. PATCH handler UPDATEs the now-revoked row
// left a ghost mutation: the row's policy fields changed AFTER
// the revoke timestamp, but the row could no longer authenticate.
//
// Post-fix the read + update both run inside a single
// better-sqlite3 transaction. To prove the read-time guard fires
// we stub findGatewayVirtualKeyById to return a revoked row on
// the first call (the one made inside the PATCH txn) — this
// simulates what the txn's read would observe if a concurrent
// revoke had committed just before the txn started.
//
// We can't reproduce a true concurrent-write race via repo.revoke
// from inside the txn because that write would be rolled back
// along with everything else when the txn aborts; mocking the
// observed read isolates the handler's behavior cleanly.
const id = await seedAdminKey();
const original = repo.findGatewayVirtualKeyById.bind(repo);
let raced = false;
const spy = vi.spyOn(repo, 'findGatewayVirtualKeyById').mockImplementation((targetId: string) => {
const out = original(targetId);
if (!raced && targetId === id && out && out.revokedAt === null) {
raced = true;
// Simulate a concurrent revoke that committed just before the
// PATCH txn read. Returning a synthesized revoked snapshot
// forces the handler down the 409 path.
return { ...out, revokedAt: '2026-05-19T00:00:00.000Z', revokedBy: 'racing-admin' };
}
return out;
});
try {
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 7777 });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/revoked/);
expect(raced).toBe(true);
} finally {
spy.mockRestore();
}
// Crucially: the budget did NOT change to 7777 — the transaction
// aborted before the UPDATE. Pre-fix this would be 7777 because
// the validation lived outside the write path. After restoring
// the spy we read the row directly: budget unchanged, NOT
// actually revoked (the spy was synthesizing the revoked state).
const fresh = repo.findGatewayVirtualKeyById(id);
expect(fresh?.tokensBudget).toBe(1000);
});
it('true cross-connection race: revoke from a sibling Repository before PATCH still 409s (atomicity smoke)', async () => {
// Belt-and-suspenders for the TOCTOU fix: open a second Repository
// pointing at the SAME DB file, revoke through it, and confirm
// the original handler's PATCH txn sees the revoke and 409s. This
// exercises the actual sqlite isolation semantics (better-sqlite3
// uses the file lock per-connection so writes are visible to other
// connections at txn-commit boundaries).
const id = await seedAdminKey();
// Reach into the test fixture's tmpDir via the repo's db path.
// The Repository keeps the path internally but not on the public
// API; we rely on the well-known location from the test fixture.
const dbPath = (repo.getDb() as unknown as { name?: string }).name;
if (!dbPath || typeof dbPath !== 'string') {
// Should not happen with better-sqlite3 — guard so the test
// surfaces a clear failure rather than a cryptic crash.
throw new Error('test setup: could not read repo db path');
}
const sibling = new Repository(dbPath);
try {
// Concurrent revoke commits BEFORE the PATCH txn starts.
expect(sibling.revokeGatewayVirtualKey(id, 'sibling-admin')).toBe(true);
// PATCH must observe the revoked state inside its own txn
// (better-sqlite3 reads via the journal so the revoke is
// visible) and return 409. Pre-fix this depended on the same
// observation but the validation was outside the write path,
// so a revoke landing AFTER the read but BEFORE the UPDATE
// would slip through. Post-fix the atomic txn guarantees no
// such window exists.
const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 1234 });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/revoked/);
const fresh = repo.findGatewayVirtualKeyById(id);
expect(fresh?.tokensBudget).toBe(1000);
expect(fresh?.revokedAt).not.toBeNull();
} finally {
sibling.close();
}
});
it('rejects PATCH on config-import row with 400', async () => {
// Seed a config-import row directly via the Repository — admin
// POST always uses 'admin' source so this is the only way to
// exercise the guard.
const created = repo.createGatewayVirtualKey({
keyHash: hashKey('config-key-raw'),
keyPrefix: 'config-key-r',
team: 'imported',
source: 'config-import',
});
const res = await request(app).patch(`/api/admin/gateway/keys/${created.id}`).send({ tokensBudget: 9999 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/config-import/);
});
});
describe('GET /:id/usage', () => {
async function seedAdminKeyWithBudget(budget = 1000): Promise<string> {
const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha', tokensBudget: budget });
return created.body.id as string;
}
it('returns zero counters when no usage row exists yet', async () => {
const id = await seedAdminKeyWithBudget();
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
expect(res.status).toBe(200);
expect(res.body.keyId).toBe(id);
expect(res.body.currentPeriod).toBe(currentPeriodYearMonth());
expect(res.body.tokensIn).toBe(0);
expect(res.body.tokensOut).toBe(0);
expect(res.body.tokensTotal).toBe(0);
expect(res.body.tokensBudget).toBe(1000);
expect(res.body.remaining).toBe(1000);
expect(res.body.requestsThisMonth).toBe(0);
expect(res.body.history).toEqual([]);
// F9: rateRecentRequests was dead (always null, UI never read it)
// and is dropped from the wire shape entirely.
expect(res.body).not.toHaveProperty('rateRecentRequests');
});
it('reports current-period totals and remaining headroom', async () => {
const id = await seedAdminKeyWithBudget(1000);
repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 200, tokensOut: 300, requests: 5 });
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
expect(res.body.tokensIn).toBe(200);
expect(res.body.tokensOut).toBe(300);
expect(res.body.tokensTotal).toBe(500);
expect(res.body.remaining).toBe(500);
expect(res.body.requestsThisMonth).toBe(5);
});
it('clamps remaining at 0 when over budget', async () => {
const id = await seedAdminKeyWithBudget(500);
repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 600, tokensOut: 100 });
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
expect(res.body.tokensTotal).toBe(700);
expect(res.body.remaining).toBe(0);
});
it('returns null remaining for unlimited budget', async () => {
const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
repo.incrementGatewayKeyUsage({ keyId: created.body.id, period: currentPeriodYearMonth(), tokensIn: 9999 });
const res = await request(app).get(`/api/admin/gateway/keys/${created.body.id}/usage`);
expect(res.body.tokensBudget).toBeNull();
expect(res.body.remaining).toBeNull();
});
it('history contains older periods, ordered DESC, excluding current period', async () => {
const id = await seedAdminKeyWithBudget();
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-03', tokensIn: 10, requests: 1 });
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-04', tokensIn: 20, requests: 2 });
repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 30, requests: 3 });
const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
const periods = (res.body.history as Array<{ period: string }>).map(h => h.period);
expect(periods).toEqual(['2026-04', '2026-03']);
});
it('returns 404 for unknown id', async () => {
const res = await request(app).get(`/api/admin/gateway/keys/missing/usage`);
expect(res.status).toBe(404);
});
});
});
@@ -0,0 +1,136 @@
/**
* Phase 3b post-review — admin mutations drop per-key Prometheus
* gauge labels so the registry doesn't grow unbounded over the key
* lifecycle (issue → revoke → issue → revoke … leaves a permanent
* `budgetUsedRatio{team, key_prefix}` series for every dead key
* without this fix).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express, { type Request, type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Registry } from 'prom-client';
import { Repository } from '../db/repository.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
import { createGatewayMetrics, type GatewayMetrics } from '../metrics/gateway-metrics.js';
function buildApp(repo: Repository, metrics: GatewayMetrics): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
const guard: RequestHandler = (req, _res, next) => {
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
gatewayMetrics: metrics,
});
app.use('/api/admin/gateway/keys', router);
return app;
}
describe('admin-gateway-api metric label removal (Phase 3b post-review)', () => {
let tmpDir: string;
let repo: Repository;
let reg: Registry;
let metrics: GatewayMetrics;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-metrics-'));
repo = new Repository(join(tmpDir, 'test.db'));
reg = new Registry();
metrics = createGatewayMetrics(reg, 'aao_gateway_ml');
app = buildApp(repo, metrics);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it('revoke removes the budgetUsedRatio label for the revoked key', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
expect(create.status).toBe(201);
const id = create.body.id as string;
const prefix = id.slice(0, 8);
// Simulate the gauge being set as it would by the bootstrap recordUsage callback.
metrics.budgetUsedRatio.labels({ team: 'alpha', key_prefix: prefix }).set(0.42);
let dump = await reg.metrics();
expect(dump).toMatch(
new RegExp(`aao_gateway_ml_virtual_key_budget_used_ratio\\{team="alpha",key_prefix="${prefix}"\\} 0\\.42`),
);
// Now revoke.
const rev = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`);
expect(rev.status).toBe(200);
// After remove() the label series should no longer appear.
dump = await reg.metrics();
expect(dump).not.toMatch(new RegExp(`key_prefix="${prefix}"`));
});
it('rotate removes the OLD key prefix label (new key creates its own on next usage)', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' });
const oldId = create.body.id as string;
const oldPrefix = oldId.slice(0, 8);
metrics.budgetUsedRatio.labels({ team: 'beta', key_prefix: oldPrefix }).set(0.55);
let dump = await reg.metrics();
expect(dump).toMatch(new RegExp(`key_prefix="${oldPrefix}"`));
const rot = await request(app).post(`/api/admin/gateway/keys/${oldId}/rotate`);
expect(rot.status).toBe(201);
dump = await reg.metrics();
expect(dump).not.toMatch(new RegExp(`key_prefix="${oldPrefix}"`));
});
it('delete removes the label too', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'gamma' });
const id = create.body.id as string;
const prefix = id.slice(0, 8);
metrics.budgetUsedRatio.labels({ team: 'gamma', key_prefix: prefix }).set(0.7);
const del = await request(app).delete(`/api/admin/gateway/keys/${id}`);
expect(del.status).toBe(204);
const dump = await reg.metrics();
expect(dump).not.toMatch(new RegExp(`key_prefix="${prefix}"`));
});
it('revoke without an existing gauge label is a safe no-op', async () => {
const create = await request(app).post('/api/admin/gateway/keys').send({ team: 'delta' });
const id = create.body.id as string;
// Don't pre-set the gauge — remove() of an unknown label is a noop.
const rev = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`);
expect(rev.status).toBe(200);
});
it('missing gatewayMetrics handle (cross-process deploy) does not block mutations', async () => {
// Build a separate app without a metrics handle to verify the
// admin API stays functional when the gateway runs in a different
// process.
const app2 = express();
app2.use(express.json({ limit: '4kb' }));
const guard: RequestHandler = (req, _res, next) => {
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
// No gatewayMetrics. dropKeyMetricLabels is a no-op.
});
app2.use('/api/admin/gateway/keys', router);
const create = await request(app2).post('/api/admin/gateway/keys').send({ team: 'eps' });
expect(create.status).toBe(201);
const id = create.body.id as string;
const rev = await request(app2).post(`/api/admin/gateway/keys/${id}/revoke`);
expect(rev.status).toBe(200);
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* AAO Gateway Phase 2a — admin REST API integration tests.
*
* Covers:
* - POST issues fresh sk-aao-* with raw key once; subsequent GETs hide it
* - GET supports ?team= and ?activeOnly=true
* - Revoke is idempotent (409 on second call) and hides from active list
* - Rotate is atomic: new key active, old key revoked, raw key returned
* - DELETE rejects source='config-import'
* - requireAdmin guard blocks non-admin callers
* - Validation: team regex, allowedModels shape
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express, { type Request, type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
function buildAppWithAdmin(repo: Repository, role: 'admin' | 'user' = 'admin'): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
// Stub admin guard inline; mirrors the auth flow without Passport.
const guard: RequestHandler = (req, res, next) => {
if (role !== 'admin') {
res.status(403).json({ error: 'Forbidden' });
return;
}
(req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
next();
};
const router = createAdminGatewayApi({
repo,
requireAdmin: guard,
getUserId: (req) => {
const u = (req as Request & { user?: { id?: string } }).user;
return u?.id ?? null;
},
});
app.use('/api/admin/gateway/keys', router);
return app;
}
describe('admin-gateway-api', () => {
let tmpDir: string;
let repo: Repository;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-test-'));
repo = new Repository(join(tmpDir, 'test.db'));
app = buildAppWithAdmin(repo);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
describe('POST /', () => {
it('issues a fresh key with sk-aao prefix and returns raw once', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
expect(res.status).toBe(201);
expect(res.body.team).toBe('alpha');
expect(res.body.source).toBe('admin');
expect(typeof res.body.key).toBe('string');
expect(res.body.key.startsWith('sk-aao-')).toBe(true);
expect(res.body.keyPrefix.startsWith('sk-aao-')).toBe(true);
expect(res.body.allowedModels).toBeNull();
// GET should not include raw key.
const list = await request(app).get('/api/admin/gateway/keys');
expect(list.body.keys[0].key).toBeUndefined();
});
it('validates team format', async () => {
const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'has spaces' });
expect(res.status).toBe(400);
});
it('accepts allowedModels and round-trips it', async () => {
const res = await request(app)
.post('/api/admin/gateway/keys')
.send({ team: 'alpha', allowedModels: ['qwen3:8b', 'qwen3:14b'] });
expect(res.status).toBe(201);
expect(res.body.allowedModels).toEqual(['qwen3:8b', 'qwen3:14b']);
});
it('rejects malformed allowedModels', async () => {
const res = await request(app)
.post('/api/admin/gateway/keys')
.send({ team: 'alpha', allowedModels: [42, ''] });
expect(res.status).toBe(400);
});
});
describe('GET / and GET /:id', () => {
it('lists with team filter and hides revoked when activeOnly=true', async () => {
const a = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' });
const old = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
await request(app).post(`/api/admin/gateway/keys/${old.id}/revoke`).send({});
const alphaAll = await request(app).get('/api/admin/gateway/keys?team=alpha');
expect(alphaAll.body.keys).toHaveLength(2);
const alphaActive = await request(app).get('/api/admin/gateway/keys?team=alpha&activeOnly=true');
expect(alphaActive.body.keys).toHaveLength(1);
expect(alphaActive.body.keys[0].id).toBe(a.id);
});
it('GET /:id returns 404 for unknown id', async () => {
const res = await request(app).get('/api/admin/gateway/keys/nope');
expect(res.status).toBe(404);
});
});
describe('POST /:id/revoke', () => {
it('revokes and is idempotent (409 second time)', async () => {
const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
const first = await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({});
expect(first.status).toBe(200);
expect(first.body.ok).toBe(true);
expect(first.body.revokedAt).toBeTruthy();
const second = await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({});
expect(second.status).toBe(409);
});
it('returns 404 for unknown id', async () => {
const res = await request(app).post('/api/admin/gateway/keys/nope/revoke').send({});
expect(res.status).toBe(404);
});
});
describe('POST /:id/rotate', () => {
it('atomically issues a new key and revokes the old', async () => {
const old = (await request(app).post('/api/admin/gateway/keys').send({
team: 'alpha',
allowedModels: ['qwen3:8b'],
})).body;
const res = await request(app).post(`/api/admin/gateway/keys/${old.id}/rotate`).send({});
expect(res.status).toBe(201);
expect(typeof res.body.key).toBe('string');
expect(res.body.id).not.toBe(old.id);
expect(res.body.allowedModels).toEqual(['qwen3:8b']);
expect(res.body.team).toBe('alpha');
const oldRefetch = await request(app).get(`/api/admin/gateway/keys/${old.id}`);
expect(oldRefetch.body.revokedAt).toBeTruthy();
});
it('refuses to rotate a revoked key', async () => {
const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
await request(app).post(`/api/admin/gateway/keys/${created.id}/revoke`).send({});
const res = await request(app).post(`/api/admin/gateway/keys/${created.id}/rotate`).send({});
expect(res.status).toBe(409);
});
});
describe('DELETE /:id', () => {
it('hard-deletes an admin-issued key', async () => {
const created = (await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' })).body;
const res = await request(app).delete(`/api/admin/gateway/keys/${created.id}`);
expect(res.status).toBe(204);
const after = await request(app).get(`/api/admin/gateway/keys/${created.id}`);
expect(after.status).toBe(404);
});
it('refuses to delete a config-import key', async () => {
const k = repo.createGatewayVirtualKey({
keyHash: 'cfg-hash',
keyPrefix: 'sk-conf-import',
team: 'imported',
source: 'config-import',
createdBy: 'config',
});
const res = await request(app).delete(`/api/admin/gateway/keys/${k.id}`);
expect(res.status).toBe(400);
expect(repo.findGatewayVirtualKeyById(k.id)).not.toBeNull();
});
});
describe('auth gating', () => {
it('non-admin caller receives 403', async () => {
const adminLess = buildAppWithAdmin(repo, 'user');
const res = await request(adminLess).get('/api/admin/gateway/keys');
expect(res.status).toBe(403);
});
});
describe('auth-disabled mount policy', () => {
// Server-level guard: createCoreServer refuses to mount this router
// when authActive=false because mounting it with a passthrough guard
// would let any anonymous caller mint valid sk-aao-* bearer tokens.
// We simulate the no-auth path by NOT mounting the router and
// asserting requests 404, matching the production behavior.
function buildAppWithoutAuth(): express.Application {
const app = express();
app.use(express.json({ limit: '4kb' }));
// Intentionally do NOT mount /api/admin/gateway/keys — this is the
// path server.ts takes when authActive===false.
return app;
}
it('returns 404 for POST when auth is disabled (route not mounted)', async () => {
const noAuthApp = buildAppWithoutAuth();
const res = await request(noAuthApp)
.post('/api/admin/gateway/keys')
.send({ team: 'alpha' });
expect(res.status).toBe(404);
});
it('returns 404 for GET when auth is disabled (route not mounted)', async () => {
const noAuthApp = buildAppWithoutAuth();
const res = await request(noAuthApp).get('/api/admin/gateway/keys');
expect(res.status).toBe(404);
});
it('returns 401 when auth IS active but caller is unauthenticated', async () => {
// With auth active, server.ts mounts requireAdmin (which 401s for
// missing user) BEFORE the router. We simulate that by wiring a
// requireAdmin that 401s without a user, then the router behind it.
const app = express();
app.use(express.json({ limit: '4kb' }));
const requireAdminLike: RequestHandler = (req, res, next) => {
const u = (req as Request & { user?: unknown }).user;
if (!u) {
res.status(401).json({ error: 'authentication required' });
return;
}
next();
};
app.use(
'/api/admin/gateway/keys',
requireAdminLike,
createAdminGatewayApi({
repo,
requireAdmin: (_req, _res, next) => next(),
getUserId: (req) =>
(req as Request & { user?: { id?: string } }).user?.id ?? null,
}),
);
const res = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
expect(res.status).toBe(401);
});
});
});
+554
View File
@@ -0,0 +1,554 @@
/**
* AAO Gateway Phase 2a — admin REST API for virtual key management.
*
* Mounted on worker-mode server.ts (not on gateway-mode server.ts; the
* gateway is intentionally read-only over auth state). Path prefix:
* /api/admin/gateway/keys
*
* Endpoint shape (see also docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md
* § Phase 2a / Admin REST API):
* POST / — issue (raw key returned ONCE here)
* GET / — list (raw key never returned)
* GET /:id — single (raw key never returned)
* POST /:id/revoke — soft delete
* POST /:id/rotate — atomic re-issue (new key returned, old revoked)
* DELETE /:id — hard delete (rejects source='config-import')
*
* Visibility: returned objects expose `keyPrefix` (sk-aao-XXXXXX) and
* meta only. The raw bearer is surfaced exactly by issue/rotate; lose
* it and you must rotate the key.
*/
import { Router, type RequestHandler, type Request } from 'express';
import type { Repository, GatewayVirtualKey } from '../db/repository.js';
import { generateVirtualKey } from '../gateway/key-format.js';
import { currentPeriodYearMonth } from '../gateway/period.js';
import type { KeyCache } from '../gateway/key-cache.js';
import type { GatewayMetrics } from '../metrics/gateway-metrics.js';
import { logger } from '../logger.js';
export interface AdminGatewayApiDeps {
repo: Repository;
/** Existing requireAdmin middleware (or a passthrough for auth-disabled deploys). */
requireAdmin: RequestHandler;
/**
* Extract the acting admin's user id from the request (for
* createdBy / revokedBy). Returns null when auth is disabled; the
* router records 'admin' in that case so audit history isn't blank.
*/
getUserId: (req: Request) => string | null;
/**
* Phase 3a F4: optional shared cache between auth + budget + rate
* middlewares. When wired by the same-process deployment, admin
* mutations (PATCH / revoke / rotate / delete) call cache.invalidate
* so the next request sees fresh state immediately. Cross-process
* setups fall back to the cache's 5s TTL.
*/
keyCache?: KeyCache;
/**
* Phase 3b post-review: optional gateway metrics handle. When wired,
* the revoke / rotate / delete handlers remove the
* `budgetUsedRatio{team, key_prefix}` series for the gone key — left
* in place those labels would grow unbounded over the key lifecycle.
*/
gatewayMetrics?: GatewayMetrics;
}
/** Wire-format DTO (camelCase JSON). Raw key is opt-in for issue/rotate. */
interface GatewayKeyDto {
id: string;
object: 'gateway.key';
keyPrefix: string;
team: string;
allowedModels: string[] | null;
source: GatewayVirtualKey['source'];
createdAt: string;
createdBy: string | null;
revokedAt: string | null;
revokedBy: string | null;
lastUsedAt: string | null;
/** Phase 2b: monthly tokens budget (null = unlimited). */
tokensBudget: number | null;
/** Phase 2b: per-minute requests cap (null = unlimited). */
rateLimitRpm: number | null;
/** Only present on POST / rotate responses. NEVER on list / get. */
key?: string;
}
function toDto(row: GatewayVirtualKey, raw?: string): GatewayKeyDto {
const dto: GatewayKeyDto = {
id: row.id,
object: 'gateway.key',
keyPrefix: row.keyPrefix,
team: row.team,
allowedModels: row.allowedModels,
source: row.source,
createdAt: row.createdAt,
createdBy: row.createdBy,
revokedAt: row.revokedAt,
revokedBy: row.revokedBy,
lastUsedAt: row.lastUsedAt,
tokensBudget: row.tokensBudget,
rateLimitRpm: row.rateLimitRpm,
};
if (raw !== undefined) dto.key = raw;
return dto;
}
/**
* Hard caps to keep policy values comfortably inside SQLite's INTEGER
* (54-bit) precision window. Past 2^53 numeric values get coerced to
* REAL on UPSERT arithmetic and start dropping low bits — so a stated
* budget like 1e20 would silently round and the running total could
* never reach it. Either limit is well beyond any sane real-world usage:
*
* - tokensBudget: 1 trillion tokens / month. At GPT-4-class pricing
* that's well over US$10M; if you legitimately need more, split keys.
* - rateLimitRpm: 1,000,000 requests / minute. The in-memory window
* array is bounded at 2× this, so the cap also keeps memory predictable.
*/
const MAX_TOKENS_BUDGET = 1_000_000_000_000;
const MAX_RATE_LIMIT_RPM = 1_000_000;
/**
* Parse a {tokensBudget?, rateLimitRpm?} pair from a request body.
* Returns the parsed values as positive integers, null (explicit
* unlimited), or undefined (don't touch).
*
* Strict: a number that's NaN / Infinity / negative is a 400 — we
* don't silently coerce because operators expect their stated limit
* to be applied. Floats are floored (sub-integer budgets are meaningless).
* Values above the hard cap are rejected — see MAX_* constants above
* for the rationale (SQLite INT-vs-REAL precision boundary).
*/
function parseLimitsPatch(body: { tokensBudget?: unknown; rateLimitRpm?: unknown } | undefined):
| { ok: true; tokensBudget?: number | null; rateLimitRpm?: number | null }
| { ok: false; error: string } {
const out: { tokensBudget?: number | null; rateLimitRpm?: number | null } = {};
if (body && Object.prototype.hasOwnProperty.call(body, 'tokensBudget')) {
const v = body.tokensBudget;
if (v === null) {
out.tokensBudget = null;
} else if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
if (v > MAX_TOKENS_BUDGET) {
return { ok: false, error: `tokensBudget exceeds maximum (${MAX_TOKENS_BUDGET})` };
}
out.tokensBudget = Math.floor(v);
} else {
return { ok: false, error: 'tokensBudget must be a positive integer or null' };
}
}
if (body && Object.prototype.hasOwnProperty.call(body, 'rateLimitRpm')) {
const v = body.rateLimitRpm;
if (v === null) {
out.rateLimitRpm = null;
} else if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
if (v > MAX_RATE_LIMIT_RPM) {
return { ok: false, error: `rateLimitRpm exceeds maximum (${MAX_RATE_LIMIT_RPM})` };
}
out.rateLimitRpm = Math.floor(v);
} else {
return { ok: false, error: 'rateLimitRpm must be a positive integer or null' };
}
}
return { ok: true, ...out };
}
const TEAM_REGEX = /^[a-zA-Z0-9._-]{1,64}$/;
const MAX_ALLOWED_MODELS = 64;
const MAX_MODEL_NAME_LEN = 128;
function parseAllowedModels(value: unknown): { ok: true; value: string[] | null } | { ok: false; error: string } {
if (value === undefined || value === null) return { ok: true, value: null };
if (!Array.isArray(value)) return { ok: false, error: 'allowedModels must be an array of strings' };
if (value.length > MAX_ALLOWED_MODELS) {
return { ok: false, error: `allowedModels supports at most ${MAX_ALLOWED_MODELS} entries` };
}
const out: string[] = [];
for (const m of value) {
if (typeof m !== 'string' || m.length === 0 || m.length > MAX_MODEL_NAME_LEN) {
return { ok: false, error: 'allowedModels entries must be non-empty strings ≤ 128 chars' };
}
out.push(m);
}
return { ok: true, value: out };
}
export function createAdminGatewayApi(deps: AdminGatewayApiDeps): Router {
const router = Router();
const { repo, requireAdmin, getUserId, keyCache, gatewayMetrics } = deps;
const actor = (req: Request): string => getUserId(req) ?? 'admin';
// Centralize cache invalidation so every mutation handler follows the
// same pattern: mutate first, invalidate second. Calling on a no-op
// cache (undefined) is a safe noop.
const invalidate = (id: string): void => {
try {
keyCache?.invalidate(id);
} catch (e) {
// Cache invalidation is best-effort; a thrown invalidate would
// most likely mean a bug in the cache, but we still don't want
// it to roll back the user-visible mutation.
logger.warn(`[admin-gateway] keyCache.invalidate threw for id=${id}: ${e instanceof Error ? e.message : String(e)}`);
}
};
// Phase 3b post-review: drop the per-key budget_used_ratio gauge label
// when the key goes away. Without this, every revoked key leaves a
// permanent {team, key_prefix} series in the registry — over enough
// rotations the label space grows without bound. Best-effort: a
// missing metrics handle (Phase 3b disabled), a label that was never
// set (key revoked before its first usage write), or a prom-client
// throw are all swallowed so admin mutation success isn't gated on
// metric bookkeeping.
const dropKeyMetricLabels = (row: { id: string; team: string }): void => {
if (!gatewayMetrics) return;
try {
const prefix = row.id.slice(0, 8);
gatewayMetrics.budgetUsedRatio.remove({ team: row.team, key_prefix: prefix });
} catch (e) {
logger.warn(
`[admin-gateway] metric label remove failed for id=${row.id}: ${e instanceof Error ? e.message : String(e)}`,
);
}
};
// POST / — issue a fresh sk-aao-* key. The raw value is returned in
// the response body once and never again.
router.post('/', requireAdmin, (req, res) => {
const body = req.body as {
team?: unknown;
allowedModels?: unknown;
tokensBudget?: unknown;
rateLimitRpm?: unknown;
} | undefined;
const team = typeof body?.team === 'string' ? body.team.trim() : '';
if (!team || !TEAM_REGEX.test(team)) {
res.status(400).json({ error: 'team must match /^[a-zA-Z0-9._-]{1,64}$/' });
return;
}
const allowed = parseAllowedModels(body?.allowedModels);
if (!allowed.ok) {
res.status(400).json({ error: allowed.error });
return;
}
const limits = parseLimitsPatch(body);
if (!limits.ok) {
res.status(400).json({ error: limits.error });
return;
}
const generated = generateVirtualKey();
let created: GatewayVirtualKey;
try {
created = repo.createGatewayVirtualKey({
keyHash: generated.hash,
keyPrefix: generated.prefix,
team,
allowedModels: allowed.value,
source: 'admin',
createdBy: actor(req),
// Phase 2b: optional budget / rate. Repository normalizer
// accepts both null and undefined as unlimited.
tokensBudget: limits.tokensBudget ?? null,
rateLimitRpm: limits.rateLimitRpm ?? null,
});
} catch (e) {
// randomBytes collision is mathematically negligible; any throw
// here is more likely a transient SQLite locking issue.
logger.warn(`[admin-gateway] create failed: ${e instanceof Error ? e.message : String(e)}`);
res.status(500).json({ error: 'failed to create key' });
return;
}
res.status(201).json(toDto(created, generated.raw));
});
// PATCH /:id — update policy fields (budget, rate limit, allowedModels).
// Bearer / team / source / created_by are immutable here. Refuses to
// touch config-import rows because those are managed via config.yaml
// (consistent with the DELETE rule).
router.patch('/:id', requireAdmin, (req, res) => {
const id = req.params['id']!;
// Parse + validate the body OUTSIDE the transaction so we don't pay
// the SQLite serialization cost on bad input.
const body = req.body as {
tokensBudget?: unknown;
rateLimitRpm?: unknown;
allowedModels?: unknown;
} | undefined;
const limits = parseLimitsPatch(body);
if (!limits.ok) {
res.status(400).json({ error: limits.error });
return;
}
const patch: {
tokensBudget?: number | null;
rateLimitRpm?: number | null;
allowedModels?: string[] | null;
} = {};
if (Object.prototype.hasOwnProperty.call(limits, 'tokensBudget')) patch.tokensBudget = limits.tokensBudget!;
if (Object.prototype.hasOwnProperty.call(limits, 'rateLimitRpm')) patch.rateLimitRpm = limits.rateLimitRpm!;
if (body && Object.prototype.hasOwnProperty.call(body, 'allowedModels')) {
if (body.allowedModels === null) {
patch.allowedModels = null;
} else {
const parsed = parseAllowedModels(body.allowedModels);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return;
}
patch.allowedModels = parsed.value;
}
}
if (Object.keys(patch).length === 0) {
res.status(400).json({ error: 'patch body must include at least one of tokensBudget, rateLimitRpm, allowedModels' });
return;
}
// Phase 3a follow-up: close the TOCTOU race between the "is the row
// revoked / config-import?" check and the UPDATE statement. Pre-fix
// the read + update lived outside any transaction; a concurrent
// revoke landing between the two would let PATCH overwrite a revoked
// row (silent ghost mutation in the audit log). Wrap both in a
// single better-sqlite3 transaction so the read and the conditional
// update are atomic, and signal the disallowed conditions back to
// the caller via typed sentinel errors.
//
// Sentinel error pattern (vs. structured return value): better-sqlite3
// transactions don't yet support typed Result returns, so we abuse
// the error channel — caller-side `instanceof` would be cleaner but
// string sentinels keep this contained to a single handler.
let updated: GatewayVirtualKey;
try {
updated = repo.getDb().transaction(() => {
const fresh = repo.findGatewayVirtualKeyById(id);
if (!fresh) throw new Error('PATCH_NOT_FOUND');
// Mirrors the rotate handler which also returns 409 for revoked.
// Pre-fix PATCH would silently update budget / rate / allowedModels
// on a row that can no longer authenticate — the new values
// would never apply to a real request and would mask audit
// history.
if (fresh.revokedAt !== null) {
const err = new Error('PATCH_REVOKED');
(err as Error & { revokedAt?: string }).revokedAt = fresh.revokedAt;
throw err;
}
if (fresh.source === 'config-import') throw new Error('PATCH_CONFIG_IMPORT');
return repo.updateGatewayVirtualKey(id, patch);
})();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === 'PATCH_NOT_FOUND') {
res.status(404).json({ error: 'key not found' });
return;
}
if (msg === 'PATCH_REVOKED') {
const revokedAt = (e as Error & { revokedAt?: string }).revokedAt ?? null;
res.status(409).json({ error: 'cannot modify a revoked key', revokedAt });
return;
}
if (msg === 'PATCH_CONFIG_IMPORT') {
res.status(400).json({
error:
"cannot PATCH a config-import key (manage tokens_budget / rate_limit_rpm / allowed_models via config.yaml's gateway.virtual_keys instead)",
});
return;
}
logger.warn(`[admin-gateway] patch failed for id=${id}: ${msg}`);
res.status(500).json({ error: 'patch failed' });
return;
}
// F4: drop the stale cache entry so the next auth/budget/rate
// middleware reads the fresh row from DB. The cache will repopulate
// on the next lookup (and stay coherent for 5s after that).
invalidate(id);
res.json(toDto(updated));
});
// GET /:id/usage — current month usage + budget headroom + recent rate
// burn rate + last 12 months of history. Single endpoint so the UI
// can render a key's detail panel in one round-trip.
router.get('/:id/usage', requireAdmin, (req, res) => {
const id = req.params['id']!;
const row = repo.findGatewayVirtualKeyById(id);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
const period = currentPeriodYearMonth();
const current = repo.getGatewayKeyUsage(id, period);
const tokensIn = current?.tokensIn ?? 0;
const tokensOut = current?.tokensOut ?? 0;
const tokensTotal = tokensIn + tokensOut;
const remaining = row.tokensBudget !== null ? Math.max(0, row.tokensBudget - tokensTotal) : null;
// History excludes the current period (UI shows it separately).
const allHistory = repo.listGatewayKeyUsagesByKey(id, { limit: 13 });
const history = allHistory
.filter(u => u.periodStart !== period)
.slice(0, 12)
.map(u => ({
period: u.periodStart,
tokensIn: u.tokensIn,
tokensOut: u.tokensOut,
requests: u.requests,
}));
// Phase 3a F9: the previous `rateRecentRequests: null` field was
// dead — the admin process and the gateway process are normally
// separate, so the live RateLimiter handle was unreachable, and
// the UI never displayed the value. Drop the field to keep the
// wire schema lean. Phase 3b/3c can re-introduce it once gateway
// IPC is in place.
res.json({
keyId: id,
currentPeriod: period,
tokensIn,
tokensOut,
tokensTotal,
tokensBudget: row.tokensBudget,
remaining,
requestsThisMonth: current?.requests ?? 0,
rateLimitRpm: row.rateLimitRpm,
history,
});
});
// GET / — list. Supports ?team= and ?activeOnly=true.
router.get('/', requireAdmin, (req, res) => {
const team = typeof req.query['team'] === 'string' ? req.query['team'] : undefined;
const activeOnly = req.query['activeOnly'] === 'true';
const rows = repo.listGatewayVirtualKeys({ team, activeOnly });
res.json({ keys: rows.map(r => toDto(r)) });
});
// GET /:id — single. Visible even when revoked so audit views work.
router.get('/:id', requireAdmin, (req, res) => {
const row = repo.findGatewayVirtualKeyById(req.params['id']!);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
res.json(toDto(row));
});
// POST /:id/revoke — soft delete. Idempotent: re-revoke is a 409 so
// callers can distinguish "already revoked" from "didn't exist".
router.post('/:id/revoke', requireAdmin, (req, res) => {
const id = req.params['id']!;
const row = repo.findGatewayVirtualKeyById(id);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
if (row.revokedAt !== null) {
res.status(409).json({ error: 'key already revoked', revokedAt: row.revokedAt });
return;
}
const ok = repo.revokeGatewayVirtualKey(id, actor(req));
if (!ok) {
// Lost a race with another revoke; refetch and return 409 for consistency.
const refreshed = repo.findGatewayVirtualKeyById(id);
// Invalidate even on the lost-race path: the cache might still
// hold the pre-revoke row from a hot lookup just before the race.
invalidate(id);
res.status(409).json({ error: 'key already revoked', revokedAt: refreshed?.revokedAt ?? null });
return;
}
// F4: a revoked key MUST NOT keep authenticating from the cache.
// The dbLookup wrapper additionally rejects cached rows with
// revokedAt !== null as defense-in-depth.
invalidate(id);
// Phase 3b post-review: also drop the per-key budgetUsedRatio
// gauge label so the prom-client registry doesn't grow unbounded
// over the key lifecycle.
dropKeyMetricLabels(row);
const refreshed = repo.findGatewayVirtualKeyById(id)!;
res.json({ ok: true, revokedAt: refreshed.revokedAt });
});
// POST /:id/rotate — atomic: issue a new key (inherits team +
// allowedModels), then revoke the old. Performed under a better-sqlite3
// transaction so a crash mid-flight can't leave both active.
router.post('/:id/rotate', requireAdmin, (req, res) => {
const id = req.params['id']!;
const old = repo.findGatewayVirtualKeyById(id);
if (!old) {
res.status(404).json({ error: 'key not found' });
return;
}
if (old.revokedAt !== null) {
res.status(409).json({ error: 'cannot rotate a revoked key' });
return;
}
const generated = generateVirtualKey();
const by = actor(req);
let created: GatewayVirtualKey;
try {
const tx = repo.getDb().transaction(() => {
const c = repo.createGatewayVirtualKey({
keyHash: generated.hash,
keyPrefix: generated.prefix,
team: old.team,
allowedModels: old.allowedModels,
source: 'admin',
createdBy: by,
});
repo.revokeGatewayVirtualKey(old.id, by);
return c;
});
created = tx();
} catch (e) {
logger.warn(`[admin-gateway] rotate failed: ${e instanceof Error ? e.message : String(e)}`);
res.status(500).json({ error: 'rotate failed' });
return;
}
// F4: drop the OLD key from the cache so the prior bearer can't
// re-auth. The newly-created row will be cache-warmed on its first
// hit; no need to pre-populate.
invalidate(old.id);
// Phase 3b post-review: the old key prefix is gone — drop its
// gauge label too. The new key will create its own label on first
// usage write.
dropKeyMetricLabels(old);
res.status(201).json(toDto(created, generated.raw));
});
// DELETE /:id — hard delete. config-import rows are protected: an
// operator should remove the entry from config.yaml instead so it
// doesn't get re-imported on the next boot. The Repository enforces
// the same rule (defense-in-depth) by throwing; we translate that to
// a 400 with a human-readable message instead of leaking a 500.
router.delete('/:id', requireAdmin, (req, res) => {
const id = req.params['id']!;
const row = repo.findGatewayVirtualKeyById(id);
if (!row) {
res.status(404).json({ error: 'key not found' });
return;
}
if (row.source === 'config-import') {
res.status(400).json({
error: "cannot delete a config-import key (remove the entry from config.yaml's gateway.virtual_keys, then restart, or POST /revoke instead)",
});
return;
}
try {
repo.deleteGatewayVirtualKey(id);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Repository's defense-in-depth guard catches the case where the
// row.source changes between our pre-check and the delete (race
// with another writer flipping source via some future code path).
if (/config-import/i.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.warn(`[admin-gateway] delete failed: ${msg}`);
res.status(500).json({ error: 'delete failed' });
return;
}
// F4: hard delete must also wipe the cache — the bearer should
// fail-auth on the next request, not after the TTL.
invalidate(id);
// Phase 3b post-review: drop metric label too. Same rationale as
// revoke/rotate — keep registry bounded.
dropKeyMetricLabels(row);
res.status(204).end();
});
return router;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Phase 3c — read-only admin endpoint for the same-process gateway
* mount status.
*
* The Gateway Server settings page polls this to render the status
* badge (running / disabled / starting / etc.) and surface any
* validation errors blocking a start. There is no PATCH/POST here —
* enabling / disabling the gateway is done through the existing
* /api/config PUT (the config-changed event picks up the new value
* and the mount handle reacts).
*/
import { Router, type Request, type Response } from 'express';
import type { GatewayMountHandle } from './gateway-mount.js';
import type { ConfigManager } from '../config-manager.js';
import { readGatewayConfig } from '../gateway/config.js';
export interface AdminGatewayStatusDeps {
/**
* Mount handle from createCoreServer. May be null when the bridge
* was created without a ConfigManager — in that case the endpoint
* reports the gateway as `unavailable` so the UI knows hot reload
* isn't supported in this deploy.
*/
mount: GatewayMountHandle | null;
/** ConfigManager so we can read the current desired-enabled flag. */
configManager: ConfigManager | null;
/**
* Port the worker bridge is listening on. Reported to the UI so the
* Gateway Server form can show `mounted at /v1 (port 9876)` instead
* of forcing the user to remember the port.
*/
workerPort: number;
}
export function createAdminGatewayStatusRouter(deps: AdminGatewayStatusDeps): Router {
const router = Router();
router.get('/', (_req: Request, res: Response) => {
const desiredEnabled = (() => {
try {
if (!deps.configManager) return null;
return readGatewayConfig(deps.configManager.getConfig()).enabled;
} catch {
return null;
}
})();
if (!deps.mount) {
res.json({
state: 'unavailable',
enabled: desiredEnabled,
errors: [],
mounted: false,
sharedPort: deps.workerPort,
message: 'gateway hot-reload unsupported in this deploy (no ConfigManager)',
});
return;
}
res.json({
state: deps.mount.getState(),
enabled: desiredEnabled,
errors: deps.mount.getErrors(),
mounted: deps.mount.getState() === 'running',
// The gateway runs on the same TCP port as the worker UI in
// same-process mode. The UI uses this to hint the user.
sharedPort: deps.workerPort,
});
});
return router;
}
+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログイン - {{APP_NAME}}</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.container {
display: flex;
min-height: 100vh;
}
/* LEFT PANEL */
.left-panel {
flex: 1;
background: linear-gradient(135deg, #1e40af 0%, #4f46e5 50%, #7c3aed 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 40px;
color: white;
}
.left-panel .logo-icon {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.left-panel .logo-icon svg {
width: 36px;
height: 36px;
}
.left-panel h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 12px;
text-align: center;
}
.left-panel .tagline {
font-size: 0.95rem;
opacity: 0.85;
text-align: center;
line-height: 1.6;
max-width: 320px;
}
.left-panel .feature-list {
margin-top: 40px;
list-style: none;
display: flex;
flex-direction: column;
gap: 14px;
}
.left-panel .feature-list li {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.875rem;
opacity: 0.9;
}
.left-panel .feature-list li::before {
content: '';
display: block;
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.7);
border-radius: 50%;
flex-shrink: 0;
}
/* RIGHT PANEL */
.right-panel {
flex: 1;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 40px;
}
.login-box {
width: 100%;
max-width: 380px;
}
.login-box h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
}
.login-box .subtitle {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 36px;
}
.oauth-button {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
padding: 12px 20px;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
cursor: pointer;
margin-bottom: 12px;
}
.oauth-button:last-child {
margin-bottom: 0;
}
.oauth-button-google {
background: #ffffff;
color: #1f2937;
border: 1.5px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.oauth-button-google:hover {
background: #f1f5f9;
border-color: #cbd5e1;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.oauth-button-gitea {
background: #2d8a4e;
color: #ffffff;
border: 1.5px solid transparent;
box-shadow: 0 1px 3px rgba(45, 138, 78, 0.3);
}
.oauth-button-gitea:hover {
background: #256e3e;
box-shadow: 0 2px 6px rgba(45, 138, 78, 0.4);
}
.oauth-button .btn-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: #94a3b8;
font-size: 0.8125rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.footer-note {
margin-top: 32px;
font-size: 0.8125rem;
color: #94a3b8;
text-align: center;
line-height: 1.5;
}
/* RESPONSIVE */
@media (max-width: 768px) {
.container {
flex-direction: column;
}
.left-panel {
padding: 40px 24px;
min-height: auto;
}
.left-panel h1 {
font-size: 1.5rem;
}
.left-panel .feature-list {
display: none;
}
.right-panel {
padding: 40px 24px;
background: #ffffff;
}
}
/* DARK MODE */
@media (prefers-color-scheme: dark) {
.right-panel {
background: #0f172a;
}
.login-box h2 {
color: #f1f5f9;
}
.login-box .subtitle {
color: #94a3b8;
}
.oauth-button-google {
background: #1e293b;
color: #e2e8f0;
border-color: #334155;
}
.oauth-button-google:hover {
background: #273549;
border-color: #475569;
}
.divider {
color: #475569;
}
.divider::before,
.divider::after {
background: #1e293b;
}
.footer-note {
color: #475569;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Left Panel -->
<div class="left-panel">
<div class="logo-icon">
<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="8" stroke="white" stroke-width="2.5"/>
<line x1="18" y1="4" x2="18" y2="10" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="18" y1="26" x2="18" y2="32" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="4" y1="18" x2="10" y2="18" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<line x1="26" y1="18" x2="32" y2="18" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="18" cy="18" r="3" fill="white"/>
</svg>
</div>
<h1>{{LOGIN_PAGE_TITLE}}</h1>
<p class="tagline">AIエージェントによるタスク自動実行プラットフォーム</p>
<ul class="feature-list">
<li>マルチエージェントによる並列タスク処理</li>
<li>LLM駆動のReActループ</li>
<li>柔軟なワークフロー定義(Piece/Movement</li>
<li>ローカルファーストな設計</li>
</ul>
</div>
<!-- Right Panel -->
<div class="right-panel">
<div class="login-box">
<h2>ログイン</h2>
<p class="subtitle">アカウントでサインインしてください</p>
<!-- GOOGLE_BUTTON_START -->
<!-- Google OAuth Button -->
<a href="/auth/google" class="oauth-button oauth-button-google">
<span class="btn-icon">
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M19.6 10.23c0-.68-.06-1.36-.18-2H10v3.78h5.4a4.62 4.62 0 01-2 3.04v2.52h3.24c1.9-1.75 2.96-4.32 2.96-7.34z" fill="#4285F4"/>
<path d="M10 20c2.7 0 4.96-.9 6.62-2.43l-3.24-2.52c-.9.6-2.04.96-3.38.96-2.6 0-4.8-1.76-5.58-4.12H1.08v2.6A9.99 9.99 0 0010 20z" fill="#34A853"/>
<path d="M4.42 11.89A6.02 6.02 0 014.1 10c0-.65.12-1.29.32-1.89V5.51H1.08A9.99 9.99 0 000 10c0 1.61.38 3.14 1.08 4.49l3.34-2.6z" fill="#FBBC05"/>
<path d="M10 3.96c1.46 0 2.78.5 3.82 1.5l2.84-2.84C14.96.9 12.7 0 10 0A9.99 9.99 0 001.08 5.51l3.34 2.6C5.2 5.72 7.4 3.96 10 3.96z" fill="#EA4335"/>
</svg>
</span>
Google でログイン
</a>
<!-- GOOGLE_BUTTON_END -->
<!-- DIVIDER_START -->
<div class="divider">または</div>
<!-- DIVIDER_END -->
<!-- GITEA_BUTTON_START -->
<!-- Gitea OAuth Button -->
<a href="/auth/gitea" class="oauth-button oauth-button-gitea">
<span class="btn-icon">
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" fill="white">
<path d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm0 2c1.54 0 2.97.44 4.18 1.2L3.2 14.18A7.96 7.96 0 012 10c0-4.42 3.58-8 8-8zm0 16a7.96 7.96 0 01-4.18-1.2L16.8 5.82A7.96 7.96 0 0118 10c0 4.42-3.58 8-8 8z"/>
</svg>
</span>
Gitea でログイン
</a>
<!-- GITEA_BUTTON_END -->
<p class="footer-note">
ログインすることで、利用規約とプライバシーポリシーに<br>同意したものとみなされます。
</p>
</div>
</div>
</div>
</body>
</html>
+221
View File
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>承認待ち - MAESTRO</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
body {
min-height: 100vh;
background: linear-gradient(135deg, #1e40af 0%, #4f46e5 50%, #7c3aed 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #ffffff;
border-radius: 16px;
padding: 48px 40px;
max-width: 440px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.icon-wrapper {
width: 72px;
height: 72px;
background: #fef3c7;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.icon-wrapper svg {
width: 36px;
height: 36px;
color: #d97706;
}
h1 {
font-size: 1.625rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
}
.message {
font-size: 0.9375rem;
color: #475569;
line-height: 1.7;
margin-bottom: 32px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: #fef9ec;
color: #92400e;
border: 1px solid #fde68a;
border-radius: 20px;
padding: 6px 16px;
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 32px;
}
.status-badge .dot {
width: 7px;
height: 7px;
background: #f59e0b;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.85); }
}
.logout-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 28px;
background: transparent;
color: #64748b;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
}
.logout-button:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #475569;
}
.logout-button svg {
width: 16px;
height: 16px;
}
.footer {
margin-top: 36px;
font-size: 0.8125rem;
color: #94a3b8;
}
/* DARK MODE */
@media (prefers-color-scheme: dark) {
.card {
background: #1e293b;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
h1 {
color: #f1f5f9;
}
.message {
color: #94a3b8;
}
.status-badge {
background: #1c1a0e;
color: #fbbf24;
border-color: #4a3800;
}
.logout-button {
color: #94a3b8;
border-color: #334155;
}
.logout-button:hover {
background: #0f172a;
border-color: #475569;
color: #cbd5e1;
}
.footer {
color: #475569;
}
}
</style>
</head>
<body>
<div class="card">
<!-- Icon -->
<div class="icon-wrapper">
<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="16" stroke="#d97706" stroke-width="2.5"/>
<line x1="18" y1="10" x2="18" y2="20" stroke="#d97706" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="18" cy="25" r="1.5" fill="#d97706"/>
</svg>
</div>
<!-- Title -->
<h1>アカウント承認待ち</h1>
<!-- Message -->
<p class="message">
管理者がアカウントを承認するまでお待ちください。<br>
承認後、自動的にサービスをご利用いただけます。
</p>
<!-- Status Badge -->
<div class="status-badge">
<span class="dot"></span>
承認待ち
</div>
<!-- Logout Button -->
<div>
<a href="/auth/logout" class="logout-button">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 14H3a1 1 0 01-1-1V3a1 1 0 011-1h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M10 11l3-3-3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
ログアウト
</a>
</div>
<p class="footer">MAESTRO</p>
</div>
<script>
setInterval(async () => {
try {
const res = await fetch('/auth/status');
const data = await res.json();
if (data.status === 'active') {
window.location.href = '/';
} else if (data.status === 'unauthenticated') {
window.location.href = '/auth/login';
}
} catch (e) { /* ignore network errors */ }
}, 5000);
</script>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { requireAuth, requireAdmin, fetchGiteaOrgsForUser } from './auth.js';
import type { Request, Response, NextFunction } from 'express';
function mockReqRes(overrides: Partial<Request> = {}) {
const req = {
isAuthenticated: () => false,
user: undefined,
originalUrl: '/api/test',
headers: { accept: 'application/json' },
...overrides,
} as unknown as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
redirect: vi.fn().mockReturnThis(),
} as unknown as Response;
const next = vi.fn() as NextFunction;
return { req, res, next };
}
describe('requireAuth', () => {
it('calls next() for authenticated active user', () => {
const { req, res, next } = mockReqRes({
isAuthenticated: () => true,
user: { id: '1', role: 'user', status: 'active' },
} as Partial<Request>);
requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
});
it('returns 401 for unauthenticated API request', () => {
const { req, res, next } = mockReqRes();
requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('redirects to /auth/login for unauthenticated non-API request', () => {
const { req, res, next } = mockReqRes({
originalUrl: '/ui',
headers: { accept: 'text/html' },
} as Partial<Request>);
requireAuth(req, res, next);
expect(res.redirect).toHaveBeenCalledWith('/auth/login');
});
});
describe('requireAdmin', () => {
it('calls next() for admin user', () => {
const { req, res, next } = mockReqRes({
isAuthenticated: () => true,
user: { id: '1', role: 'admin', status: 'active' },
} as Partial<Request>);
requireAdmin(req, res, next);
expect(next).toHaveBeenCalled();
});
it('returns 403 for non-admin user', () => {
const { req, res, next } = mockReqRes({
isAuthenticated: () => true,
user: { id: '1', role: 'user', status: 'active' },
} as Partial<Request>);
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('returns 401 for unauthenticated request', () => {
const { req, res, next } = mockReqRes();
requireAdmin(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
});
describe('fetchGiteaOrgsForUser', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
vi.restoreAllMocks();
});
function makeRepo(): Repository {
tempDir = mkdtempSync(join(tmpdir(), 'auth-test-'));
return new Repository(join(tempDir, 'db.sqlite'));
}
it('fetches and persists user orgs from Gitea API', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true,
json: async () => [
{ id: 10, username: 'marketing' },
{ id: 20, username: 'platform' },
],
} as Response);
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token-xyz');
expect(orgIds.sort()).toEqual(['10', '20']);
expect(repo.listUserGiteaOrgs(user.id).map(o => o.orgName).sort())
.toEqual(['marketing', 'platform']);
} finally {
repo.close();
}
});
it('returns empty array on !res.ok', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: false, status: 401,
} as Response);
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'bad-token');
expect(orgIds).toEqual([]);
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
} finally {
repo.close();
}
});
it('returns empty array on fetch rejection (network error)', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));
const orgIds = await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
expect(orgIds).toEqual([]);
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
} finally {
repo.close();
}
});
it('clears stale cached orgs when fetch fails (prevents permission lag after org removal)', async () => {
const repo = makeRepo();
try {
const user = repo.createUser({
email: '[email protected]', name: 'alice', role: 'user', status: 'active',
});
repo.replaceUserGiteaOrgs(user.id, [
{ orgId: '10', orgName: 'marketing' },
{ orgId: '20', orgName: 'platform' },
]);
expect(repo.listUserGiteaOrgs(user.id)).toHaveLength(2);
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, status: 503 } as Response);
await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
repo.replaceUserGiteaOrgs(user.id, [{ orgId: '10', orgName: 'marketing' }]);
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED'));
await fetchGiteaOrgsForUser(repo, user.id, 'http://gitea.local', 'token');
expect(repo.listUserGiteaOrgs(user.id)).toEqual([]);
} finally {
repo.close();
}
});
});
+636
View File
@@ -0,0 +1,636 @@
import { readFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import type { Request, Response, NextFunction, RequestHandler, Router } from 'express';
import type { IncomingMessage } from 'http';
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import type { Database } from 'better-sqlite3';
import type { AuthConfig } from '../config.js';
import type { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
/**
* WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。
* Express の上では express-session + passport が自動でこれを担うが、
* server.on('upgrade', ...) は middleware を素通しするので個別に呼ぶ必要がある。
*/
export type UpgradeAuthChecker = (req: IncomingMessage) => Promise<Express.User | null>;
// ── Login Page Renderer ──────────────────────────────────────────────────────
const __authDirname = path.dirname(fileURLToPath(import.meta.url));
export interface LoginBranding {
appName: string;
loginPageTitle: string;
}
const DEFAULT_LOGIN_BRANDING: LoginBranding = {
appName: 'MAESTRO',
loginPageTitle: 'MAESTRO',
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* auth-login.html をレンダリングする。
* primary_provider 設定と各プロバイダの configured 状態に応じて
* Google/Gitea ボタンおよび divider を表示/非表示する。
* branding が指定されていれば {{APP_NAME}} / {{LOGIN_PAGE_TITLE}} を差し替える。
*/
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
const primary = authConfig.primaryProvider;
const googleConfigured = !!authConfig.providers.google?.clientId;
const giteaConfigured = !!authConfig.providers.gitea?.clientId;
// Decide which buttons to show
let showGoogle: boolean;
let showGitea: boolean;
if (primary === 'google') {
showGoogle = googleConfigured;
showGitea = false;
} else if (primary === 'gitea') {
showGoogle = false;
showGitea = giteaConfigured;
} else {
// No primary specified: show every configured provider
showGoogle = googleConfigured;
showGitea = giteaConfigured;
}
const stripBlock = (html: string, startMarker: string, endMarker: string): string => {
const re = new RegExp(`<!--\\s*${startMarker}\\s*-->[\\s\\S]*?<!--\\s*${endMarker}\\s*-->`, 'g');
return html.replace(re, '');
};
let out = raw;
if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END');
if (!showGitea) out = stripBlock(out, 'GITEA_BUTTON_START', 'GITEA_BUTTON_END');
// Hide divider unless both buttons are visible
if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END');
// Branding placeholders
out = out
.replace(/\{\{APP_NAME\}\}/g, escapeHtml(branding.appName))
.replace(/\{\{LOGIN_PAGE_TITLE\}\}/g, escapeHtml(branding.loginPageTitle));
return out;
}
// ── Global type augmentation ─────────────────────────────────────────────────
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface User {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
role: 'admin' | 'user';
status: 'active' | 'pending' | 'disabled';
orgIds: string[];
defaultVisibility: 'private' | 'org' | 'public';
defaultVisibilityOrgId: string | null;
}
}
}
// ── Middleware ────────────────────────────────────────────────────────────────
/**
* requireAuth: 認証済みかつ status=active のユーザーのみ通過させる。
* API リクエスト(/api/ プレフィックス)には 401 JSON を返す。
* それ以外のリクエストは /auth/login にリダイレクトする。
*/
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (req.isAuthenticated() && req.user && (req.user as Express.User).status === 'active') {
next();
return;
}
if (req.originalUrl.startsWith('/api/')) {
res.status(401).json({ error: 'Unauthorized' });
} else {
res.redirect('/auth/login');
}
}
/**
* requireAdmin: admin ロールのユーザーのみ通過させる。
* 未認証の場合は requireAuth と同じ挙動(401 or redirect)。
* 認証済みだが admin でない場合は 403 を返す。
*/
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.isAuthenticated() || !req.user) {
if (req.originalUrl.startsWith('/api/')) {
res.status(401).json({ error: 'Unauthorized' });
} else {
res.redirect('/auth/login');
}
return;
}
const user = req.user as Express.User;
if (user.role !== 'admin') {
res.status(403).json({ error: 'Forbidden' });
return;
}
next();
}
// ── SQLite Session Store ──────────────────────────────────────────────────────
/**
* Repository の SQLite DB を使ったカスタムセッションストア。
* sessions テーブル (sid, sess, expired) を直接操作する。
*/
function createSqliteSessionStore(db: Database): session.Store {
// session.Store の基底クラスを継承
const Store = session.Store as unknown as new () => session.Store;
class SqliteStore extends Store {
get(sid: string, callback: (err: unknown, session?: session.SessionData | null) => void): void {
try {
const row = db
.prepare('SELECT sess, expired FROM sessions WHERE sid = ?')
.get(sid) as { sess: string; expired: string } | undefined;
if (!row) {
callback(null, null);
return;
}
// 期限切れチェック
if (new Date(row.expired) <= new Date()) {
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
callback(null, null);
return;
}
const sessionData = JSON.parse(row.sess) as session.SessionData;
callback(null, sessionData);
} catch (err) {
callback(err);
}
}
set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
try {
const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000;
const expired = new Date(Date.now() + ttl).toISOString();
const sess = JSON.stringify(sessionData);
db.prepare(`
INSERT INTO sessions (sid, sess, expired)
VALUES (?, ?, ?)
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expired = excluded.expired
`).run(sid, sess, expired);
callback?.();
} catch (err) {
callback?.(err);
}
}
destroy(sid: string, callback?: (err?: unknown) => void): void {
try {
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
callback?.();
} catch (err) {
callback?.(err);
}
}
touch(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
try {
const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000;
const expired = new Date(Date.now() + ttl).toISOString();
db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid);
callback?.();
} catch (err) {
callback?.(err);
}
}
}
return new SqliteStore();
}
// ── OAuth Callback ────────────────────────────────────────────────────────────
/**
* OAuth コールバック共通処理。
* email から findOrCreateUserByOAuth を呼び出し、
* adminEmails に一致する pending ユーザーは自動で admin に昇格する。
*/
async function handleOAuthCallback(
repo: Repository,
adminEmails: string[],
provider: string,
providerId: string,
email: string,
name: string,
avatarUrl: string | undefined,
done: (err: unknown, user?: Express.User | false) => void
): Promise<void> {
try {
let user = repo.findOrCreateUserByOAuth({
provider,
providerId,
email,
name,
avatarUrl,
});
// adminEmails に一致する pending ユーザーを自動昇格
if (user.status === 'pending' && adminEmails.includes(email)) {
repo.updateUser(user.id, { status: 'active', role: 'admin' });
const updated = repo.getUserById(user.id);
if (updated) user = updated;
}
// deserializeUser will enrich with orgIds + defaults on subsequent requests.
const sessionUser: Express.User = {
...user,
orgIds: [],
defaultVisibility: user.defaultVisibility ?? 'private',
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
};
done(null, sessionUser);
} catch (err) {
done(err);
}
}
// ── Gitea Orgs Fetch ─────────────────────────────────────────────────────────
/**
* Gitea の /api/v1/user/orgs を呼び出してユーザーの所属 org 一覧を取得し、
* Repository に永続化する。返り値は org ID の文字列配列。
* 失敗時は空配列を返し、警告ログを出力する(認証フロー自体は継続)。
*/
export async function fetchGiteaOrgsForUser(
repo: Repository,
userId: string,
baseUrl: string,
accessToken: string,
): Promise<string[]> {
let res: globalThis.Response;
try {
res = await fetch(`${baseUrl}/api/v1/user/orgs`, {
headers: { Authorization: `token ${accessToken}`, Accept: 'application/json' },
});
} catch (err) {
console.warn(`[auth] gitea orgs fetch error: ${(err as Error).message}`);
// Clear stale cache: if we can't confirm membership, don't keep old grants around.
repo.replaceUserGiteaOrgs(userId, []);
return [];
}
if (!res.ok) {
console.warn(`[auth] gitea orgs fetch failed: ${res.status}`);
repo.replaceUserGiteaOrgs(userId, []);
return [];
}
const orgs = (await res.json()) as Array<{ id: number; username: string }>;
const items = orgs.map(o => ({ orgId: String(o.id), orgName: o.username }));
repo.replaceUserGiteaOrgs(userId, items);
return items.map(i => i.orgId);
}
// ── Strategy Registration ─────────────────────────────────────────────────────
function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
const googleConfig = authConfig.providers.google;
if (!googleConfig) return;
passport.use(
new GoogleStrategy(
{
clientID: googleConfig.clientId,
clientSecret: googleConfig.clientSecret,
callbackURL: googleConfig.callbackUrl,
},
async (_accessToken, _refreshToken, profile, done) => {
const email = profile.emails?.[0]?.value ?? '';
const name = profile.displayName ?? '';
const avatarUrl = profile.photos?.[0]?.value;
await handleOAuthCallback(
repo,
authConfig.adminEmails,
'google',
profile.id,
email,
name,
avatarUrl,
done as (err: unknown, user?: Express.User | false) => void
);
}
)
);
}
function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
const giteaConfig = authConfig.providers.gitea;
if (!giteaConfig) return;
const baseUrl = giteaConfig.baseUrl ?? '';
passport.use(
'gitea',
new OAuth2Strategy(
{
authorizationURL: `${baseUrl}/login/oauth/authorize`,
tokenURL: `${baseUrl}/login/oauth/access_token`,
clientID: giteaConfig.clientId,
clientSecret: giteaConfig.clientSecret,
callbackURL: giteaConfig.callbackUrl,
},
async (accessToken: string, _refreshToken: string, _params: unknown, _profile: unknown, done: (err: unknown, user?: Express.User | false) => void) => {
try {
// Gitea 専用: アクセストークンでユーザー情報を取得
const response = await fetch(`${baseUrl}/api/v1/user`, {
headers: {
Authorization: `token ${accessToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
done(new Error(`Gitea userinfo fetch failed: ${response.status}`));
return;
}
const profile = await response.json() as {
id: number;
login: string;
email: string;
full_name?: string;
avatar_url?: string;
};
const email = profile.email && profile.email.length > 0
? profile.email
: `${profile.login}@gitea.local`;
// Gitea returns full_name="" when the user hasn't set it; `??` would
// keep that empty string, so use `||` to fall through to the login.
const name = profile.full_name || profile.login || '';
const avatarUrl = profile.avatar_url;
// Gitea verify は handleOAuthCallback をインライン化:
// accessToken/baseUrl がこのスコープでしか得られないため、
// user を確定させた後 fetchGiteaOrgsForUser を呼んで orgs を永続化する。
let user = repo.findOrCreateUserByOAuth({
provider: 'gitea',
providerId: String(profile.id),
email,
name,
avatarUrl,
});
if (user.status === 'pending' && authConfig.adminEmails.includes(email)) {
repo.updateUser(user.id, { status: 'active', role: 'admin' });
const updated = repo.getUserById(user.id);
if (updated) user = updated;
}
await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken);
const orgIds = repo.listUserGiteaOrgs(user.id).map(o => o.orgId);
const sessionUser: Express.User = {
...user,
orgIds,
defaultVisibility: user.defaultVisibility ?? 'private',
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
};
done(null, sessionUser);
} catch (err) {
done(err);
}
}
)
);
}
// ── Auth Router ───────────────────────────────────────────────────────────────
function createAuthRouter(
authConfig: AuthConfig,
getBranding?: () => LoginBranding,
): Router {
const router = express.Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ログインページ
router.get('/login', (_req: Request, res: Response) => {
const branding = getBranding ? getBranding() : DEFAULT_LOGIN_BRANDING;
res.type('html').send(renderLoginPage(authConfig, branding));
});
// 承認待ちページ(承認済みなら自動リダイレクト)
router.get('/pending', (req, res) => {
if (req.isAuthenticated() && (req.user as Express.User).status === 'active') {
res.redirect('/');
return;
}
res.sendFile(path.join(__dirname, 'auth-pending.html'));
});
// ステータス確認エンドポイント(承認待ちページのポーリング用)
router.get('/status', (req, res) => {
if (!req.isAuthenticated() || !req.user) {
res.json({ status: 'unauthenticated' });
return;
}
res.json({ status: (req.user as Express.User).status });
});
// Google OAuth
if (authConfig.providers.google) {
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
router.get(
'/google/callback',
passport.authenticate('google', { failureRedirect: '/auth/login' }),
(req, res) => {
const user = req.user as Express.User | undefined;
if (user?.status === 'active') {
res.redirect('/');
} else {
res.redirect('/auth/pending');
}
}
);
}
// Gitea OAuth
if (authConfig.providers.gitea) {
router.get('/gitea', passport.authenticate('gitea'));
router.get(
'/gitea/callback',
passport.authenticate('gitea', { failureRedirect: '/auth/login' }),
(req, res) => {
const user = req.user as Express.User | undefined;
if (user?.status === 'active') {
res.redirect('/');
} else {
res.redirect('/auth/pending');
}
}
);
}
// ログアウト
router.get('/logout', (req, res, next) => {
req.logout((err) => {
if (err) {
next(err);
return;
}
res.redirect('/auth/login');
});
});
return router;
}
// ── setupAuth ─────────────────────────────────────────────────────────────────
export interface AuthMiddlewares {
sessionMiddleware: RequestHandler;
passportInit: RequestHandler;
passportSession: RequestHandler;
authRouter: Router;
/**
* Raw HTTP upgradeWebSocket)リクエストから認証済みユーザーを解決する。
* Cookie → セッション → Passport deserialize の順に通し、最終的な req.user を返す。
* 認証されていなければ null を返す。
*/
authenticateUpgrade: UpgradeAuthChecker;
}
/**
* 認証モジュールのセットアップ。
* セッション、Passport、OAuth ストラテジーを設定し、
* ミドルウェアと認証ルーターを返す。
*/
export function setupAuth(
repo: Repository,
authConfig: AuthConfig,
getBranding?: () => LoginBranding,
): AuthMiddlewares {
const db = repo.getDb();
// セッションミドルウェア
const sessionMiddleware = session({
secret: authConfig.sessionSecret,
resave: false,
saveUninitialized: false,
store: createSqliteSessionStore(db),
cookie: {
secure: authConfig.secureCookie,
maxAge: authConfig.sessionMaxAge,
},
});
// Passport シリアライズ/デシリアライズ
passport.serializeUser((user: Express.User, done) => {
done(null, user.id);
});
passport.deserializeUser((id: string, done) => {
try {
const baseUser = repo.getUserById(id);
if (!baseUser) { done(null, false); return; }
const orgs = repo.listUserGiteaOrgs(id);
const enriched: Express.User = {
...baseUser,
orgIds: orgs.map(o => o.orgId),
defaultVisibility: baseUser.defaultVisibility ?? 'private',
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
};
done(null, enriched);
} catch (err) {
done(err);
}
});
// OAuth ストラテジー登録
registerGoogleStrategy(repo, authConfig);
registerGiteaStrategy(repo, authConfig);
// 認証ルーター
const authRouter = createAuthRouter(authConfig, getBranding);
const passportInit = passport.initialize();
const passportSession = passport.session();
// 生 upgrade リクエスト用の認証チェッカー。
// sessionMiddleware → passportInit → passportSession を順に走らせ、req.user を populate する。
// 失敗時は null を返し、呼び出し側で socket.destroy() する想定。
// 各 middleware が next(err) を呼んだ場合(session store 障害・deserialize 失敗等)は
// ログを出してから null を返す(fail-closed)。
const authenticateUpgrade: UpgradeAuthChecker = (req) => {
return new Promise((resolve) => {
// express-session 等が res.setHeader / res.end を呼ぶことがあるため、
// 必要最小限のメソッドを no-op で備えたスタブを渡す。
const fakeRes = {
setHeader: () => fakeRes,
getHeader: () => undefined,
removeHeader: () => fakeRes,
end: () => fakeRes,
writeHead: () => fakeRes,
statusCode: 200,
on: () => fakeRes,
} as unknown as Response;
const reqAny = req as unknown as Request;
const failClosed = (stage: string, err: unknown): void => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[auth] authenticateUpgrade ${stage} failed: ${msg}`);
resolve(null);
};
sessionMiddleware(reqAny, fakeRes, (sessionErr?: unknown) => {
if (sessionErr) { failClosed('sessionMiddleware', sessionErr); return; }
passportInit(reqAny, fakeRes, (initErr?: unknown) => {
if (initErr) { failClosed('passportInit', initErr); return; }
passportSession(reqAny, fakeRes, (sessErr?: unknown) => {
if (sessErr) { failClosed('passportSession', sessErr); return; }
const user = reqAny.user as Express.User | undefined;
if (!user) {
resolve(null);
return;
}
// status=active のみ認める(disabled/pending を弾く)
if (user.status !== 'active') {
resolve(null);
return;
}
resolve(user);
});
});
});
});
};
return {
sessionMiddleware,
passportInit,
passportSession,
authRouter,
authenticateUpgrade,
};
}
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { ConfigManager } from '../config-manager.js';
import { mountBrandingApi, resolveBranding } from './branding-api.js';
function makeApp(yaml: string, withUpload = false) {
const dir = mkdtempSync(join(tmpdir(), 'branding-api-'));
writeFileSync(join(dir, 'config.yaml'), yaml);
const cm = new ConfigManager(join(dir, 'config.yaml'));
const app = express();
if (withUpload) {
mountBrandingApi(app, cm, {
brandingDir: join(dir, 'branding'),
adminGuard: (_req, _res, next) => next(),
});
} else {
mountBrandingApi(app, cm);
}
return { app, cm, dir };
}
describe('Branding API', () => {
it('returns defaults when branding is not configured', async () => {
const { app } = makeApp('provider:\n model: test-model\n');
const res = await request(app).get('/api/branding');
expect(res.status).toBe(200);
expect(res.body).toEqual({
appName: 'MAESTRO',
primaryColor: '#2563eb',
loginPageTitle: 'MAESTRO',
logoUrl: null,
faviconUrl: null,
footerText: null,
});
});
it('returns configured values when branding is set', async () => {
const { app } = makeApp([
'provider:',
' model: test-model',
'branding:',
' app_name: "My Team AI"',
' primary_color: "#ff5500"',
' login_page_title: "Welcome to My Team"',
' logo_url: "/branding/logo-abc.svg"',
' favicon_url: "/branding/favicon-def.png"',
' footer_text: "© 2026 My Team"',
].join('\n'));
const res = await request(app).get('/api/branding');
expect(res.status).toBe(200);
expect(res.body).toEqual({
appName: 'My Team AI',
primaryColor: '#ff5500',
loginPageTitle: 'Welcome to My Team',
logoUrl: '/branding/logo-abc.svg',
faviconUrl: '/branding/favicon-def.png',
footerText: '© 2026 My Team',
});
});
it('falls back loginPageTitle to appName when only appName is set', async () => {
const { app } = makeApp([
'provider:',
' model: test-model',
'branding:',
' app_name: "Custom App"',
].join('\n'));
const res = await request(app).get('/api/branding');
expect(res.body.appName).toBe('Custom App');
expect(res.body.loginPageTitle).toBe('Custom App');
expect(res.body.primaryColor).toBe('#2563eb');
});
it('ignores empty strings and falls back to defaults', async () => {
const { app } = makeApp([
'provider:',
' model: test-model',
'branding:',
' app_name: ""',
' primary_color: " "',
].join('\n'));
const res = await request(app).get('/api/branding');
expect(res.body.appName).toBe('MAESTRO');
expect(res.body.primaryColor).toBe('#2563eb');
});
it('resolveBranding returns defaults for undefined configManager', () => {
const branding = resolveBranding(undefined);
expect(branding).toEqual({
appName: 'MAESTRO',
primaryColor: '#2563eb',
loginPageTitle: 'MAESTRO',
logoUrl: null,
faviconUrl: null,
footerText: null,
});
});
describe('reactive to config updates', () => {
let cm: ConfigManager;
let app: express.Application;
beforeEach(() => {
const built = makeApp('provider:\n model: test-model\n');
cm = built.cm;
app = built.app;
});
it('reflects runtime updates via ConfigManager', async () => {
const before = await request(app).get('/api/branding');
expect(before.body.appName).toBe('MAESTRO');
const etag = cm.getConfigForApi().etag;
cm.updateConfig({ branding: { appName: 'Hot Reload' } }, etag);
const after = await request(app).get('/api/branding');
expect(after.body.appName).toBe('Hot Reload');
});
});
describe('asset upload', () => {
// 1x1 transparent PNG
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
it('uploads a logo and reflects it in GET /api/branding', async () => {
const { app, dir } = makeApp('provider:\n model: test-model\n', true);
const upload = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'my-logo.png', contentBase64: PNG_BASE64 });
expect(upload.status).toBe(200);
expect(upload.body.ok).toBe(true);
expect(upload.body.url).toMatch(/^\/branding\/logo-[a-f0-9]{12}\.png$/);
// File actually written to branding dir
expect(existsSync(join(dir, 'branding'))).toBe(true);
const files = readdirSync(join(dir, 'branding'));
expect(files.some(f => f.startsWith('logo-') && f.endsWith('.png'))).toBe(true);
// Reflected in public GET
const get = await request(app).get('/api/branding');
expect(get.body.logoUrl).toBe(upload.body.url);
});
it('rejects invalid kind', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
const res = await request(app)
.post('/api/branding/upload')
.send({ kind: 'banner', filename: 'x.png', contentBase64: PNG_BASE64 });
expect(res.status).toBe(400);
});
it('rejects disallowed extension for favicon', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
const res = await request(app)
.post('/api/branding/upload')
.send({ kind: 'favicon', filename: 'evil.gif', contentBase64: PNG_BASE64 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/extension/);
});
it('rejects files over size limit', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
const big = Buffer.alloc(257 * 1024, 0x00).toString('base64'); // > 256KB for favicon
const res = await request(app)
.post('/api/branding/upload')
.send({ kind: 'favicon', filename: 'big.png', contentBase64: big });
expect(res.status).toBe(413);
});
it('DELETE clears the config field and removes the file', async () => {
const { app, dir } = makeApp('provider:\n model: test-model\n', true);
const upload = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'my.png', contentBase64: PNG_BASE64 });
expect(upload.status).toBe(200);
const uploadedName = upload.body.url.replace('/branding/', '');
expect(existsSync(join(dir, 'branding', uploadedName))).toBe(true);
const del = await request(app).delete('/api/branding/upload?kind=logo');
expect(del.status).toBe(200);
expect(existsSync(join(dir, 'branding', uploadedName))).toBe(false);
const get = await request(app).get('/api/branding');
expect(get.body.logoUrl).toBeNull();
});
it('replacing an existing asset cleans up the old file', async () => {
const { app, dir } = makeApp('provider:\n model: test-model\n', true);
const first = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'a.png', contentBase64: PNG_BASE64 });
const firstName = first.body.url.replace('/branding/', '');
const second = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'b.png', contentBase64: PNG_BASE64 });
const secondName = second.body.url.replace('/branding/', '');
expect(firstName).not.toBe(secondName);
const files = readdirSync(join(dir, 'branding')).filter(f => f.startsWith('logo-'));
expect(files).toEqual([secondName]);
});
});
});
+244
View File
@@ -0,0 +1,244 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import express from 'express';
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
import { join, extname, basename } from 'path';
import { randomBytes } from 'crypto';
import type { ConfigManager } from '../config-manager.js';
import { logger } from '../logger.js';
export interface PublicBranding {
appName: string;
primaryColor: string;
loginPageTitle: string;
logoUrl: string | null;
faviconUrl: string | null;
footerText: string | null;
}
const DEFAULTS: PublicBranding = {
appName: 'MAESTRO',
primaryColor: '#2563eb',
loginPageTitle: 'MAESTRO',
logoUrl: null,
faviconUrl: null,
footerText: null,
};
function pickString(obj: Record<string, unknown>, key: string): string | null {
const v = obj[key];
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
return null;
}
export function resolveBranding(configManager: ConfigManager | undefined): PublicBranding {
const raw = configManager?.getConfigForApi()?.config?.branding;
const cfg: Record<string, unknown> =
raw && typeof raw === 'object' && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const appName = pickString(cfg, 'appName') ?? DEFAULTS.appName;
const primaryColor = pickString(cfg, 'primaryColor') ?? DEFAULTS.primaryColor;
const loginPageTitle = pickString(cfg, 'loginPageTitle') ?? appName;
const logoUrl = pickString(cfg, 'logoUrl');
const faviconUrl = pickString(cfg, 'faviconUrl');
const footerText = pickString(cfg, 'footerText');
return { appName, primaryColor, loginPageTitle, logoUrl, faviconUrl, footerText };
}
// ── Upload handling ──────────────────────────────────────────────────────────
type AssetKind = 'logo' | 'favicon';
const ALLOWED_KINDS: AssetKind[] = ['logo', 'favicon'];
const ALLOWED_EXTENSIONS: Record<AssetKind, string[]> = {
logo: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'],
favicon: ['.svg', '.png', '.ico', '.webp'],
};
const MAX_SIZE_BYTES: Record<AssetKind, number> = {
logo: 2 * 1024 * 1024, // 2 MB
favicon: 256 * 1024, // 256 KB
};
function sanitizeExt(filename: string): string {
const ext = extname(filename).toLowerCase();
// Defensive: reject anything with path separators / null bytes
if (/[\\/\0]/.test(ext)) return '';
// Extension must be in a safe set
if (!/^\.[a-z0-9]{1,5}$/.test(ext)) return '';
return ext;
}
function removeExistingAsset(brandingDir: string, kind: AssetKind): void {
if (!existsSync(brandingDir)) return;
for (const name of readdirSync(brandingDir)) {
if (name.startsWith(`${kind}-`)) {
try {
unlinkSync(join(brandingDir, name));
} catch (e) {
logger.warn(`[branding] failed to remove old asset ${name}: ${e}`);
}
}
}
}
function assetUrlFromConfig(configManager: ConfigManager | undefined, kind: AssetKind): string | null {
const b = configManager?.getConfigForApi()?.config?.branding;
if (!b || typeof b !== 'object') return null;
const key = kind === 'logo' ? 'logoUrl' : 'faviconUrl';
const v = (b as Record<string, unknown>)[key];
return typeof v === 'string' ? v : null;
}
function removeAssetByUrl(brandingDir: string, url: string | null): void {
if (!url) return;
if (!url.startsWith('/branding/')) return;
const name = basename(url);
// Defense in depth: basename strips any `..` components
if (!name || name.includes('..')) return;
const fullPath = join(brandingDir, name);
if (existsSync(fullPath)) {
try {
unlinkSync(fullPath);
} catch (e) {
logger.warn(`[branding] failed to remove asset ${name}: ${e}`);
}
}
}
export interface MountBrandingOptions {
/** Absolute or relative path where branding assets (logos, favicons) are stored. Created on demand. */
brandingDir: string;
/** Admin-only middleware. When auth is disabled, pass a passthrough. */
adminGuard: RequestHandler;
}
export function mountBrandingApi(
app: Application,
configManager: ConfigManager | undefined,
opts?: MountBrandingOptions,
): void {
// Public GET — no auth required. UI fetches this at startup (even on login page).
app.get('/api/branding', (_req: Request, res: Response) => {
res.json(resolveBranding(configManager));
});
if (!opts) return;
const { brandingDir, adminGuard } = opts;
// Serve uploaded assets. Directory is created lazily if first write happens;
// express.static handles the not-exists case by falling through to 404.
app.use('/branding', express.static(brandingDir, {
maxAge: '7d',
fallthrough: true,
}));
// Upload endpoint: admin only. Body is JSON with base64 content
// (same pattern as task attachment upload in local-tasks-api.ts).
const uploadJson = express.json({ limit: '4mb' });
app.post('/api/branding/upload', uploadJson, adminGuard, (req: Request, res: Response) => {
if (!configManager) {
res.status(503).json({ ok: false, error: 'ConfigManager unavailable' });
return;
}
try {
const { kind, filename, contentBase64 } = req.body ?? {};
if (!ALLOWED_KINDS.includes(kind)) {
res.status(400).json({ ok: false, error: 'kind must be "logo" or "favicon"' });
return;
}
if (typeof filename !== 'string' || typeof contentBase64 !== 'string') {
res.status(400).json({ ok: false, error: 'filename and contentBase64 are required strings' });
return;
}
const ext = sanitizeExt(filename);
if (!ext || !ALLOWED_EXTENSIONS[kind as AssetKind].includes(ext)) {
res.status(400).json({
ok: false,
error: `extension must be one of ${ALLOWED_EXTENSIONS[kind as AssetKind].join(', ')}`,
});
return;
}
let buf: Buffer;
try {
buf = Buffer.from(contentBase64, 'base64');
} catch {
res.status(400).json({ ok: false, error: 'invalid base64 content' });
return;
}
if (buf.length === 0) {
res.status(400).json({ ok: false, error: 'empty file' });
return;
}
if (buf.length > MAX_SIZE_BYTES[kind as AssetKind]) {
res.status(413).json({
ok: false,
error: `file too large (max ${MAX_SIZE_BYTES[kind as AssetKind]} bytes)`,
});
return;
}
// Create branding directory on first upload
if (!existsSync(brandingDir)) {
mkdirSync(brandingDir, { recursive: true });
}
// Clean up any previously stored asset of this kind so we don't
// accumulate orphaned files when the admin re-uploads.
removeExistingAsset(brandingDir, kind as AssetKind);
// Hash-suffixed filename provides unique URL for cache busting
const hash = randomBytes(6).toString('hex');
const storedName = `${kind}-${hash}${ext}`;
writeFileSync(join(brandingDir, storedName), buf);
const publicUrl = `/branding/${storedName}`;
const configKey = kind === 'logo' ? 'logoUrl' : 'faviconUrl';
const result = configManager.updateConfig({ branding: { [configKey]: publicUrl } });
if (!result.ok) {
// Rollback: delete the file we just wrote, otherwise the config and
// filesystem disagree forever.
try { unlinkSync(join(brandingDir, storedName)); } catch { /* best effort */ }
res.status(500).json({ ok: false, error: 'Failed to persist config', detail: result });
return;
}
logger.info(`[branding] uploaded ${kind} -> ${publicUrl} (${buf.length} bytes)`);
res.json({ ok: true, kind, url: publicUrl });
} catch (e) {
logger.warn(`[branding] upload failed: ${e}`);
res.status(500).json({ ok: false, error: String(e) });
}
});
app.delete('/api/branding/upload', adminGuard, (req: Request, res: Response) => {
if (!configManager) {
res.status(503).json({ ok: false, error: 'ConfigManager unavailable' });
return;
}
try {
const kind = req.query.kind;
if (typeof kind !== 'string' || !ALLOWED_KINDS.includes(kind as AssetKind)) {
res.status(400).json({ ok: false, error: 'query.kind must be "logo" or "favicon"' });
return;
}
const currentUrl = assetUrlFromConfig(configManager, kind as AssetKind);
removeAssetByUrl(brandingDir, currentUrl);
const configKey = kind === 'logo' ? 'logoUrl' : 'faviconUrl';
const result = configManager.updateConfig({ branding: { [configKey]: '' } });
if (!result.ok) {
res.status(500).json({ ok: false, error: 'Failed to persist config', detail: result });
return;
}
logger.info(`[branding] cleared ${kind}`);
res.json({ ok: true });
} catch (e) {
logger.warn(`[branding] delete failed: ${e}`);
res.status(500).json({ ok: false, error: String(e) });
}
});
}
+318
View File
@@ -0,0 +1,318 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { createBrowserApi } from './browser-api.js';
import {
type SessionManager,
type BrowserSession,
CAPTCHA_POOL_SESSION_ID,
} from '../engine/browser-session.js';
import { unlinkSync } from 'fs';
// dev 環境では vendor/noVNC/vnc.html が存在しないため、
// 既存テストの期待 (available: true) を保つために isNovncStaticInstalled
// を true に固定する。novnc_not_installed 経路は専用テストで上書きする。
vi.mock('./novnc-proxy.js', async () => {
const actual = await vi.importActual<typeof import('./novnc-proxy.js')>('./novnc-proxy.js');
return {
...actual,
isNovncStaticInstalled: vi.fn(() => true),
};
});
const novncProxyMock = await import('./novnc-proxy.js');
/**
* 2026-05 redesign: API は CAPTCHA Pool (admin only) と Task Session
* (visibility ベース) を分離。テストダブルも kind / taskId / captchaPending
* を扱える形にしている。
*/
class FakeSessionManager {
private sessions = new Map<string, BrowserSession>();
releaseCalls: string[] = [];
destroyCalls: string[] = [];
preload(partial: Partial<BrowserSession> & Pick<BrowserSession, 'id' | 'kind'>): BrowserSession {
const now = new Date();
const full: BrowserSession = {
id: partial.id,
kind: partial.kind,
taskId: partial.taskId,
userId: partial.userId,
browser: undefined as never,
context: undefined as never,
vncPort: 5900,
novncPort: 6900,
userDataDir: '/tmp/test',
state: partial.state ?? 'ready',
xvfbProcess: undefined as never,
x11vncProcess: undefined as never,
websockifyProcess: undefined as never,
display: ':99',
createdAt: partial.createdAt ?? now,
lastActiveAt: partial.lastActiveAt ?? now,
lockedByJobId: partial.lockedByJobId ?? null,
captchaPending: partial.captchaPending,
};
this.sessions.set(full.id, full);
return full;
}
// --- SessionManager 互換 ---
listSessions(): BrowserSession[] { return Array.from(this.sessions.values()); }
getSession(id: string): BrowserSession | undefined { return this.sessions.get(id); }
async destroySession(id: string): Promise<void> {
this.destroyCalls.push(id);
this.sessions.delete(id);
}
releaseToAgent(id: string): void { this.releaseCalls.push(id); }
}
function makeApp(
sessionManager: FakeSessionManager,
repo: Repository,
user?: { id: string; role: 'admin' | 'user'; orgIds?: string[] },
): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
if (user) {
(req as unknown as { user: unknown }).user = {
id: user.id,
role: user.role,
status: 'active',
orgIds: user.orgIds ?? [],
email: `${user.id}@example.com`,
name: user.id,
avatarUrl: null,
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
}
next();
});
app.use('/api/local/browser/sessions', createBrowserApi(sessionManager as unknown as SessionManager, repo));
return app;
}
async function createTask(
repo: Repository,
ownerId: string | null,
visibility: 'private' | 'org' | 'public' = 'private',
visibilityScopeOrgId: string | null = null,
): Promise<number> {
const task = await repo.createLocalTask({
title: 't', body: 'b', pieceName: 'general',
profile: 'auto', outputFormat: 'plain', askPolicy: 'allow',
priority: 0, ownerId, visibility, visibilityScopeOrgId,
});
return task.id;
}
describe('Browser API', () => {
let repo: Repository;
const dbPath = './_test_browser_api.db';
beforeEach(() => {
repo = new Repository(dbPath);
runMigrations(repo.getDb());
});
afterEach(() => {
repo.close();
try { unlinkSync(dbPath); } catch { /* ignore */ }
});
describe('GET /captcha-pool', () => {
it('returns available:false when no pool exists', async () => {
const sm = new FakeSessionManager();
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false });
});
it('returns pool info to admin', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool', captchaPending: true });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
expect(res.body.captchaPending).toBe(true);
expect(res.body.novncPath).toContain(CAPTCHA_POOL_SESSION_ID);
});
it('rejects non-admin', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(403);
});
});
describe('DELETE /captcha-pool', () => {
it('admin can destroy the pool', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).delete('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(200);
expect(sm.destroyCalls).toContain(CAPTCHA_POOL_SESSION_ID);
});
it('rejects non-admin', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).delete('/api/local/browser/sessions/captcha-pool');
expect(res.status).toBe(403);
expect(sm.destroyCalls).toEqual([]);
});
});
describe('GET /task-session/:taskId', () => {
it('returns available:false when no session for that task', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false });
});
it('owner can see their own task session', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
expect(res.body.sessionId).toBe('sess-1');
});
it('non-owner is told available:false (no info leak)', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice', 'private');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false });
});
it('admin can see any task session', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice', 'private');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
});
it('public task session is visible to anyone', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice', 'public');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body.available).toBe(true);
});
it('returns reason:novnc_not_installed when vnc.html is missing', async () => {
const spy = vi.mocked(novncProxyMock.isNovncStaticInstalled).mockReturnValueOnce(false);
try {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, reason: 'novnc_not_installed' });
} finally {
spy.mockRestore();
}
});
});
describe('POST /task-session/:taskId/release', () => {
it('owner can release their own task session', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).post(`/api/local/browser/sessions/task-session/${taskId}/release`);
expect(res.status).toBe(200);
expect(sm.destroyCalls).toEqual(['sess-1']);
});
it('non-owner is rejected', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).post(`/api/local/browser/sessions/task-session/${taskId}/release`);
expect(res.status).toBe(403);
expect(sm.destroyCalls).toEqual([]);
});
});
describe('GET /', () => {
it('lists only visible task sessions and excludes the pool', async () => {
const sm = new FakeSessionManager();
const aliceTaskId = await createTask(repo, 'alice', 'private');
const bobTaskId = await createTask(repo, 'bob', 'private');
sm.preload({ id: 'pool', kind: 'pool' }); // 除外されるはず
sm.preload({ id: 'sess-alice', kind: 'task', taskId: String(aliceTaskId), userId: 'alice' });
sm.preload({ id: 'sess-bob', kind: 'task', taskId: String(bobTaskId), userId: 'bob' });
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
const res = await request(app).get('/api/local/browser/sessions/');
expect(res.status).toBe(200);
const ids = res.body.sessions.map((s: { id: string }) => s.id).sort();
expect(ids).toEqual(['sess-alice']);
});
it('admin sees all task sessions but not the pool', async () => {
const sm = new FakeSessionManager();
const aliceTaskId = await createTask(repo, 'alice');
const bobTaskId = await createTask(repo, 'bob');
sm.preload({ id: 'pool', kind: 'pool' });
sm.preload({ id: 'sess-alice', kind: 'task', taskId: String(aliceTaskId) });
sm.preload({ id: 'sess-bob', kind: 'task', taskId: String(bobTaskId) });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get('/api/local/browser/sessions/');
expect(res.status).toBe(200);
const ids = res.body.sessions.map((s: { id: string }) => s.id).sort();
expect(ids).toEqual(['sess-alice', 'sess-bob']);
});
});
describe('GET /:id', () => {
it('returns 404 for the pool sessionId (must use /captcha-pool)', async () => {
const sm = new FakeSessionManager();
sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
const res = await request(app).get(`/api/local/browser/sessions/${CAPTCHA_POOL_SESSION_ID}`);
expect(res.status).toBe(404);
});
});
describe('DELETE /:id', () => {
it('non-owner cannot destroy', async () => {
const sm = new FakeSessionManager();
const taskId = await createTask(repo, 'alice');
sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
const res = await request(app).delete('/api/local/browser/sessions/sess-1');
expect(res.status).toBe(404);
expect(sm.destroyCalls).toEqual([]);
});
});
});
+272
View File
@@ -0,0 +1,272 @@
import { Router, Request, Response } from 'express';
import { SessionManager, type BrowserSession, CAPTCHA_POOL_SESSION_ID } from '../engine/browser-session.js';
import type { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { buildNovncPath, isNovncStaticInstalled } from './novnc-proxy.js';
import { canUserSeeTask, canEditEntity } from './visibility.js';
/**
* 2026-05 redesign: CAPTCHA Pool (admin 専用) と Task Session (タスク
* visibility ベース) を分離した API。
*
* - GET /captcha-pool : admin only. Pool の noVNC パス + captchaPending
* - DELETE /captcha-pool : admin only. Pool を destroy (次の CAPTCHA で再生成)
* - GET /task-session/:taskId : visibility 通過なら novncPath、なければ available:false
* - POST /task-session/:taskId/release: owner or admin. その taskId の session を destroy
* - GET / : 自分が見えるタスクの task session 一覧 (Pool は除外)
* - GET /:id : 直 sessionId 指定 (admin / 旧来 owner)。kind=='task' のときは taskId 経由を推奨
* - DELETE /:id : admin or task owner
*
* 旧 /search-session, POST / は廃止 (Plan の clean break)。
*/
function isAdmin(req: Request): boolean {
const user = req.user as Express.User | undefined;
return user?.role === 'admin';
}
function getUser(req: Request): Express.User | undefined {
return req.user as Express.User | undefined;
}
/** auth 未設定 (dev モード) では req.user が undefined。その場合は全許可で互換維持 */
function isUnauthenticatedDev(req: Request): boolean {
return getUser(req) === undefined;
}
function serializeTaskSession(session: BrowserSession) {
return {
id: session.id,
kind: session.kind,
taskId: session.taskId,
state: session.state,
novncPath: buildNovncPath(session.id),
lockedByJobId: session.lockedByJobId,
createdAt: session.createdAt.toISOString(),
lastActiveAt: session.lastActiveAt.toISOString(),
};
}
/**
* 指定 task session に user がアクセスできるかを判定する (visibility ベース)。
* Pool は admin only。dev モードは全許可。
*/
async function canViewSession(
req: Request,
session: BrowserSession,
repo: Repository,
): Promise<boolean> {
if (isUnauthenticatedDev(req)) return true;
const user = getUser(req)!;
if (user.role === 'admin') return true;
if (session.kind === 'pool') return false;
if (session.kind === 'task' && session.taskId) {
const taskIdNum = Number(session.taskId);
if (!Number.isFinite(taskIdNum)) return false;
const task = await repo.getLocalTask(taskIdNum);
return task ? canUserSeeTask(user, task) : false;
}
// 旧来 (kind 未設定): owner だけ
return session.userId === user.id;
}
/**
* 指定 task session を user が destroy / release できるかを判定する。
* - admin: 常に可
* - task owner: 可
* - dev モード: 可
*/
async function canControlSession(
req: Request,
session: BrowserSession,
repo: Repository,
): Promise<boolean> {
if (isUnauthenticatedDev(req)) return true;
const user = getUser(req)!;
if (user.role === 'admin') return true;
if (session.kind === 'pool') return false;
if (session.kind === 'task' && session.taskId) {
const taskIdNum = Number(session.taskId);
if (!Number.isFinite(taskIdNum)) return false;
const task = await repo.getLocalTask(taskIdNum);
if (!task) return false;
return canEditEntity(user, task);
}
return session.userId === user.id;
}
export function createBrowserApi(sessionManager: SessionManager | null, repo: Repository): Router {
const router = Router();
// sessionManager が null (Xvfb 等が無い環境) でも /captcha-pool / /task-session
// は available: false を返したい。503 にしてしまうと UI 側でエラー扱いされてしまう。
if (!sessionManager) {
router.get('/captcha-pool', (_req: Request, res: Response) => {
res.json({ available: false });
});
router.get('/task-session/:taskId', (_req: Request, res: Response) => {
res.json({ available: false });
});
router.all('*', (_req: Request, res: Response) => {
res.status(503).json({ error: 'Browser sessions not available (missing system dependencies)' });
});
return router;
}
// --- CAPTCHA Pool (admin only) ---
router.get('/captcha-pool', (req: Request, res: Response) => {
if (!isUnauthenticatedDev(req) && !isAdmin(req)) {
res.status(403).json({ error: 'Admin role required' });
return;
}
const pool = sessionManager.getSession(CAPTCHA_POOL_SESSION_ID);
if (!pool) {
res.json({ available: false });
return;
}
if (!isNovncStaticInstalled()) {
res.json({ available: false, reason: 'novnc_not_installed' });
return;
}
res.json({
available: true,
sessionId: pool.id,
novncPath: buildNovncPath(pool.id),
display: pool.display,
captchaPending: pool.captchaPending === true,
createdAt: pool.createdAt.toISOString(),
});
});
router.delete('/captcha-pool', async (req: Request, res: Response) => {
if (!isUnauthenticatedDev(req) && !isAdmin(req)) {
res.status(403).json({ error: 'Admin role required' });
return;
}
// Pool destroy 時は web.ts の persistentContexts も連動して破棄する
// (Cookie の生残りで認証状態が混乱するのを防ぐ)
try {
const webMod = await import('../engine/tools/web.js') as { clearPersistentContexts?: () => void };
webMod.clearPersistentContexts?.();
} catch { /* ignore */ }
await sessionManager.destroySession(CAPTCHA_POOL_SESSION_ID);
res.json({ ok: true });
});
// --- Task Session (visibility-aware) ---
router.get('/task-session/:taskId', async (req: Request, res: Response) => {
const taskId = req.params.taskId;
const session = sessionManager
.listSessions()
.find((s) => s.kind === 'task' && s.taskId === taskId);
if (!session) {
res.json({ available: false });
return;
}
if (!(await canViewSession(req, session, repo))) {
// 認可失敗は available: false にして session 存在情報を漏らさない
res.json({ available: false });
return;
}
if (!isNovncStaticInstalled()) {
// session は存在するが、iframe で読む vnc.html が配置されていない。
// UI 側で「scripts/setup-novnc.sh を実行してください」と案内する。
res.json({ available: false, reason: 'novnc_not_installed' });
return;
}
res.json({
available: true,
sessionId: session.id,
novncPath: buildNovncPath(session.id),
display: session.display,
state: session.state,
lockedByJobId: session.lockedByJobId,
createdAt: session.createdAt.toISOString(),
lastActiveAt: session.lastActiveAt.toISOString(),
});
});
router.post('/task-session/:taskId/release', async (req: Request, res: Response) => {
const taskId = req.params.taskId;
const session = sessionManager
.listSessions()
.find((s) => s.kind === 'task' && s.taskId === taskId);
if (!session) {
res.status(404).json({ error: 'Task session not found' });
return;
}
if (!(await canControlSession(req, session, repo))) {
res.status(403).json({ error: 'Forbidden' });
return;
}
await sessionManager.destroySession(session.id);
res.json({ ok: true });
});
// --- Generic list / detail (task sessions only; pool excluded) ---
router.get('/', async (req: Request, res: Response) => {
const taskSessions = sessionManager.listSessions().filter((s) => s.kind === 'task');
const visible: BrowserSession[] = [];
for (const s of taskSessions) {
if (await canViewSession(req, s, repo)) visible.push(s);
}
res.json({ sessions: visible.map(serializeTaskSession) });
});
router.get('/:id', async (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.id);
if (!session || session.kind === 'pool') {
// Pool は /captcha-pool 経由でのみアクセスさせる (id 直指定では不可)
res.status(404).json({ error: 'Session not found' });
return;
}
if (!(await canViewSession(req, session, repo))) {
res.status(404).json({ error: 'Session not found' });
return;
}
res.json(serializeTaskSession(session));
});
router.delete('/:id', async (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.id);
if (!session || session.kind === 'pool') {
res.status(404).json({ error: 'Session not found' });
return;
}
if (!(await canControlSession(req, session, repo))) {
res.status(404).json({ error: 'Session not found' });
return;
}
await sessionManager.destroySession(session.id);
res.json({ ok: true });
});
router.post('/:id/release', async (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.id);
if (!session || session.kind === 'pool') {
res.status(404).json({ error: 'Session not found' });
return;
}
if (!(await canControlSession(req, session, repo))) {
res.status(404).json({ error: 'Session not found' });
return;
}
sessionManager.releaseToAgent(session.id);
if (session.lockedByJobId) {
try {
await repo.updateJob(session.lockedByJobId, {
status: 'queued',
waitReason: null,
});
} catch (err) {
logger.warn(`[browser-api] failed to re-queue job ${session.lockedByJobId}: ${(err as Error).message}`);
}
}
res.json({ ok: true, state: 'agent_controlled' });
});
return router;
}
+277
View File
@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository, BrowserSessionRepo } from '../db/repository.js';
import { createBrowserSessionApi } from './browser-session-api.js';
import { initMasterKey, generateUserDek, encryptUserDek } from '../crypto/sessions.js';
import type { SessionManager } from '../engine/browser-session.js';
interface TestContext {
app: express.Application;
repository: Repository;
sessRepo: BrowserSessionRepo;
tempDir: string;
}
function buildApp(userId: string): TestContext {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const db = repository.getDb();
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
.run(userId, `${userId}@test`);
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
const master = initMasterKey(masterKeyPath);
// Pre-seed a DEK for the user
sessRepo.setUserDek(userId, encryptUserDek(master, generateUserDek()));
const app = express();
app.use(express.json());
// Stub req.user for auth-required tests
app.use((req, _res, next) => {
(req as { user?: unknown }).user = { id: userId, role: 'active' };
next();
});
app.use('/api/browser-sessions', createBrowserSessionApi({ sessRepo, sessionManager: null, masterKeyPath }));
return { app, repository, sessRepo, tempDir };
}
describe('browser-session-api', () => {
let ctx: TestContext | null = null;
afterEach(() => {
if (ctx) {
ctx.repository.close();
rmSync(ctx.tempDir, { recursive: true, force: true });
ctx = null;
}
});
it('lists empty for a new user', async () => {
ctx = buildApp('u1');
const res = await request(ctx.app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(200);
expect(res.body.profiles).toEqual([]);
});
it('creates a profile (status=pending, label echoes input)', async () => {
ctx = buildApp('u1');
const res = await request(ctx.app).post('/api/browser-sessions/profiles').send({
label: 'GitHub',
startUrl: 'https://github.com',
matchPatterns: ['https://github.com/**'],
storageOrigins: ['https://github.com'],
loginUrlPatterns: ['https://github.com/login**'],
});
expect(res.status).toBe(201);
expect(res.body.profile.status).toBe('pending');
expect(res.body.profile.label).toBe('GitHub');
// Must NOT leak the encrypted blob
expect(res.body.profile.encryptedStateBlob).toBeUndefined();
expect(res.body.profile.encrypted_state_blob).toBeUndefined();
});
it('deletes only profiles the user owns', async () => {
ctx = buildApp('u1');
// Owned profile: 200
const id = ctx.sessRepo.createProfile({
ownerId: 'u1', label: 'X', startUrl: 'https://x.com',
matchPatterns: [], storageOrigins: [], loginUrlPatterns: [],
});
const ok = await request(ctx.app).delete(`/api/browser-sessions/profiles/${id}`);
expect(ok.status).toBe(200);
// Non-existent: 404
const missing = await request(ctx.app).delete(`/api/browser-sessions/profiles/9999`);
expect(missing.status).toBe(404);
// Other user's profile: 404 (owner enforcement)
ctx.repository.getDb().prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES ('u2','u2@test','active','active',datetime('now'),datetime('now'))`).run();
const otherId = ctx.sessRepo.createProfile({
ownerId: 'u2', label: 'Other', startUrl: 'https://other.com',
matchPatterns: [], storageOrigins: [], loginUrlPatterns: [],
});
const forbidden = await request(ctx.app).delete(`/api/browser-sessions/profiles/${otherId}`);
expect(forbidden.status).toBe(404);
// Confirm it was NOT actually deleted (still exists for u2)
expect(ctx.sessRepo.getProfileById(otherId, 'u2')).not.toBeNull();
});
it('rejects unauthenticated requests', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-noauth-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const sessRepo = new BrowserSessionRepo(repository.getDb());
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// No req.user middleware → unauthenticated
app.use('/api/browser-sessions', createBrowserSessionApi({ sessRepo, sessionManager: null, masterKeyPath }));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthenticated');
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
// ── P2b: authActive-aware gate ─────────────────────────────────────────────
it('authActive=true still rejects unauthenticated requests', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-auth-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const sessRepo = new BrowserSessionRepo(repository.getDb());
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// authActive=true + no req.user → must still return 401
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: null,
masterKeyPath,
authActive: true,
}));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthenticated');
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('authActive=false falls back to synthetic local user (no-auth mode)', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-noauth-local-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const db = repository.getDb();
// Insert local user so FK constraints pass
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES ('local', 'local@localhost', 'active', 'active', datetime('now'), datetime('now'))`).run();
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// No req.user middleware, but authActive=false → should inject synthetic local user
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: null,
masterKeyPath,
authActive: false,
}));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(200);
expect(res.body.profiles).toEqual([]);
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
});
describe('login + save flow', () => {
let tempDirsToClean: string[] = [];
let repositoryToClose: Repository | null = null;
afterEach(() => {
if (repositoryToClose) {
repositoryToClose.close();
repositoryToClose = null;
}
for (const d of tempDirsToClean) {
rmSync(d, { recursive: true, force: true });
}
tempDirsToClean = [];
});
it('starts a login session, then save captures storageState and encrypts it', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-loginflow-'));
tempDirsToClean.push(tempDir);
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
repositoryToClose = repository;
const db = repository.getDb();
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
.run('u1', 'u1@test');
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
// Intentionally NOT pre-seeding a DEK — /save should lazily create one via ensureUserDek.
const id = sessRepo.createProfile({
ownerId: 'u1',
label: 'X',
startUrl: 'https://example.com',
matchPatterns: [],
storageOrigins: [],
loginUrlPatterns: [],
});
const fakeContext = {
pages: () => [],
newPage: async () => ({ goto: async () => null }),
storageState: async () => ({ cookies: [{ name: 's', value: '1' }], origins: [] }),
};
const fake = {
createLoginSession: async (_opts: unknown) => ({
id: 'sess1',
kind: 'login',
profileId: id,
context: fakeContext,
browser: { isConnected: () => true },
display: ':99',
}),
getSession: () => ({
id: 'sess1',
kind: 'login',
profileId: id,
context: fakeContext,
}),
destroySession: async () => {},
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as { user?: unknown }).user = { id: 'u1', role: 'active' };
next();
});
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: fake as unknown as SessionManager,
masterKeyPath,
}));
const start = await request(app).post(`/api/browser-sessions/profiles/${id}/login`);
expect(start.status).toBe(200);
expect(start.body.sessionId).toBe('sess1');
expect(start.body.novncPath).toContain('sess1');
const save = await request(app)
.post(`/api/browser-sessions/profiles/${id}/save`)
.send({ sessionId: 'sess1' });
expect(save.status).toBe(200);
const profile = sessRepo.getProfileById(id, 'u1')!;
expect(profile).not.toBeNull();
expect(profile.status).toBe('active');
expect(profile.encryptedStateBlob).not.toBeNull();
expect(profile.encryptedStateBlob!.length).toBeGreaterThan(16);
// Sanity: the API response itself must NOT leak the encrypted blob.
expect(save.body.profile.encryptedStateBlob).toBeUndefined();
expect(save.body.profile.encrypted_state_blob).toBeUndefined();
expect(save.body.profile.status).toBe('active');
});
});
+483
View File
@@ -0,0 +1,483 @@
import { Router, type Request, type Response } from 'express';
import { readFileSync } from 'fs';
import { createRequire } from 'module';
import type { BrowserSessionRepo, BrowserSessionProfile } from '../db/browser-session-repo.js';
import type { SessionManager } from '../engine/browser-session.js';
import {
initMasterKey,
generateUserDek,
encryptUserDek,
decryptUserDek,
encryptStateBlob,
decryptStateBlob,
} from '../crypto/sessions.js';
import { buildNovncPath } from './novnc-proxy.js';
import { logger } from '../logger.js';
const requireFromHere = createRequire(import.meta.url);
let cachedPlaywrightVersion: string | null = null;
function getPlaywrightVersion(): string {
if (cachedPlaywrightVersion) return cachedPlaywrightVersion;
try {
const pkgPath = requireFromHere.resolve('playwright/package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string };
cachedPlaywrightVersion = pkg.version;
} catch {
cachedPlaywrightVersion = 'unknown';
}
return cachedPlaywrightVersion;
}
interface Deps {
sessRepo: BrowserSessionRepo;
sessionManager: SessionManager | null;
masterKeyPath: string;
authActive?: boolean;
}
interface AuthedUser {
id: string;
role: string;
}
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
/** JSON-friendly subset of a profile. NEVER includes the encrypted blob. */
function serializeProfile(p: BrowserSessionProfile): {
id: number;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector: string | null;
loginUrlPatterns: string[];
status: BrowserSessionProfile['status'];
stateVersion: number;
lastSavedAt: string | null;
lastUsedAt: string | null;
lastValidatedAt: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
} {
return {
id: p.id,
label: p.label,
startUrl: p.startUrl,
matchPatterns: p.matchPatterns,
storageOrigins: p.storageOrigins,
loggedInSelector: p.loggedInSelector,
loginUrlPatterns: p.loginUrlPatterns,
status: p.status,
stateVersion: p.stateVersion,
lastSavedAt: p.lastSavedAt,
lastUsedAt: p.lastUsedAt,
lastValidatedAt: p.lastValidatedAt,
lastError: p.lastError,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
}
/**
* Ensure the user has a DEK, creating + persisting one if needed.
* Returns the decrypted DEK (32 bytes) for use with state-blob encryption.
*/
export function ensureUserDek(deps: Deps, userId: string): Buffer {
const master = initMasterKey(deps.masterKeyPath);
let enc = deps.sessRepo.getUserDek(userId);
if (!enc) {
const dek = generateUserDek();
enc = encryptUserDek(master, dek);
deps.sessRepo.setUserDek(userId, enc);
return dek;
}
return decryptUserDek(master, enc);
}
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(s => typeof s === 'string');
}
export function createBrowserSessionApi(deps: Deps): Router {
const r = Router();
const authActive = deps.authActive ?? true;
// Auth gate — every request must have req.user.
// In no-auth mode (authActive=false), fall back to a synthetic 'local' user
// so the Browser Sessions panel works in local dev without OAuth.
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// GET /profiles — list owned profiles
r.get('/profiles', (req: Request, res: Response) => {
const u = getUser(req)!;
const profiles = deps.sessRepo.listProfilesByOwner(u.id).map(serializeProfile);
res.json({ profiles });
});
// POST /profiles — create a new profile (status=pending, no blob yet)
r.post('/profiles', (req: Request, res: Response) => {
const u = getUser(req)!;
const b = (req.body ?? {}) as Record<string, unknown>;
if (typeof b['label'] !== 'string' || (b['label'] as string).length === 0) {
res.status(400).json({ error: 'label is required and must be a non-empty string' });
return;
}
if (typeof b['startUrl'] !== 'string' || (b['startUrl'] as string).length === 0) {
res.status(400).json({ error: 'startUrl is required and must be a non-empty string' });
return;
}
if (b['matchPatterns'] !== undefined && !isStringArray(b['matchPatterns'])) {
res.status(400).json({ error: 'matchPatterns must be a string[]' });
return;
}
if (b['storageOrigins'] !== undefined && !isStringArray(b['storageOrigins'])) {
res.status(400).json({ error: 'storageOrigins must be a string[]' });
return;
}
if (b['loginUrlPatterns'] !== undefined && !isStringArray(b['loginUrlPatterns'])) {
res.status(400).json({ error: 'loginUrlPatterns must be a string[]' });
return;
}
if (b['loggedInSelector'] !== undefined && b['loggedInSelector'] !== null && typeof b['loggedInSelector'] !== 'string') {
res.status(400).json({ error: 'loggedInSelector must be a string or null' });
return;
}
const id = deps.sessRepo.createProfile({
ownerId: u.id,
label: b['label'] as string,
startUrl: b['startUrl'] as string,
matchPatterns: isStringArray(b['matchPatterns']) ? b['matchPatterns'] : [],
storageOrigins: isStringArray(b['storageOrigins']) ? b['storageOrigins'] : [],
loggedInSelector: typeof b['loggedInSelector'] === 'string' ? b['loggedInSelector'] : null,
loginUrlPatterns: isStringArray(b['loginUrlPatterns']) ? b['loginUrlPatterns'] : [],
});
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'create',
result: 'success',
});
const profile = deps.sessRepo.getProfileById(id, u.id);
res.status(201).json({ profile: serializeProfile(profile!) });
});
// DELETE /profiles/:id — owner-only delete
r.delete('/profiles/:id', (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const ok = deps.sessRepo.deleteProfile(id, u.id);
if (!ok) {
// Either not found or not owned — same response either way.
// Do NOT audit on failure (per checklist).
res.status(404).json({ error: 'not found' });
return;
}
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'delete',
result: 'success',
});
res.json({ ok: true });
});
// POST /profiles/:id/login — spawn an interactive noVNC session for the owner
r.post('/profiles/:id/login', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile) {
res.status(404).json({ error: 'not found' });
return;
}
if (!deps.sessionManager) {
res.status(503).json({ error: 'browser sessions unavailable (missing Xvfb/x11vnc/websockify)' });
return;
}
try {
const session = await deps.sessionManager.createLoginSession({ ownerId: u.id, profileId: id });
// Navigate to start_url for the user (best-effort; do not fail the endpoint on goto error).
try {
const ctx = session.context;
if (ctx) {
const pages = ctx.pages();
const page = pages.length > 0 ? pages[0]! : await ctx.newPage();
await page.goto(profile.startUrl, { waitUntil: 'load', timeout: 60_000 });
}
} catch (gotoErr) {
logger.warn(`[browser-session-api] login goto failed: ${(gotoErr as Error).message}`);
}
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'login_start',
result: 'success',
});
res.json({ sessionId: session.id, novncPath: buildNovncPath(session.id) });
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'login_start',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
}
});
// POST /profiles/:id/save — capture context.storageState(), encrypt, persist, destroy session
r.post('/profiles/:id/save', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const sessionId = (req.body as { sessionId?: string } | undefined)?.sessionId;
if (!sessionId) {
res.status(400).json({ error: 'sessionId required' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile) {
res.status(404).json({ error: 'not found' });
return;
}
if (!deps.sessionManager) {
res.status(503).json({ error: 'browser sessions unavailable' });
return;
}
const session = deps.sessionManager.getSession(sessionId);
if (!session || session.kind !== 'login' || session.profileId !== id) {
res.status(400).json({ error: 'login session does not match profile' });
return;
}
try {
if (!session.context) {
throw new Error('login session has no browser context');
}
const state = await session.context.storageState();
const dek = ensureUserDek(deps, u.id);
const blob = encryptStateBlob(dek, JSON.stringify(state));
deps.sessRepo.saveProfileBlob(id, blob, getPlaywrightVersion());
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'save',
result: 'success',
});
await deps.sessionManager.destroySession(sessionId);
const fresh = deps.sessRepo.getProfileById(id, u.id)!;
res.json({ profile: serializeProfile(fresh) });
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'save',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
}
});
// POST /profiles/:id/cancel — abort an in-progress login session
r.post('/profiles/:id/cancel', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const sessionId = (req.body as { sessionId?: string } | undefined)?.sessionId;
if (!sessionId) {
res.status(400).json({ error: 'sessionId required' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile || !deps.sessionManager) {
res.status(404).json({ error: 'not found' });
return;
}
await deps.sessionManager.destroySession(sessionId);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'login_cancel',
result: 'success',
});
res.json({ ok: true });
});
// POST /profiles/:id/test — decrypt the saved state, drive a real headless
// chromium against profile.startUrl, and update the profile's status based
// on the auth-expiry heuristics in browser-session-expiry.
r.post('/profiles/:id/test', async (req: Request, res: Response) => {
const u = getUser(req)!;
const id = Number(req.params['id']);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const profile = deps.sessRepo.getProfileById(id, u.id);
if (!profile) {
res.status(404).json({ error: 'not found' });
return;
}
if (profile.encryptedStateBlob == null) {
res.status(409).json({ error: 'profile has no saved state' });
return;
}
// Lazy-load Playwright so the bridge layer doesn't require it at import time.
let chromium: typeof import('playwright').chromium;
try {
const pw = await import('playwright');
chromium = pw.chromium;
} catch (e) {
const msg = (e as Error).message;
logger.warn(`[browser-session-api] playwright import failed: ${msg}`);
res.status(503).json({ error: 'playwright not available' });
return;
}
let stateJson: string;
try {
const dek = ensureUserDek(deps, u.id);
stateJson = decryptStateBlob(dek, profile.encryptedStateBlob);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'decrypt',
result: 'success',
reason: 'test',
});
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'decrypt',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
return;
}
// detectAuthExpiry is loaded via dynamic import to avoid any circular-dep
// surprises between the bridge and engine layers.
const { detectAuthExpiry } = await import('../engine/browser-session-expiry.js');
let browser: import('playwright').Browser | null = null;
try {
const { buildLaunchOptions, applyStealthInitScript } = await import('../engine/browser-launch.js');
const { loadConfig } = await import('../config.js');
browser = await chromium.launch(buildLaunchOptions(loadConfig().browser, true));
const ctx = await browser.newContext({ storageState: JSON.parse(stateJson) });
await applyStealthInitScript(ctx);
const page = await ctx.newPage();
const response = await page.goto(profile.startUrl, { waitUntil: 'load', timeout: 60_000 });
const finalUrl = page.url();
const statusCode = response?.status() ?? 0;
const present = profile.loggedInSelector
? !!(await page.$(profile.loggedInSelector))
: true;
const verdict = detectAuthExpiry({
profile: {
loggedInSelector: profile.loggedInSelector,
loginUrlPatterns: profile.loginUrlPatterns,
},
finalUrl,
statusCode,
loggedInSelectorPresent: present,
});
if (verdict.expired) {
deps.sessRepo.markProfileStatus(id, 'expired', verdict.reason);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'expire',
result: 'success',
reason: verdict.reason,
});
} else {
deps.sessRepo.markProfileStatus(id, 'active', null);
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'test',
result: 'success',
});
}
res.json({ verdict, finalUrl, statusCode });
} catch (e) {
const msg = (e as Error).message;
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: u.id,
profileId: id,
action: 'test',
result: 'error',
reason: msg,
});
res.status(500).json({ error: msg });
} finally {
if (browser) {
try {
await browser.close();
} catch (closeErr) {
logger.warn(`[browser-session-api] browser.close failed: ${(closeErr as Error).message}`);
}
}
}
});
return r;
}
+509
View File
@@ -0,0 +1,509 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { ConfigManager } from '../config-manager.js';
import { mountConfigApi, __clearWorkerBackendsCache } from './config-api.js';
describe('Config API', () => {
let app: express.Application;
let cm: ConfigManager;
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'config-api-'));
writeFileSync(join(tempDir, 'config.yaml'), [
'config_version: 2',
'llm:',
' workers:',
' - id: w1',
' connection_type: direct',
' endpoint: http://localhost:11434/v1',
' model: test-model',
' roles: [auto, fast, quality]',
' max_concurrency: 1',
' enabled: true',
].join('\n'));
cm = new ConfigManager(join(tempDir, 'config.yaml'));
app = express();
app.use(express.json());
mountConfigApi(app, cm);
});
it('GET /api/config returns v2 shape with etag', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.configVersion).toBe(2);
expect(res.body.config.llm.workers[0].model).toBe('test-model');
expect(res.headers.etag).toBeDefined();
});
it('GET /api/config omits legacy provider block and flat storage keys', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.provider).toBeUndefined();
expect(res.body.config.worktreeDir).toBeUndefined();
expect(res.body.config.customPiecesDir).toBeUndefined();
expect(res.body.config.userFolderRoot).toBeUndefined();
});
it('GET /api/config exposes storage.* block when set', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'config_version: 2',
'llm:',
' workers:',
' - id: w1',
' connection_type: direct',
' endpoint: http://x/v1',
' model: m',
'storage:',
' worktree_dir: /tmp/wt',
' custom_pieces_dir: /tmp/pieces',
' task_upload_max_size_mb: 25',
' trash_retention_days: 14',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.config.storage).toEqual({
worktreeDir: '/tmp/wt',
customPiecesDir: '/tmp/pieces',
taskUploadMaxSizeMb: 25,
trashRetentionDays: 14,
});
// Legacy tools.taskUploadMaxSizeMb / tools.trashRetentionDays must not
// appear under tools.* in v2 output.
expect(res.body.config.tools?.taskUploadMaxSizeMb).toBeUndefined();
expect(res.body.config.tools?.trashRetentionDays).toBeUndefined();
});
it('PUT /api/config updates llm.workers and round-trips through YAML', async () => {
const getRes = await request(app).get('/api/config');
const etag = getRes.headers.etag;
const res = await request(app)
.put('/api/config')
.set('If-Match', etag)
.send({
llm: {
workers: [{
id: 'w1',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'updated-model',
roles: ['auto', 'fast', 'quality'],
maxConcurrency: 1,
enabled: true,
}],
},
});
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(cm.getConfig().llm?.workers[0]?.model).toBe('updated-model');
// YAML on disk: snake_case + config_version: 2 stamped + no legacy provider block
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('updated-model');
expect(yaml).toContain('config_version: 2');
expect(yaml).toContain('llm:');
expect(yaml).not.toMatch(/^provider:/m);
expect(yaml).not.toMatch(/connectionType/);
});
it('PUT /api/config force-stamps config_version=2 when omitted', async () => {
const getRes = await request(app).get('/api/config');
const etag = getRes.headers.etag;
const res = await request(app)
.put('/api/config')
.set('If-Match', etag)
.send({ concurrency: 4 });
expect(res.status).toBe(200);
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('config_version: 2');
});
it('PUT /api/config rejects body with legacy provider block (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ provider: { model: 'x' } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('provider');
expect(res.body.error).toMatch(/llm\.\*/);
});
it('PUT /api/config rejects body with flat worktreeDir key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ worktreeDir: '/tmp/wt' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('worktreeDir');
expect(res.body.error).toMatch(/storage\.worktreeDir/);
});
it('PUT /api/config rejects body with flat customPiecesDir key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ customPiecesDir: '/tmp/p' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('customPiecesDir');
});
it('PUT /api/config rejects body with flat userFolderRoot key (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ userFolderRoot: '/tmp/users' });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('userFolderRoot');
});
it('PUT /api/config rejects body with tools.taskUploadMaxSizeMb (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ tools: { taskUploadMaxSizeMb: 50 } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('tools.taskUploadMaxSizeMb');
expect(res.body.error).toMatch(/storage\.taskUploadMaxSizeMb/);
});
it('PUT /api/config rejects body with tools.trashRetentionDays (400)', async () => {
const res = await request(app)
.put('/api/config')
.send({ tools: { trashRetentionDays: 7 } });
expect(res.status).toBe(400);
expect(res.body.rejectedKey).toBe('tools.trashRetentionDays');
});
it('PUT /api/config writes storage.* round-trip cleanly', async () => {
const res = await request(app)
.put('/api/config')
.send({
storage: {
worktreeDir: '/var/lib/aao',
customPiecesDir: '/etc/aao/pieces',
},
});
expect(res.status).toBe(200);
const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
expect(yaml).toContain('storage:');
expect(yaml).toContain('worktree_dir: /var/lib/aao');
expect(yaml).toContain('custom_pieces_dir: /etc/aao/pieces');
// No flat legacy storage keys should appear at top level
expect(yaml).not.toMatch(/^worktree_dir:/m);
expect(yaml).not.toMatch(/^custom_pieces_dir:/m);
});
it('PUT /api/config returns 409 on stale etag', async () => {
const res = await request(app)
.put('/api/config')
.set('If-Match', 'stale-etag')
.send({ concurrency: 2 });
expect(res.status).toBe(409);
});
it('POST /api/config/reload reloads from file', async () => {
const res = await request(app).post('/api/config/reload');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
describe('GET /api/workers', () => {
it('returns the synthesized default worker when no workers are configured', async () => {
// Override the beforeEach v2 fixture with a v1-style empty provider
// to exercise loadConfig's "no workers" auto-gen path.
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: test-model',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(1);
expect(res.body.workers[0].id).toBe('default');
});
it('returns workers from config with allowlisted fields only', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: shared-model',
' workers:',
' - id: gpu1',
' endpoint: http://10.0.0.10:11434/v1',
' model: qwen3:8b',
' roles: [auto, fast]',
' enabled: true',
' api_key: super-secret-do-not-leak',
' - id: gpu2',
' endpoint: http://10.0.0.10:11434/v1',
' enabled: false',
' retry:',
' max_attempts: 1',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(2);
const [w1, w2] = res.body.workers;
expect(w1).toEqual({
id: 'gpu1',
endpoint: 'http://10.0.0.10:11434/v1',
model: 'qwen3:8b',
roles: ['auto', 'fast'],
enabled: true,
proxy: false,
});
expect(w2.id).toBe('gpu2');
expect(w2.enabled).toBe(false);
// sensitive fields must not leak
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain('api_key');
expect(serialized).not.toContain('super-secret');
});
it('exposes proxy + proxyType on proxy workers (no api_key leak)', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' model: shared-model',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' api_key: team-tok-do-not-leak',
' retry:',
' max_attempts: 1',
].join('\n'));
cm.reloadFromFile();
const res = await request(app).get('/api/workers');
expect(res.status).toBe(200);
expect(res.body.workers[0]).toEqual({
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
model: null,
roles: ['auto', 'fast', 'quality'],
enabled: true,
proxy: true,
proxyType: 'litellm',
});
expect(JSON.stringify(res.body)).not.toContain('team-tok-do-not-leak');
});
});
describe('GET /api/workers/:workerId/backends', () => {
beforeEach(() => {
__clearWorkerBackendsCache();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
__clearWorkerBackendsCache();
});
it('returns 404 for an unknown worker', async () => {
const res = await request(app).get('/api/workers/nope/backends');
expect(res.status).toBe(404);
});
it('returns source=direct with empty backends for non-proxy workers', async () => {
// beforeEach now seeds a v2 worker named w1, so probe its id.
const res = await request(app).get('/api/workers/w1/backends');
expect(res.status).toBe(200);
expect(res.body).toEqual({ source: 'direct', backends: [] });
});
it('fetches /v1/models from a proxy worker and returns the deployment list', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
' api_key: tok-xyz',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'gpu-rtx-a', object: 'model' },
{ id: 'gpu-h100-b', object: 'model' },
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(200);
expect(res.body).toEqual({
source: 'proxy',
proxyType: 'litellm',
backends: [
{ id: 'gpu-rtx-a', model: 'gpu-rtx-a', online: true },
{ id: 'gpu-h100-b', model: 'gpu-h100-b', online: true },
],
});
// Should have called the upstream with the worker's api_key
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0]!;
expect(call[0]).toBe('http://litellm:4000/v1/models');
expect((call[1] as RequestInit).headers).toMatchObject({
Authorization: 'Bearer tok-xyz',
});
});
it('caches successful proxy results across calls (60s TTL)', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'gpu-x' }] }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const r1 = await request(app).get('/api/workers/team-pool/backends');
const r2 = await request(app).get('/api/workers/team-pool/backends');
expect(r1.body).toEqual(r2.body);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('rejects file:// endpoint scheme without leaking apiKey to fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: file:///etc/passwd',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects data: endpoint scheme without leaking apiKey to fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: "data:text/plain,hi"',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects malformed endpoint URLs without calling fetch', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: "not-a-url"',
' proxy: true',
' api_key: tok-leak-target',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/invalid endpoint URL/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns 502 with error payload when the upstream proxy fails', async () => {
writeFileSync(join(tempDir, 'config.yaml'), [
'provider:',
' workers:',
' - id: team-pool',
' endpoint: http://litellm:4000/v1',
' proxy: true',
].join('\n'));
cm.reloadFromFile();
const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
vi.stubGlobal('fetch', fetchMock);
const res = await request(app).get('/api/workers/team-pool/backends');
expect(res.status).toBe(502);
expect(res.body.source).toBe('proxy');
expect(res.body.backends).toEqual([]);
expect(res.body.error).toContain('ECONNREFUSED');
});
});
describe('/api/workers auth guard', () => {
// server.ts wires `app.use('/api/workers', requireAuth)` when auth is
// active. config-api.ts itself doesn't mount the middleware (separation
// of concerns), so this suite asserts the same middleware shape works
// when callers mount it the way server.ts does.
let guardedApp: express.Application;
let isAuthed: boolean;
const fakeRequireAuth: express.RequestHandler = (_req, res, next) => {
if (isAuthed) {
next();
} else {
res.status(401).json({ error: 'unauthenticated' });
}
};
beforeEach(() => {
isAuthed = true;
guardedApp = express();
guardedApp.use(express.json());
guardedApp.use('/api/workers', fakeRequireAuth);
mountConfigApi(guardedApp, cm);
});
it('returns 401 for unauthenticated GET /api/workers', async () => {
isAuthed = false;
const res = await request(guardedApp).get('/api/workers');
expect(res.status).toBe(401);
});
it('returns 401 for unauthenticated GET /api/workers/:id/backends', async () => {
isAuthed = false;
const res = await request(guardedApp).get('/api/workers/default/backends');
expect(res.status).toBe(401);
});
it('returns 200 for authenticated GET /api/workers', async () => {
isAuthed = true;
const res = await request(guardedApp).get('/api/workers');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.workers)).toBe(true);
});
});
});
+260
View File
@@ -0,0 +1,260 @@
import { type Application, type Request, type Response } from 'express';
import { ConfigManager } from '../config-manager.js';
import { logger } from '../logger.js';
/**
* Backend list response for GET /api/workers/:workerId/backends.
*
* Direct workers always return { source: 'direct', backends: [] } because
* the worker itself IS the node — there is no proxy layer to fan out to.
* Proxy workers (proxy: true) return the deployment list reported by the
* upstream proxy's /v1/models endpoint.
*
* See docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
*/
export interface WorkerBackendsResponse {
source: 'direct' | 'proxy';
proxyType?: 'litellm';
backends: Array<{
id: string;
model: string | null;
online: boolean;
}>;
/** Set only when the upstream probe failed; UI uses this to render a degraded badge. */
error?: string;
}
interface BackendsCacheEntry {
expiresAt: number;
payload: WorkerBackendsResponse;
}
/**
* In-memory cache for proxy /v1/models lookups. Prevents the UI (which may
* mount PetsPanel multiple times per session) from hammering the upstream
* proxy. 60s matches the design doc and keeps backend list "fresh enough"
* without coupling to a hard refresh button.
*/
const BACKENDS_CACHE_TTL_MS = 60_000;
const backendsCache = new Map<string, BackendsCacheEntry>();
/** Test hook: clear cache between tests so each case starts fresh. */
export function __clearWorkerBackendsCache(): void {
backendsCache.clear();
}
/**
* Top-level keys that v2 `PUT /api/config` rejects with 400. The migration
* to the v2 shape (design doc 2026-05-21) treats these as authored-in-the-
* wrong-shape errors: the UI is expected to send `llm.*` and `storage.*`
* instead. Returning 400 here (rather than silently dropping them in
* config-manager) gives the UI an actionable error message instead of a
* confusing "save succeeded but nothing changed" outcome.
*/
const V2_REJECTED_TOP_LEVEL_KEYS: Record<string, string> = {
provider: "use 'llm.*' instead (provider block removed in config v2; run scripts/migrate-config.sh to convert)",
worktreeDir: "use 'storage.worktreeDir' instead (flat storage keys removed in config v2)",
customPiecesDir: "use 'storage.customPiecesDir' instead (flat storage keys removed in config v2)",
userFolderRoot: "use 'storage.userFolderRoot' instead (flat storage keys removed in config v2)",
};
function rejectLegacyV2Body(body: Record<string, unknown>): { key: string; message: string } | null {
for (const [key, message] of Object.entries(V2_REJECTED_TOP_LEVEL_KEYS)) {
if (key in body) return { key, message };
}
// tools.taskUploadMaxSizeMb / tools.trashRetentionDays migrated into
// storage.* — reject them too so the UI doesn't silently keep writing
// the old key.
const tools = (body as any).tools;
if (tools && typeof tools === 'object') {
if ('taskUploadMaxSizeMb' in tools) {
return {
key: 'tools.taskUploadMaxSizeMb',
message: "use 'storage.taskUploadMaxSizeMb' instead (moved out of tools.* in config v2)",
};
}
if ('trashRetentionDays' in tools) {
return {
key: 'tools.trashRetentionDays',
message: "use 'storage.trashRetentionDays' instead (moved out of tools.* in config v2)",
};
}
}
return null;
}
export function mountConfigApi(app: Application, configManager: ConfigManager): void {
app.get('/api/config', (_req: Request, res: Response) => {
const { config, etag, overriddenByEnv } = configManager.getConfigForApi();
res.set('ETag', etag);
res.json({ config, overriddenByEnv });
});
app.put('/api/config', (req: Request, res: Response) => {
if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) {
res.status(400).json({ ok: false, error: 'Request body must be a JSON object' }); return;
}
const rejected = rejectLegacyV2Body(req.body as Record<string, unknown>);
if (rejected) {
logger.warn(`[config-api] PUT /api/config rejected v1-shaped key '${rejected.key}'`);
res.status(400).json({
ok: false,
error: `legacy config key '${rejected.key}' is no longer accepted; ${rejected.message}`,
rejectedKey: rejected.key,
});
return;
}
const etag = req.headers['if-match'] as string | undefined;
const result = configManager.updateConfig(req.body, etag);
if (!result.ok) {
const status = (result as any).conflict ? 409 : 400;
res.status(status).json(result);
return;
}
res.json({ ok: true });
});
app.post('/api/config/reload', (_req: Request, res: Response) => {
try {
configManager.reloadFromFile();
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, message: String(e) });
}
});
app.get('/api/workers', (_req: Request, res: Response) => {
const cfg = configManager.getConfig();
const workers = (cfg.provider.workers ?? [])
.filter(w => typeof w.id === 'string' && w.id.length > 0)
.map(w => ({
id: w.id,
endpoint: w.endpoint ?? null,
model: w.model ?? null,
roles: Array.isArray(w.roles) ? w.roles : [],
enabled: w.enabled !== false,
proxy: w.proxy === true,
proxyType: w.proxy === true ? (w.proxyType ?? 'litellm') : undefined,
}));
res.json({ workers });
});
// GET /api/workers/:workerId/backends
//
// Returns the physical backends behind a worker. For direct workers this
// is a trivial empty list (the worker IS the node). For proxy workers
// (proxy: true), we proxy to <endpoint>/v1/models and translate the
// result into a uniform shape. Cached for 60s per worker so the panel
// can be re-rendered freely without hammering the upstream.
app.get('/api/workers/:workerId/backends', async (req: Request, res: Response) => {
const workerId = String(req.params['workerId'] ?? '');
const cfg = configManager.getConfig();
const worker = (cfg.provider.workers ?? []).find(w => w.id === workerId);
if (!worker) {
res.status(404).json({ error: 'worker not found' });
return;
}
if (worker.proxy !== true) {
res.json({ source: 'direct', backends: [] } satisfies WorkerBackendsResponse);
return;
}
const cacheKey = `${workerId}|${worker.endpoint}`;
const cached = backendsCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.payload);
return;
}
try {
const payload = await fetchProxyBackends(worker.endpoint, worker.apiKey);
backendsCache.set(cacheKey, { expiresAt: Date.now() + BACKENDS_CACHE_TTL_MS, payload });
res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn(`[config-api] /api/workers/${workerId}/backends failed: ${message}`);
const payload: WorkerBackendsResponse = {
source: 'proxy',
proxyType: 'litellm',
backends: [],
error: message,
};
// Cache the failure briefly so a flapping upstream doesn't cause a
// request storm. Shorter than success TTL on purpose.
backendsCache.set(cacheKey, { expiresAt: Date.now() + 10_000, payload });
res.status(502).json(payload);
}
});
}
interface LiteLLMModelEntry {
id?: unknown;
// LiteLLM's /v1/models response includes a few non-standard fields
// alongside the OpenAI-shape entries; we only consume `id`.
[key: string]: unknown;
}
/**
* Fetch the physical backend list from a LiteLLM-style proxy.
*
* Calls `<endpoint>/v1/models` and converts each entry into a NodeStatus-
* compatible backend record. v1 treats every returned id as `online: true`;
* Phase B's BackendStatusRegistry will replace this with /health-derived
* status when it lands.
*
* Exported for unit tests to drive end-to-end without a live proxy.
*/
export async function fetchProxyBackends(endpoint: string, apiKey?: string): Promise<WorkerBackendsResponse> {
// Defense-in-depth: validate the endpoint URL before we touch it. Without
// this guard, an admin (or a config write bug) could set the endpoint to
// `file://...`, `data:...`, or `javascript:...` and we'd happily ship the
// worker's apiKey as a Bearer token to whatever fetch() interprets it as.
// /api/workers is admin-adjacent but the apiKey here may be a shared GPU
// pool credential — leaking it to an arbitrary scheme is a credential
// exfil primitive, not just a logic bug.
//
// Allowlist http: and https:. v1 doesn't enforce a host allowlist (admin
// is trusted to point at the right proxy); scheme validation alone closes
// the credential-leak class.
let parsed: URL;
try {
parsed = new URL(endpoint);
} catch {
throw new Error(`invalid endpoint URL: ${endpoint}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`unsupported endpoint scheme: ${parsed.protocol} (only http: and https: are allowed)`);
}
// /v1/models is the canonical OpenAI-compatible discovery endpoint.
// LiteLLM honors it and returns the union of all configured deployments
// visible under the caller's virtual key.
const trimmed = endpoint.replace(/\/+$/, '');
const url = `${trimmed}/models`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const res = await fetch(url, { method: 'GET', headers });
if (!res.ok) {
throw new Error(`proxy /v1/models returned HTTP ${res.status}`);
}
const body = await res.json() as { data?: LiteLLMModelEntry[] } | LiteLLMModelEntry[];
const data: LiteLLMModelEntry[] = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
const seen = new Set<string>();
const backends: WorkerBackendsResponse['backends'] = [];
for (const entry of data) {
const id = typeof entry?.['id'] === 'string' ? (entry['id'] as string).trim() : '';
if (!id || seen.has(id)) continue;
seen.add(id);
// LiteLLM populates `id` with the model alias users request; deployment
// detail (litellm_params.model) is only returned via the admin
// `/model/info` endpoint, which requires admin auth. v1 surfaces just
// the alias; richer detail can be added in Phase B without changing
// the response shape.
backends.push({ id, model: id, online: true });
}
return { source: 'proxy', proxyType: 'litellm', backends };
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createConsoleAdminRouter } from './console-admin-api.js';
function mkApp(registry: any) {
const app = express();
app.use(express.json());
app.use('/api/admin', createConsoleAdminRouter({
registry,
requireAdmin: (_req: any, _res: any, next: any) => next(),
}));
return app;
}
describe('console admin API', () => {
it('lists active sessions', async () => {
const registry = {
listAll: () => [
{ localTaskId: 't1', connectionId: 'c1', ownerId: 'u1', startedAt: 1000,
lastActivityAt: 2000, totalInputBytes: 10, totalOutputBytes: 20, isClosed: false },
],
};
const res = await request(mkApp(registry)).get('/api/admin/ssh/console-sessions');
expect(res.status).toBe(200);
expect(res.body.sessions).toHaveLength(1);
expect(res.body.sessions[0].task_id).toBe('t1');
});
it('kills session by task id', async () => {
const registry = {
listAll: () => [],
closeForTask: vi.fn(async () => {}),
};
const res = await request(mkApp(registry))
.post('/api/admin/ssh/console-sessions/t1/kill')
.send({ reason: 'investigating' });
expect(res.status).toBe(200);
expect(res.body.closed).toBe(true);
expect(registry.closeForTask).toHaveBeenCalledWith('t1', 'admin_kill');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { Router, type Request, type Response } from 'express';
import type { SessionRegistry } from '../ssh/console-registry.js';
export function createConsoleAdminRouter(deps: {
registry: SessionRegistry;
requireAdmin: any;
}): Router {
const r = Router();
r.get('/ssh/console-sessions', deps.requireAdmin, (_req: Request, res: Response) => {
const sessions = deps.registry.listAll().map((s) => ({
task_id: s.localTaskId,
owner_id: s.ownerId,
connection_id: s.connectionId,
started_at: new Date(s.startedAt).toISOString(),
last_activity_at: new Date(s.lastActivityAt).toISOString(),
total_input_bytes: s.totalInputBytes,
total_output_bytes: s.totalOutputBytes,
}));
res.json({ sessions });
});
r.post('/ssh/console-sessions/:taskId/kill', deps.requireAdmin, async (req: Request, res: Response) => {
const taskId = req.params.taskId!;
await deps.registry.closeForTask(taskId, 'admin_kill');
res.json({ closed: true });
});
return r;
}
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
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 { runMigrations } from '../db/migrate.js';
import { createAccessResolver } from '../ssh/access.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
import type { SshConnection } from '../ssh/connection-repo.js';
describe('decideAccess', () => {
const baseTask = { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' };
it('rejects unauthenticated', () => {
const r = decideAccess({ user: null, task: baseTask, session: null, accessAllowed: false });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('unauthenticated');
});
it('rejects when task not visible', () => {
const r = decideAccess({ user: { id: 'u2', role: 'user' } as any, task: null, session: null, accessAllowed: false });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('task_not_visible');
});
it('rejects when no active session', () => {
const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session: null, accessAllowed: true });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('no_session');
});
it('rejects when SSH access denied', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: false });
expect(r.allowed).toBe(false);
expect((r as any).reason).toBe('no_grant');
});
it('owner gets canWrite=true', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: true });
expect(r.allowed).toBe(true);
if (r.allowed) expect(r.canWrite).toBe(true);
});
it('non-owner with task visibility gets canWrite=false', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'other', role: 'user' } as any, task: { ...baseTask, visibility: 'org' } as any, session, accessAllowed: true });
expect(r.allowed).toBe(true);
if (r.allowed) expect(r.canWrite).toBe(false);
});
it('admin always canWrite=true', () => {
const session = { connectionId: 'c1' } as any;
const r = decideAccess({ user: { id: 'admin', role: 'admin' } as any, task: baseTask, session, accessAllowed: true });
expect(r.allowed).toBe(true);
if (r.allowed) expect(r.canWrite).toBe(true);
});
});
class FakeWS extends EventEmitter {
readyState = 1; // OPEN
OPEN = 1;
sent: Array<{ kind: 'text' | 'binary'; data: any }> = [];
send(data: any, opts?: { binary?: boolean }) {
if (opts?.binary) this.sent.push({ kind: 'binary', data });
else this.sent.push({ kind: 'text', data: JSON.parse(data) });
}
}
function fakeSessionForWs() {
return {
cols: 80, rows: 24,
connectionId: 'c1',
scrollbackBytes: () => Buffer.alloc(0),
onOutput: (_cb: any) => () => {},
write: vi.fn(),
resize: vi.fn(),
addViewer: vi.fn(() => () => {}),
listViewers: vi.fn(() => []),
} as any;
}
describe('handleConsoleSocket', () => {
it('drops human input that fails deny-list and emits notice', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('message', Buffer.from('rm -rf /\n'), true);
expect(session.write).not.toHaveBeenCalled();
const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice');
expect(notice).toBeDefined();
expect((notice as any).data.severity).toBe('error');
});
it('forwards safe human input to session.write', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('message', Buffer.from('uptime\n'), true);
expect(session.write).toHaveBeenCalled();
const arg = session.write.mock.calls[0][0] as Buffer;
expect(arg.toString()).toBe('uptime\n');
});
it('rejects input when canWrite=false', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] });
ws.emit('message', Buffer.from('uptime\n'), true);
expect(session.write).not.toHaveBeenCalled();
const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice');
expect(notice).toBeDefined();
expect((notice as any).data.severity).toBe('warn');
});
it('handles resize text frame when canWrite=true', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false);
expect(session.resize).toHaveBeenCalledWith(100, 40);
});
it('ignores resize text frame when canWrite=false', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] });
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false);
expect(session.resize).not.toHaveBeenCalled();
});
it('registers a ViewerHandle with the session on attach', () => {
const ws = new FakeWS();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
expect(session.addViewer).toHaveBeenCalledTimes(1);
const handle = (session.addViewer as any).mock.calls[0][0];
expect(handle.userId).toBe('u1');
expect(typeof handle.close).toBe('function');
});
it('viewer.close() sends a close message and ws.close(1008, reason)', () => {
const ws = new FakeWS();
(ws as any).close = vi.fn();
const session = fakeSessionForWs();
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
const handle = (session.addViewer as any).mock.calls[0][0];
handle.close('access_revoked');
const closeMsg = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'close');
expect(closeMsg).toBeDefined();
expect((closeMsg as any).data.reason).toBe('access_revoked');
expect((ws as any).close).toHaveBeenCalledWith(1008, 'access_revoked');
});
it('viewer unsubscribes from session on ws close', () => {
const ws = new FakeWS();
const unsubViewer = vi.fn();
const session = fakeSessionForWs();
(session.addViewer as any).mockReturnValue(unsubViewer);
handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
ws.emit('close');
expect(unsubViewer).toHaveBeenCalled();
});
});
// Regression: PR fixing "wss error for non-admin owner of task with piece-specific grant".
// Documents the contract that the WS upgrade access check MUST pass the
// task's pieceName to accessResolver so piece-specific grants match.
// Before fix: server.ts resolveSshAccess hardcoded pieceName: '' → all
// piece-specific grants silently failed (no_grant) even when one existed.
describe('regression: piece-specific grant matching via accessResolver', () => {
let tmpRoot: string;
let db: Database.Database;
const CONN_ID = 'conn-global-1';
const USER_ID = 'user-non-admin-1';
const PIECE = 'ssh-console';
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'console-ws-regression-'));
db = new Database(join(tmpRoot, 'test.db'));
runMigrations(db);
// Insert a global connection (ownerId=NULL) so the owner branch in
// access.ts doesn't short-circuit — forces grant lookup.
db.prepare(`
INSERT INTO ssh_connections (id, owner_id, label, host, port, username, private_key_enc, remote_path_prefix, enabled, created_at, updated_at)
VALUES (?, NULL, 'global', 'host.example', 22, 'u', X'00', '/srv', 1, datetime('now'), datetime('now'))
`).run(CONN_ID);
// Insert a piece-specific grant for the user.
const grantsRepo = createGrantsRepo(db);
grantsRepo.create({
connectionId: CONN_ID,
subjectType: 'user',
subjectId: USER_ID,
pieceName: PIECE,
appliesToAllPieces: false,
grantedByUserId: 'admin-1',
reason: 'regression test',
});
});
afterEach(() => {
db.close();
rmSync(tmpRoot, { recursive: true, force: true });
});
function connection(): SshConnection {
return {
id: CONN_ID,
ownerId: null,
label: 'global',
host: 'host.example',
port: 22,
username: 'u',
privateKeyEnc: Buffer.alloc(0),
passphraseEnc: null,
keyVersion: 1,
keyFingerprint: null,
hostKeyType: null,
hostKeyB64: null,
hostKeyFingerprint: null,
hostKeyRecordedAt: null,
hostKeyVerifiedAt: null,
hostKeyPending: false,
hostKeyPendingB64: null,
hostKeyPendingFingerprint: null,
hostKeyPendingToken: null,
hostKeyPendingSource: null,
commandDenyPatterns: null,
commandAllowPatterns: null,
remotePathPrefix: '/srv',
enabled: true,
allowRemoteUnrestricted: false,
allowPrivateAddresses: false,
createdAt: '',
updatedAt: '',
} as unknown as SshConnection;
}
it('access GRANTED when pieceName matches the grant (the fix)', () => {
const grants = createGrantsRepo(db);
const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
const decision = resolver.resolveAccess({
connection: connection(),
userId: USER_ID,
isAdmin: false,
pieceName: PIECE, // <-- the fix passes the task's actual pieceName
orgIds: [],
});
expect(decision.allowed).toBe(true);
expect((decision as any).via).toBe('grant');
});
it('access DENIED when pieceName is empty (the original bug)', () => {
const grants = createGrantsRepo(db);
const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
const decision = resolver.resolveAccess({
connection: connection(),
userId: USER_ID,
isAdmin: false,
pieceName: '', // <-- pre-fix server.ts hardcoded this; grant never matches
orgIds: [],
});
expect(decision.allowed).toBe(false);
expect((decision as any).reason).toBe('no_grant');
});
it('access DENIED when pieceName differs from the grant', () => {
const grants = createGrantsRepo(db);
const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
const decision = resolver.resolveAccess({
connection: connection(),
userId: USER_ID,
isAdmin: false,
pieceName: 'unrelated-piece',
orgIds: [],
});
expect(decision.allowed).toBe(false);
});
});
describe('createConsoleStatusRouter', () => {
// Issue #347 regression: the App.tsx-side poller fires
// GET /api/local/tasks/:taskId/console/status every 5 seconds for
// the currently-selected local task. Returning 404 when the task is
// missing / not-visible logged an unsuppressible network error in
// the browser DevTools console on every tick. The router now returns
// 200 active=false instead, matching the no-session shape.
function buildApp(opts: {
resolveTask?: (id: string, user: any) => Promise<any>;
registry?: { get: (id: string) => any };
user?: any;
}) {
const app = express();
if (opts.user) {
app.use((req, _res, next) => { (req as any).user = opts.user; next(); });
}
app.use('/api', createConsoleStatusRouter({
registry: (opts.registry ?? { get: () => null }) as any,
requireAuth: (_req: any, _res: any, next: any) => next(),
resolveTask: opts.resolveTask ?? (async () => null),
}));
return app;
}
it('returns 200 active=false when task is not visible to the user (was 404)', async () => {
const app = buildApp({
user: { id: 'alice', role: 'user' },
resolveTask: async () => null, // not visible
});
const res = await request(app).get('/api/local/tasks/182/console/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ active: false });
});
it('returns 200 active=false when task exists but no SSH session is open', async () => {
const app = buildApp({
user: { id: 'alice', role: 'user' },
resolveTask: async () => ({ id: 't1' }),
registry: { get: () => null },
});
const res = await request(app).get('/api/local/tasks/t1/console/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ active: false });
});
it('returns 200 active=true with session metadata when session is live', async () => {
const now = Date.now();
const app = buildApp({
user: { id: 'alice', role: 'user' },
resolveTask: async () => ({ id: 't1' }),
registry: { get: () => ({
connectionId: 'conn-1',
startedAt: now - 60_000,
lastActivityAt: now,
cols: 120,
rows: 30,
}) },
});
const res = await request(app).get('/api/local/tasks/t1/console/status');
expect(res.status).toBe(200);
expect(res.body.active).toBe(true);
expect(res.body.connection_id).toBe('conn-1');
expect(res.body.cols).toBe(120);
});
it('still returns 401 when there is no authenticated user', async () => {
const app = buildApp({}); // no user middleware
const res = await request(app).get('/api/local/tasks/t1/console/status');
expect(res.status).toBe(401);
});
});
+291
View File
@@ -0,0 +1,291 @@
import type { IncomingMessage, Server as HttpServer } from 'node:http';
import type { Socket } from 'node:net';
import { WebSocketServer, type WebSocket } from 'ws';
import { Router, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import type { SessionRegistry } from '../ssh/console-registry.js';
import type { ConsoleSession } from '../ssh/console-session.js';
import type { AttachMessage, ServerTextMessage } from '../ssh/console-protocol.js';
import { checkConsoleInput } from '../ssh/console-deny-check.js';
export interface SimpleUser { id: string; role: 'admin' | 'user' | string }
export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string }
export type AccessDecision =
| { allowed: true; canWrite: boolean }
| { allowed: false; reason: 'unauthenticated' | 'task_not_visible' | 'no_session' | 'no_grant' };
/**
* Pure access decision for an SSH Console WS attach attempt.
*
* - Unauthenticated → reject
* - Task not visible → reject ("not found"-like)
* - No active session for the task → reject
* - SSH access denied (no grant) → reject
* - Otherwise → allow; canWrite gated on owner OR admin.
*
* Non-owners with task-visibility (e.g. org members on an org-visible task)
* attach as read-only viewers — they see scrollback + live output but cannot
* type or resize.
*/
export function decideAccess(args: {
user: SimpleUser | null;
task: SimpleTask | null;
session: ConsoleSession | null;
accessAllowed: boolean;
}): AccessDecision {
if (!args.user) return { allowed: false, reason: 'unauthenticated' };
if (!args.task) return { allowed: false, reason: 'task_not_visible' };
if (!args.session) return { allowed: false, reason: 'no_session' };
if (!args.accessAllowed) return { allowed: false, reason: 'no_grant' };
const canWrite = args.user.id === args.task.ownerId || args.user.role === 'admin';
return { allowed: true, canWrite };
}
export interface DenyPatternProvider {
/** Returns the {deny, allow} regex patterns for a given connection_id. */
getPatterns(connectionId: string): Promise<{ deny: string[]; allow: string[] }>;
}
export interface ConsoleWsDeps {
registry: SessionRegistry;
resolveUserFromUpgrade: (req: IncomingMessage) => Promise<SimpleUser | null>;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
resolveSshAccess: (user: SimpleUser, session: ConsoleSession, task: SimpleTask) => Promise<boolean>;
denyPatterns: DenyPatternProvider;
}
const PATH_RE = /^\/+api\/local\/tasks\/([^/]+)\/console\/ws$/;
/**
* Attach the SSH Console WebSocket upgrade handler to the given http.Server.
*
* Matches paths of the form /api/local/tasks/:taskId/console/ws and runs the
* full auth + access pipeline. Rejected upgrades are silently destroyed
* (the client gets a 1006 abnormal close) so we don't leak failure
* reasons over the upgrade channel. The reason is always logged.
*/
export function attachConsoleWs(server: HttpServer, deps: ConsoleWsDeps): void {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', async (req, socket, head) => {
const url = req.url ?? '';
const m = url.match(PATH_RE);
if (!m) return;
const taskId = decodeURIComponent(m[1]!);
try {
const user = await deps.resolveUserFromUpgrade(req);
const task = user ? await deps.resolveTask(taskId, user) : null;
const session = deps.registry.get(taskId);
const accessAllowed = !!(user && task && session)
? await deps.resolveSshAccess(user, session, task)
: false;
const decision = decideAccess({ user: user ?? null, task, session, accessAllowed });
if (!decision.allowed) {
logger.info(`[console-ws] reject taskId=${taskId} reason=${decision.reason}`);
socket.destroy();
return;
}
const patterns = await deps.denyPatterns.getPatterns(session!.connectionId);
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
handleConsoleSocket(ws, session!, user!, decision.canWrite, patterns);
});
} catch (e) {
logger.warn(`[console-ws] upgrade error: ${(e as Error).message}`);
socket.destroy();
}
});
}
/**
* Handle a single accepted Console WebSocket. The caller has already done
* auth + access + scrollback fetch.
*
* Wire protocol (see console-protocol.ts):
* - server → client: attach (JSON), ESC c (binary reset), replay bytes,
* replay_begin (JSON), replay_end (JSON), live binary output, notices
* - client → server: binary input frames (forwarded to PTY) and JSON
* control frames (`resize`).
*
* Input policy:
* - canWrite=false ⇒ all input is rejected with a 'warn' notice.
* - canWrite=true ⇒ input is forwarded as-is to the PTY, BUT any chunk
* containing a line terminator is first checked against the connection's
* deny/allow patterns. A rejected line drops the WHOLE chunk and emits
* an 'error' notice. The check fires only on chunks containing a CR/LF
* since pre-Enter keystrokes are partial input the operator hasn't
* committed yet — the live shell echo will show them on screen but
* they only matter for safety once the line is submitted.
*/
export function handleConsoleSocket(
ws: WebSocket,
session: ConsoleSession,
user: SimpleUser,
canWrite: boolean,
patterns: { deny: string[]; allow: string[] },
): void {
const sendText = (msg: ServerTextMessage) => {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
};
const sendBinary = (buf: Buffer) => {
if (ws.readyState === ws.OPEN) ws.send(buf, { binary: true });
};
const attachMsg: AttachMessage = {
type: 'attach',
acting_user_id: user.id,
can_write: canWrite,
connection_id: session.connectionId,
cols: session.cols,
rows: session.rows,
};
sendText(attachMsg);
// Replay scrollback: ESC c reset, raw bytes, then markers around the bytes.
// The ESC c ensures the client terminal starts fresh even if it was already
// attached to another session before this one.
const scroll = session.scrollbackBytes();
sendBinary(Buffer.from([0x1b, 0x63])); // ESC c — full reset
sendText({ type: 'replay_begin', bytes: scroll.length });
if (scroll.length > 0) sendBinary(scroll);
sendText({ type: 'replay_end' });
const unsub = session.onOutput((b) => sendBinary(b));
// Register this WS as a viewer so the registry can selectively kick it
// (e.g. when its access grant is revoked) without tearing down the whole
// SSH session. unsubViewer must fire on `close` along with unsub.
const unsubViewer = session.addViewer({
userId: user.id,
close: (reason) => {
try {
sendText({ type: 'close', reason });
} catch { /* socket gone */ }
try {
// 1008 = Policy Violation — appropriate for authorization revocation.
ws.close(1008, reason);
} catch { /* already closed */ }
},
});
// Heartbeat — without this, a half-dead WS (TCP alive but the peer
// can't respond, e.g. laptop suspended or NAT/proxy dropped state)
// never fires `close` and the UI just silently swallows user input.
// Send ping every 30s; if no pong within the next ping cycle, treat
// as dead and terminate so the client switches to `disconnected`
// and the user knows to refresh / reconnect.
let alive = true;
const heartbeatTimer = setInterval(() => {
if (!alive) {
logger.warn(`[console-ws] heartbeat timeout for task=${session.localTaskId} — terminating`);
try { ws.terminate(); } catch { /* already dead */ }
return;
}
alive = false;
try {
ws.ping();
} catch (e) {
logger.warn(`[console-ws] ping failed: ${(e as Error).message}`);
}
}, 30_000);
ws.on('pong', () => { alive = true; });
ws.on('message', (data, isBinary) => {
if (isBinary) {
if (!canWrite) {
sendText({ type: 'notice', severity: 'warn', msg: 'read-only viewer; input ignored.' });
return;
}
const buf = data as Buffer;
const text = buf.toString('utf8');
if (/[\r\n]/.test(text)) {
const denyResult = checkConsoleInput(
text,
patterns.deny.length ? patterns.deny : null,
patterns.allow.length ? patterns.allow : null,
);
if (!denyResult.ok) {
sendText({
type: 'notice',
severity: 'error',
msg: `command rejected: ${denyResult.reason} (${denyResult.matched ?? 'n/a'})`,
});
return;
}
}
session.write(buf, 'human');
return;
}
try {
const msg = JSON.parse(String(data));
if (msg && msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
if (canWrite) session.resize(msg.cols, msg.rows);
}
} catch (e) {
logger.warn(`[console-ws] bad text frame: ${(e as Error).message}`);
}
});
ws.on('close', () => {
clearInterval(heartbeatTimer);
unsub();
unsubViewer();
});
}
/**
* REST router exposing GET /local/tasks/:taskId/console/status.
*
* Used by the UI to know whether a Console tab should render an attach
* button (active=true) or a "no live session" empty state.
*/
export function createConsoleStatusRouter(deps: {
registry: SessionRegistry;
requireAuth: any;
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
}): Router {
const r = Router();
r.get(
'/local/tasks/:taskId/console/status',
deps.requireAuth,
async (req: Request, res: Response) => {
const taskId = req.params.taskId!;
const user = (req.user as SimpleUser | undefined) ?? null;
if (!user) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const task = await deps.resolveTask(taskId, user);
if (!task) {
// Task is missing or not visible to this user. Return 200
// active=false rather than 404: the UI's App.tsx polls this
// endpoint every 5 seconds for the currently-selected local
// task, and a 404 logs an unsuppressible network error in
// the browser DevTools console on every tick (reported as
// issue #347 during dogfooding). The poll only needs to
// know whether a SSH attach button should be rendered, and
// "no, you can't attach" is the same answer whether the
// task doesn't exist or just doesn't expose a session —
// collapsing both into 200 active=false matches the rest of
// the route's fallback shape without leaking task existence
// either way.
res.json({ active: false });
return;
}
const s = deps.registry.get(taskId);
if (!s) {
res.json({ active: false });
return;
}
res.json({
active: true,
connection_id: s.connectionId,
started_at: new Date(s.startedAt).toISOString(),
last_activity_at: new Date(s.lastActivityAt).toISOString(),
cols: s.cols,
rows: s.rows,
});
},
);
return r;
}
+335
View File
@@ -0,0 +1,335 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { createDashboardApi } from './dashboard-api.js';
import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status-registry.js';
function makeApp(userId: string, repo: Repository, opts?: {
registry?: BackendStatusRegistry | null;
}): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' };
next();
});
app.use(
'/api/local/dashboard',
createDashboardApi({
repo,
getWorkers: () => [
{ id: 'w1', endpoint: 'x', roles: ['task'] },
],
authActive: true,
backendStatusRegistry: opts?.registry ?? null,
}),
);
return app;
}
function stubRegistry(nodes: NodeStatus[]): BackendStatusRegistry {
return {
start: () => {},
stop: async () => {},
getAll: () => nodes.slice(),
getByNodeId: (id) => nodes.find(n => n.nodeId === id) ?? null,
subscribe: () => () => {},
refresh: async () => {},
};
}
describe('Dashboard API', () => {
let tmpDir: string;
let repo: Repository;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-api-test-'));
repo = new Repository(join(tmpDir, 'test.db'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('GET /widgets returns empty when none', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/widgets');
expect(res.status).toBe(200);
expect(res.body.widgets).toEqual([]);
});
it('POST /widgets creates a widget', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo', content: 'hi' });
expect(res.status).toBe(201);
expect(res.body.widget.slug).toBe('memo');
expect(res.body.widget.markdownContent).toBe('hi');
});
it('POST /widgets rejects invalid slug', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'Bad Slug!', title: 't', content: '' });
expect(res.status).toBe(400);
});
it('POST /widgets rejects duplicate slug', async () => {
const app = makeApp('u1', repo);
await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'a', content: '' });
const dup = await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'b', content: '' });
expect(dup.status).toBe(409);
});
it('PATCH /widgets/:id updates content', async () => {
const app = makeApp('u1', repo);
const created = await request(app).post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: 'old' });
const id = created.body.widget.id;
const res = await request(app).patch(`/api/local/dashboard/widgets/${id}`).send({ content: 'new' });
expect(res.status).toBe(200);
expect(res.body.widget.markdownContent).toBe('new');
});
it('PATCH /widgets/:id returns 404 for other user', async () => {
const created = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' });
const res = await request(makeApp('u2', repo))
.patch(`/api/local/dashboard/widgets/${created.body.widget.id}`).send({ content: 'hack' });
expect(res.status).toBe(404);
});
it('DELETE /widgets/:id removes for owner only', async () => {
const created = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' });
const other = await request(makeApp('u2', repo))
.delete(`/api/local/dashboard/widgets/${created.body.widget.id}`);
expect(other.status).toBe(404);
const owner = await request(makeApp('u1', repo))
.delete(`/api/local/dashboard/widgets/${created.body.widget.id}`);
expect(owner.status).toBe(204);
});
it('PUT /widgets/reorder reorders within user scope', async () => {
const app = makeApp('u1', repo);
const a = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'a', title: 'A', content: '' })).body.widget.id;
const b = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'b', title: 'B', content: '' })).body.widget.id;
const res = await request(app).put('/api/local/dashboard/widgets/reorder').send({ ids: [b, a] });
expect(res.status).toBe(200);
const list = await request(app).get('/api/local/dashboard/widgets');
expect(list.body.widgets.map((w: any) => w.slug)).toEqual(['b', 'a']);
});
it('GET /workers returns idle/running per worker', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers');
expect(res.status).toBe(200);
expect(res.body.workers).toHaveLength(1);
expect(res.body.workers[0].id).toBe('w1');
expect(res.body.workers[0].state).toBe('idle');
});
it('GET /workers does not include job id/title/owner', async () => {
const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed' });
await repo.updateJob(j.id, { status: 'running', workerId: 'w1' });
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers');
const keys = Object.keys(res.body.workers[0]).sort();
// `proxy` was added when Worker widget gained tree-expand for proxy
// workers (PR #350). `backends` / `busySlots` / `totalSlots` / `online`
// are conditional (proxy with registry, direct with registry) so they
// can be absent. The privacy contract here is the negative — job id,
// title, and owner must never appear — so assert that explicitly
// alongside the allowed-key whitelist.
expect(keys.includes('proxy')).toBe(true);
const allowed = new Set(['id', 'name', 'roles', 'state', 'proxy', 'backends', 'busySlots', 'totalSlots', 'online']);
for (const k of keys) {
expect(allowed.has(k)).toBe(true);
}
// Defensive: leaks would show up as one of these substrings.
const serialized = JSON.stringify(res.body.workers[0]);
expect(serialized).not.toMatch(/local\/task-1/);
expect(serialized).not.toMatch(/instruction|"u1"|"seed"/);
});
it('returns 401 when no req.user and authActive=true', async () => {
const app = express();
app.use(express.json());
app.use('/api/local/dashboard', createDashboardApi({
repo,
getWorkers: () => [],
authActive: true,
}));
const res = await request(app).get('/api/local/dashboard/widgets');
expect(res.status).toBe(401);
});
it('POST /widgets accepts kind=node-status', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
expect(res.status).toBe(201);
expect(res.body.widget.kind).toBe('node-status');
});
it('POST /widgets defaults kind to markdown when omitted', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo' });
expect(res.status).toBe(201);
expect(res.body.widget.kind).toBe('markdown');
});
it('POST /widgets rejects unknown kind', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'x', title: 'X', kind: 'mystery' });
expect(res.status).toBe(400);
});
it('PATCH /widgets/:id rejects content edits on node-status widgets (400)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
expect(created.status).toBe(201);
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ content: 'manual override' });
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain('node-status');
});
it('PATCH /widgets/:id allows title-only edits on node-status widgets (200)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ title: 'GPU Pool' });
expect(res.status).toBe(200);
expect(res.body.widget.title).toBe('GPU Pool');
expect(res.body.widget.kind).toBe('node-status');
});
it('PATCH /widgets/:id still allows content edits on markdown widgets (regression)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo', content: 'old' });
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ content: 'new content' });
expect(res.status).toBe(200);
expect(res.body.widget.markdownContent).toBe('new content');
});
it('GET /node-status returns 503 when registry is not configured', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/node-status');
expect(res.status).toBe(503);
});
it('GET /node-status returns registry snapshot', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(res.body.nodes).toEqual(nodes);
});
it('GET /node-status sets Cache-Control: no-store and a weak ETag', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.headers['etag']).toMatch(/^W\/"[0-9a-f]{16}"$/);
});
it('GET /node-status returns 304 on If-None-Match match', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
const first = await request(app).get('/api/local/dashboard/node-status');
const etag = first.headers['etag'];
const second = await request(app)
.get('/api/local/dashboard/node-status')
.set('If-None-Match', etag);
expect(second.status).toBe(304);
// 304 must not carry a body.
expect(second.text).toBe('');
});
it('GET /node-status returns 304 on multi-value If-None-Match (RFC 9110 §13.1.2)', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
const first = await request(app).get('/api/local/dashboard/node-status');
const etag = first.headers['etag'] as string;
// Browsers' BFCache restore and HTTP intermediaries can produce
// comma-separated multi-tag If-None-Match headers. The server must
// match any of them per RFC 9110 §13.1.2.
const multi = `W/"deadbeefdeadbeef", ${etag}, W/"cafef00dcafef00d"`;
const second = await request(app)
.get('/api/local/dashboard/node-status')
.set('If-None-Match', multi);
expect(second.status).toBe(304);
expect(second.text).toBe('');
});
it('GET /node-status returns 200 when no tag in multi-value If-None-Match matches', async () => {
const nodes: NodeStatus[] = [{
nodeId: 'gpu-a', workerId: 'pool', source: 'proxy',
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: 'qwen3:8b', throughputTps: null,
lastSeen: '2026-05-18T00:00:00.000Z',
}];
const app = makeApp('u1', repo, { registry: stubRegistry(nodes) });
const second = await request(app)
.get('/api/local/dashboard/node-status')
.set('If-None-Match', 'W/"deadbeef", W/"cafef00d"');
expect(second.status).toBe(200);
expect(second.body.nodes).toEqual(nodes);
});
it('GET /node-status returns 200 when If-None-Match header is absent', async () => {
const nodes: NodeStatus[] = [];
const res = await request(makeApp('u1', repo, { registry: stubRegistry(nodes) }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(res.body.nodes).toEqual([]);
});
it('GET /node-status calls noteSubscriberActivity when available', async () => {
const nodes: NodeStatus[] = [];
const stub = stubRegistry(nodes) as BackendStatusRegistry & { calls: number };
stub.calls = 0;
(stub as any).noteSubscriberActivity = () => { stub.calls++; };
const res = await request(makeApp('u1', repo, { registry: stub }))
.get('/api/local/dashboard/node-status');
expect(res.status).toBe(200);
expect(stub.calls).toBe(1);
});
});
+277
View File
@@ -0,0 +1,277 @@
/**
* dashboard-api.ts — REST router for Side Info Panel.
* Mounted at /api/local/dashboard.
*
* Routes:
* GET /widgets — list current user's widgets
* POST /widgets — create
* PATCH /widgets/:id — update title/content
* DELETE /widgets/:id — delete
* PUT /widgets/reorder — reorder by id list
* GET /workers — worker idle/running (no job details)
*
* Auth: all routes require req.user (or fall back to 'local' when authActive=false).
* Owner: every operation scopes to req.user.id; cross-user access returns 404.
*/
import { Router, type Request, type Response } from 'express';
import { createHash } from 'crypto';
import { isDashboardWidgetKind, type DashboardWidgetKind, type Repository } from '../db/repository.js';
import type { WorkerDef } from '../config.js';
import { collectWorkerStatuses } from './dashboard-workers.js';
import type { BackendStatusRegistry } from '../engine/backend-status-registry.js';
import { logger } from '../logger.js';
const SLUG_PATTERN = /^[a-z0-9-]+$/;
const MAX_SLUG_LEN = 32;
const MAX_TITLE_LEN = 64;
const MAX_CONTENT_BYTES = 64 * 1024;
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
export interface DashboardApiDeps {
repo: Repository;
getWorkers: () => WorkerDef[];
authActive?: boolean;
/**
* Optional BackendStatusRegistry. When supplied, the API exposes
* GET /node-status; when omitted (e.g. in unit tests that don't care
* about node status), the route 503s.
*/
backendStatusRegistry?: BackendStatusRegistry | null;
}
export function createDashboardApi(deps: DashboardApiDeps): Router {
const { repo, getWorkers } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
r.get('/widgets', async (req, res) => {
const u = getUser(req)!;
try {
const widgets = await repo.listDashboardWidgets(u.id);
res.json({ widgets });
} catch (err) {
logger.error(`[dashboard-api] GET /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list widgets' });
}
});
r.post('/widgets', async (req, res) => {
const u = getUser(req)!;
const { slug, title, content, kind } = (req.body ?? {}) as {
slug?: string;
title?: string;
content?: string;
kind?: string;
};
if (!slug || !SLUG_PATTERN.test(slug) || slug.length > MAX_SLUG_LEN) {
res.status(400).json({ error: 'invalid slug (lowercase a-z, 0-9, hyphen; max 32 chars)' });
return;
}
if (!title || title.length > MAX_TITLE_LEN) {
res.status(400).json({ error: `title required and <= ${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
// kind is optional; defaults to 'markdown' for backward compat.
let resolvedKind: DashboardWidgetKind = 'markdown';
if (kind !== undefined) {
if (!isDashboardWidgetKind(kind)) {
res.status(400).json({ error: 'invalid kind (allowed: markdown, node-status)' });
return;
}
resolvedKind = kind;
}
try {
const widget = await repo.createDashboardWidget({
userId: u.id,
slug,
title,
content: content ?? '',
kind: resolvedKind,
});
res.status(201).json({ widget });
} catch (err: any) {
if (String(err?.message ?? err).includes('UNIQUE')) {
res.status(409).json({ error: 'slug already exists' });
return;
}
logger.error(`[dashboard-api] POST /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to create widget' });
}
});
r.patch('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
const { title, content } = (req.body ?? {}) as { title?: string; content?: string };
// Non-markdown widget kinds (currently just 'node-status') render
// data live from a backing source instead of stored markdown — any
// content the caller sends would be dead state at best and a
// confusing surprise on the next render at worst. Title remains
// editable so the user can rename the panel.
if (existing.kind !== 'markdown' && content !== undefined) {
res.status(400).json({
error: `cannot edit content of ${existing.kind} widget (title-only updates allowed)`,
});
return;
}
if (title !== undefined && (title.length === 0 || title.length > MAX_TITLE_LEN)) {
res.status(400).json({ error: `title must be 1..${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
try {
const widget = await repo.updateDashboardWidget(id, u.id, { title, content });
res.json({ widget });
} catch (err) {
logger.error(`[dashboard-api] PATCH /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to update widget' });
}
});
r.delete('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
try {
await repo.deleteDashboardWidget(id, u.id);
res.status(204).end();
} catch (err) {
logger.error(`[dashboard-api] DELETE /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete widget' });
}
});
r.put('/widgets/reorder', async (req, res) => {
const u = getUser(req)!;
const { ids } = (req.body ?? {}) as { ids?: number[] };
if (!Array.isArray(ids) || !ids.every(n => Number.isFinite(n))) {
res.status(400).json({ error: 'ids must be array of numbers' });
return;
}
try {
await repo.reorderDashboardWidgets(u.id, ids);
res.json({ ok: true });
} catch (err) {
logger.error(`[dashboard-api] reorder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to reorder' });
}
});
r.get('/workers', async (_req, res) => {
try {
const workers = await collectWorkerStatuses(repo, getWorkers(), deps.backendStatusRegistry ?? null);
res.json({ workers });
} catch (err) {
logger.error(`[dashboard-api] GET /workers failed err=${err}`);
res.status(500).json({ error: 'Failed to list worker status' });
}
});
// GET /node-status
//
// Returns the latest BackendStatusRegistry snapshot. The registry is
// already polling in the background at a fixed cadence, so this
// handler is a cheap cache read.
//
// Caching headers (Phase C):
// - `Cache-Control: no-store` — multiple AAO instances might sit
// behind a shared proxy/CDN with body-level caching defaults; the
// snapshot is per-process state and must never be cached
// intermediately.
// - Weak ETag of the JSON payload + 304 short-circuit — when 5s polls
// land on an unchanged registry (idle pool, no probes flipped) the
// response avoids re-serialising the body and the browser refetch
// skips the JSON parse, halving the per-tick CPU under N-tab loads.
//
// The registry tick also notifies the registry that a subscriber is
// active so the polling cadence can fall back to the idle interval
// when no UI is open (see BackendStatusRegistry.noteSubscriberActivity).
r.get('/node-status', async (req, res) => {
const reg = deps.backendStatusRegistry ?? null;
if (!reg) {
// The registry is started by server.ts; when running under tests
// that don't bother to construct one, we'd rather signal "feature
// disabled" than crash.
res.status(503).json({ nodes: [], error: 'node-status registry not configured' });
return;
}
try {
const nodes = reg.getAll();
// Signal to the registry that a UI is actively watching so the
// polling cadence stays in the active (5s) band; without any
// recent GET the registry falls back to the idle (30s) cadence.
if (typeof reg.noteSubscriberActivity === 'function') {
try { reg.noteSubscriberActivity(); } catch { /* never fail the GET on metrics */ }
}
const body = JSON.stringify({ nodes });
// Weak ETag: payload identity is the only thing that matters for
// 304 short-circuiting; we don't care about byte-for-byte
// equivalence (no Content-Encoding negotiation here).
const etag = `W/"${createHash('sha1').update(body).digest('hex').slice(0, 16)}"`;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('ETag', etag);
const inm = req.headers['if-none-match'];
// RFC 9110 §13.1.2: If-None-Match may carry a comma-separated
// list of entity tags (browsers' BFCache restore and HTTP
// intermediaries can both produce multi-tag headers). Strict
// equality on the whole header would silently miss matches and
// re-send the body unnecessarily — splitting + per-tag compare
// is the spec-compliant behaviour.
if (typeof inm === 'string') {
const tags = inm.split(',').map(s => s.trim());
if (tags.includes(etag)) {
res.status(304).end();
return;
}
}
res.type('application/json').send(body);
} catch (err) {
logger.error(`[dashboard-api] GET /node-status failed err=${err}`);
res.status(500).json({ error: 'Failed to read node status' });
}
});
return r;
}
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { collectWorkerStatuses } from './dashboard-workers.js';
import type { WorkerDef } from '../config.js';
describe('collectWorkerStatuses', () => {
let tmpDir: string;
let repo: Repository;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-workers-test-'));
repo = new Repository(join(tmpDir, 'test.db'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('returns idle for all workers when no running jobs exist', async () => {
const workers: WorkerDef[] = [
{ id: 'w1', endpoint: 'x', roles: ['task'] },
{ id: 'w2', endpoint: 'y', roles: ['title'] },
];
const result = await collectWorkerStatuses(repo, workers);
expect(result).toEqual([
{ id: 'w1', name: 'w1', roles: ['task'], state: 'idle', proxy: false },
{ id: 'w2', name: 'w2', roles: ['title'], state: 'idle', proxy: false },
]);
});
it('returns running for workers with active jobs', async () => {
// Seed a running job for w1 via Repository's public API
const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed' });
await repo.updateJob(j.id, { status: 'running', workerId: 'w1' });
const workers: WorkerDef[] = [
{ id: 'w1', endpoint: 'x', roles: ['task'] },
{ id: 'w2', endpoint: 'y', roles: ['task'] },
];
const result = await collectWorkerStatuses(repo, workers);
expect(result.find(w => w.id === 'w1')!.state).toBe('running');
expect(result.find(w => w.id === 'w2')!.state).toBe('idle');
});
it('does not leak job id/title/owner in the response shape', async () => {
const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed' });
await repo.updateJob(j.id, { status: 'running', workerId: 'w1' });
const result = await collectWorkerStatuses(repo, [{ id: 'w1', endpoint: 'x' }]);
const keys = Object.keys(result[0]!).sort();
// proxy is a new keyed field; backends is optional and absent for
// direct workers — that's part of the public shape contract.
expect(keys).toEqual(['id', 'name', 'proxy', 'roles', 'state']);
});
it('fans out proxy workers into backends[] when a registry is supplied', async () => {
const fakeRegistry = {
getAll: () => [
// Self-row that the proxy probe surfaces with nodeId === workerId —
// must be filtered out so the proxy doesn't appear as its own child.
{
nodeId: 'gw', workerId: 'gw', source: 'proxy' as const,
online: true, busy: false, busySlots: 0, totalSlots: 0,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
{
nodeId: 'backend-a', workerId: 'gw', source: 'proxy' as const,
online: true, busy: true, busySlots: 2, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
{
nodeId: 'backend-b', workerId: 'gw', source: 'proxy' as const,
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
// Belongs to a different worker — must not leak into gw.backends
{
nodeId: 'other-backend', workerId: 'other', source: 'proxy' as const,
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
],
};
const workers: WorkerDef[] = [
{ id: 'gw', endpoint: 'http://gw/v1', proxy: true },
{ id: 'direct', endpoint: 'http://direct/v1' },
];
const result = await collectWorkerStatuses(repo, workers, fakeRegistry);
const gw = result.find(w => w.id === 'gw')!;
expect(gw.proxy).toBe(true);
expect(gw.backends?.map(b => b.id).sort()).toEqual(['backend-a', 'backend-b']);
expect(gw.backends?.find(b => b.id === 'backend-a')).toMatchObject({
state: 'running', busySlots: 2, totalSlots: 4, online: true,
});
expect(gw.backends?.find(b => b.id === 'backend-b')).toMatchObject({
state: 'idle', busySlots: 0,
});
const direct = result.find(w => w.id === 'direct')!;
expect(direct.proxy).toBe(false);
// direct workers MUST omit `backends` (undefined, not empty) so the
// UI can distinguish "direct" from "proxy with zero backends".
expect(direct.backends).toBeUndefined();
});
it('omits backends[] for proxy workers when no registry is supplied (back-compat)', async () => {
const result = await collectWorkerStatuses(repo, [
{ id: 'gw', endpoint: 'http://gw/v1', proxy: true },
], null);
expect(result[0]!.proxy).toBe(true);
expect(result[0]!.backends).toBeUndefined();
});
it('surfaces busy/total slots on the row for direct workers via the registry self-row', async () => {
// Direct workers don't have a backends[] expansion, so the slot
// pressure has to live at the row level — otherwise the Worker
// widget can't show "(1/3)" for them the way proxy backends do.
const fakeRegistry = {
getAll: () => [
{
nodeId: 'gpu-1', workerId: 'gpu-1', source: 'direct' as const,
online: true, busy: true, busySlots: 1, totalSlots: 3,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
},
],
};
const workers: WorkerDef[] = [
{ id: 'gpu-1', endpoint: 'http://gpu-1:8080/v1' },
];
const result = await collectWorkerStatuses(repo, workers, fakeRegistry);
const row = result[0]!;
expect(row.proxy).toBe(false);
expect(row.backends).toBeUndefined();
expect(row.busySlots).toBe(1);
expect(row.totalSlots).toBe(3);
expect(row.online).toBe(true);
// Probe-derived state wins over the local jobs-table check when
// the probe sees in-flight requests this AAO didn't dispatch.
expect(row.state).toBe('running');
});
it('keeps direct worker slot fields undefined when registry has no matching row', async () => {
const fakeRegistry = {
// Registry knows other workers, but not the one we're asking about.
getAll: () => [{
nodeId: 'other', workerId: 'other', source: 'direct' as const,
online: true, busy: false, busySlots: 0, totalSlots: 4,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
}],
};
const workers: WorkerDef[] = [
{ id: 'gpu-1', endpoint: 'http://gpu-1:8080/v1' },
];
const result = await collectWorkerStatuses(repo, workers, fakeRegistry);
const row = result[0]!;
expect(row.busySlots).toBeUndefined();
expect(row.totalSlots).toBeUndefined();
expect(row.online).toBeUndefined();
});
it('marks online=false for direct workers when probe failed', async () => {
const fakeRegistry = {
getAll: () => [{
nodeId: 'gpu-1', workerId: 'gpu-1', source: 'direct' as const,
online: false, busy: false, busySlots: 0, totalSlots: 0,
loadedModel: null, throughputTps: null, lastSeen: '2026-05-21T00:00:00Z',
lastProbeError: 'connection refused',
}],
};
const result = await collectWorkerStatuses(repo, [{ id: 'gpu-1', endpoint: 'x' }], fakeRegistry);
expect(result[0]!.online).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { Repository } from '../db/repository.js';
import type { WorkerDef } from '../config.js';
import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status-registry.js';
export interface WorkerStatusBackendRow {
/** Stable identifier from the upstream /health response (deployment id). */
id: string;
/** idle = registry says zero busy slots, running = at least one in-flight. */
state: 'idle' | 'running';
/** Number of in-flight slots reported by the registry. */
busySlots: number;
/** Total slot capacity. 0 when the registry hasn't probed yet. */
totalSlots: number;
/** false when the most recent /health probe failed. null when unprobed. */
online: boolean | null;
}
export interface WorkerStatusRow {
id: string;
name: string;
roles: string[];
state: 'idle' | 'running';
/** True when this row represents a `proxy: true` worker (LiteLLM / AAO Gateway). */
proxy: boolean;
/**
* Slot pressure from the BackendStatusRegistry, populated for direct
* workers when the registry has seen at least one probe cycle and a
* matching `nodeId === worker.id` row exists. Proxy workers leave
* these undefined — the meaningful breakdown lives in `backends[]`
* (per-backend, since the proxy itself doesn't have its own
* /slots-style busy figure).
*/
busySlots?: number;
totalSlots?: number;
/** Probe liveness. Same gating as busySlots/totalSlots. */
online?: boolean;
/**
* Per-backend rows for proxy workers — populated when a
* BackendStatusRegistry is wired and the registry has seen at least
* one probe cycle for this worker. Omitted (undefined, not empty)
* for direct workers so the UI can distinguish "no backends because
* this is a direct worker" from "proxy worker with zero backends
* reported".
*/
backends?: WorkerStatusBackendRow[];
}
/**
* Build the per-worker status list for the Side Info Panel.
*
* Proxy workers fan out into a `backends[]` list when a
* `BackendStatusRegistry` is supplied — the Worker widget renders the
* tree at the same granularity as the Node Status widget, so an
* operator can see *which* backend behind a LiteLLM / AAO Gateway
* front is currently in use rather than just "the proxy is busy".
*
* Privacy: returns idle/running booleans + slot counts only. Never job
* ids, titles, or owners, since the panel is shown to all users in a
* multi-tenant deployment.
*/
export async function collectWorkerStatuses(
repo: Repository,
workers: WorkerDef[],
registry: Pick<BackendStatusRegistry, 'getAll'> | null = null,
): Promise<WorkerStatusRow[]> {
// Build a workerId → NodeStatus[] map once per call so we don't
// O(N*M) the registry snapshot per worker. registry.getAll() copies
// its internal cache, so calling it once is cheap.
const byWorker = new Map<string, NodeStatus[]>();
if (registry) {
for (const ns of registry.getAll()) {
const list = byWorker.get(ns.workerId);
if (list) list.push(ns);
else byWorker.set(ns.workerId, [ns]);
}
}
return workers.map((w) => {
const isProxy = w.proxy === true;
const row: WorkerStatusRow = {
id: w.id,
name: w.id,
roles: w.roles ?? [],
state: repo.isWorkerBusy(w.id) ? 'running' : 'idle',
proxy: isProxy,
};
if (isProxy && registry) {
// Filter to backend-source rows only — the registry also stores a
// self-row for the proxy worker itself (source='proxy', nodeId =
// workerId) which would otherwise show up duplicated as a child
// of itself.
const rows = (byWorker.get(w.id) ?? []).filter((ns) => ns.nodeId !== w.id);
row.backends = rows.map((ns) => ({
id: ns.nodeId,
state: ns.busySlots > 0 ? 'running' : 'idle',
busySlots: ns.busySlots,
totalSlots: ns.totalSlots,
online: ns.online,
}));
} else if (!isProxy && registry) {
// Direct workers: the registry stores one row keyed by the
// worker id (source='direct', populated from llama-server
// /slots). Surface its slot pressure at the row level so the
// UI can render `(busy/total)` next to the state badge — same
// signal proxy backends get, just one level higher in the
// tree because direct workers have no expansion.
const selfRow = (byWorker.get(w.id) ?? []).find((ns) => ns.nodeId === w.id);
if (selfRow) {
row.busySlots = selfRow.busySlots;
row.totalSlots = selfRow.totalSlots;
row.online = selfRow.online;
// Re-derive state from the probe too — it sees in-flight
// requests that didn't go through the local jobs table
// (e.g. anything dispatched outside AAO). `repo.isWorkerBusy`
// alone misses those.
if (selfRow.busySlots > 0) row.state = 'running';
}
}
return row;
});
}
+515
View File
@@ -0,0 +1,515 @@
/**
* Phase 3c — same-process gateway mount unit tests.
*
* Drives the mount handle directly with a fake ConfigManager + fake
* BackendStatusRegistry so we don't need a Repository or live HTTP
* upstream. Verifies:
*
* - Gate returns 404 for /v1/* while disabled (and the worker bridge's
* own routes still work in the same Express app)
* - Flipping enabled: true brings the gateway up; /v1/models authed
* against the boot virtual_keys returns a backend list
* - Flipping back to false drains and re-404s
* - A backend list change while running triggers a bounce (stop + start)
* - misconfigured config (no backends) parks in `misconfigured` state
* with errors exposed
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express, { type Express } from 'express';
import request from 'supertest';
import { EventEmitter } from 'events';
import {
isGatewayPath,
classifyGatewayPath,
mountGateway,
type GatewayMountHandle,
} from './gateway-mount.js';
import type { ConfigManager } from '../config-manager.js';
import type { AppConfig } from '../config.js';
import type {
BackendStatusRegistry,
NodeStatus,
NodeStatusListener,
Unsubscribe,
} from '../engine/backend-status-registry.js';
function fakeRegistry(): BackendStatusRegistry & { emit(s: NodeStatus[]): void } {
const listeners = new Set<NodeStatusListener>();
const reg: BackendStatusRegistry = {
start: () => {},
stop: async () => {},
getAll: () => [],
subscribe: (l: NodeStatusListener): Unsubscribe => {
listeners.add(l);
return () => listeners.delete(l);
},
noteSubscriberActivity: () => {},
};
return Object.assign(reg, {
emit: (snapshot: NodeStatus[]) => {
for (const l of listeners) l(snapshot);
},
});
}
function fakeConfigManager(initial: Partial<AppConfig>): ConfigManager & {
setConfig(next: Partial<AppConfig>): void;
} {
let cfg = initial as AppConfig;
const emitter = new EventEmitter();
const cm = {
getConfig: () => cfg,
onConfigChanged: (cb: (c: AppConfig) => void) => {
emitter.on('config-changed', cb);
},
setConfig: (next: Partial<AppConfig>) => {
cfg = next as AppConfig;
emitter.emit('config-changed', cfg);
},
} as unknown as ConfigManager & { setConfig(next: Partial<AppConfig>): void };
return cm;
}
function gatewayConfigBlock(opts: {
enabled: boolean;
backends?: Array<{ id: string; endpoint: string; model: string; maxSlots: number }>;
virtualKeys?: Array<{ key: string; team: string }>;
}): Partial<AppConfig> {
// readGatewayConfig reads app['gateway'] directly and feeds it to
// normalizeGatewayConfig, which expects camelCase (snake → camel
// happens earlier inside loadConfig via transformKeys). Tests bypass
// loadConfig so we pre-camelCase the block here.
return {
gateway: {
enabled: opts.enabled,
listenPort: 4000,
backends: opts.backends ?? [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
virtualKeys: opts.virtualKeys ?? [{ key: 'sk-test', team: 'alpha' }],
},
} as unknown as Partial<AppConfig>;
}
describe('isGatewayPath', () => {
it('matches /v1/* paths', () => {
expect(isGatewayPath('/v1/chat/completions')).toBe(true);
expect(isGatewayPath('/v1/models')).toBe(true);
expect(isGatewayPath('/v1/')).toBe(true);
});
it('A1: matches bare /v1 (no slash) as well as /v1/', () => {
// Regression: previously isGatewayPath('/v1') was false because
// the prefix match used '/v1/'. Bare /v1 silently bypassed the
// gate. classifyGatewayPath now treats both consistently.
expect(isGatewayPath('/v1')).toBe(true);
expect(isGatewayPath('/v1/')).toBe(true);
});
it('matches /health/liveness but NOT bare /health (worker owns it)', () => {
expect(isGatewayPath('/health/liveness')).toBe(true);
// `isGatewayPath` keeps the back-compat semantics — only
// gateway-only paths return true. Bare /health is
// 'gateway-when-enabled' which is NOT gateway-only.
expect(isGatewayPath('/health')).toBe(false);
});
it('does not match worker bridge routes', () => {
expect(isGatewayPath('/api/local/tasks')).toBe(false);
expect(isGatewayPath('/api/admin/gateway/keys')).toBe(false);
expect(isGatewayPath('/auth/google')).toBe(false);
expect(isGatewayPath('/ui/index.html')).toBe(false);
expect(isGatewayPath('/metrics')).toBe(false);
});
});
describe('classifyGatewayPath (CRITICAL-3 tri-state)', () => {
it('classifies /v1/* as gateway-only', () => {
expect(classifyGatewayPath('/v1/chat/completions')).toBe('gateway-only');
expect(classifyGatewayPath('/v1/models')).toBe('gateway-only');
expect(classifyGatewayPath('/v1')).toBe('gateway-only');
expect(classifyGatewayPath('/v1/')).toBe('gateway-only');
});
it('classifies /health/liveness as gateway-only', () => {
expect(classifyGatewayPath('/health/liveness')).toBe('gateway-only');
});
it('classifies /health as gateway-when-enabled', () => {
expect(classifyGatewayPath('/health')).toBe('gateway-when-enabled');
});
it('returns false for everything else', () => {
expect(classifyGatewayPath('/api/local/tasks')).toBe(false);
expect(classifyGatewayPath('/healthz')).toBe(false);
expect(classifyGatewayPath('/metrics')).toBe(false);
expect(classifyGatewayPath('/')).toBe(false);
});
});
describe('mountGateway lifecycle', () => {
let app: Express;
let configManager: ReturnType<typeof fakeConfigManager>;
let registry: ReturnType<typeof fakeRegistry>;
let mount: GatewayMountHandle;
beforeEach(() => {
app = express();
configManager = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
registry = fakeRegistry();
// A representative worker route that should NEVER be 404'd by the
// gateway gate.
app.get('/api/version', (_req, res) => res.json({ version: 'test' }));
mount = mountGateway({
app,
configManager,
repo: null,
// CRITICAL-2: gateway owns its own registry built per config.
// Tests inject via buildRegistry so the gateway sees the same
// fake the test drives (snapshot emits / listener count).
buildRegistry: () => registry,
promRegistry: null,
});
});
it('starts in disabled state', () => {
expect(mount.getState()).toBe('disabled');
expect(mount.getErrors()).toEqual([]);
});
it('returns 404 for /v1/* while disabled', async () => {
await mount.applyConfig({
enabled: false,
listenPort: 4000,
requestTimeoutSec: 600,
upstreamTimeoutSec: 30,
shutdownGracefulSec: 30,
backends: [],
virtualKeys: [],
});
const res = await request(app).get('/v1/models');
expect(res.status).toBe(404);
});
it('preserves worker bridge routes regardless of gateway state', async () => {
const r1 = await request(app).get('/api/version');
expect(r1.status).toBe(200);
expect(r1.body.version).toBe('test');
// Enable gateway: worker route still works.
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
// Wait for async applyConfig to drain.
await new Promise(r => setImmediate(r));
await new Promise(r => setImmediate(r));
const r2 = await request(app).get('/api/version');
expect(r2.status).toBe(200);
});
it('flipping enabled true brings the gateway up; /v1/models authed succeeds', async () => {
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
// applyConfig is fire-and-forget from onConfigChanged; wait for the
// mutex to drain so the running state stabilises before we hit it.
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].id).toBe('gpu-a');
});
it('flipping enabled false drains and re-404s', async () => {
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
configManager.setConfig(gatewayConfigBlock({ enabled: false }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('disabled');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(404);
});
it('backend list change bounces (stop + start) without leaving state stale', async () => {
configManager.setConfig(gatewayConfigBlock({ enabled: true }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
configManager.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
{ id: 'gpu-b', endpoint: 'http://localhost:9/v1', model: 'qwen3:14b', maxSlots: 4 },
],
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.body.data.map((m: { id: string }) => m.id).sort()).toEqual(['gpu-a', 'gpu-b']);
});
it('misconfigured config (no backends) parks in misconfigured state with errors', async () => {
configManager.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [], // empty → validation fails
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('misconfigured');
expect(mount.getErrors().length).toBeGreaterThan(0);
// Gate stays closed.
const res = await request(app).get('/v1/models');
expect(res.status).toBe(404);
});
it('mount.stop() is idempotent and safe when never started', async () => {
await expect(mount.stop()).resolves.toBeUndefined();
await expect(mount.stop()).resolves.toBeUndefined();
expect(mount.getState()).toBe('disabled');
});
it('CRITICAL-2: gateway owns its registry (built per config.backends, not worker list)', async () => {
// Adversarial-review regression: previously the gateway router
// borrowed the worker bridge's BackendStatusRegistry, which probes
// provider.workers[].id (e.g. "w-a"). When gateway.backends[].id is
// "gw-a" the router would get null status for every backend (id
// mismatch), making /health empty + least-busy routing blind.
//
// The fix: gateway builds its own registry via buildRegistry(config)
// on every start. This test sets up a fresh mount with a buildRegistry
// spy and verifies (a) it gets called with the new config containing
// gw-* ids, (b) the same fake registry is then used to serve
// /v1/models (proving the router queries the gateway's registry).
const localApp = express();
const localCm = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
const gwRegistry = fakeRegistry();
const buildSpy = vi.fn(() => gwRegistry);
const localMount = mountGateway({
app: localApp,
configManager: localCm,
repo: null,
buildRegistry: buildSpy,
promRegistry: null,
});
localCm.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [
{ id: 'gw-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(localMount.getState()).toBe('running');
// buildRegistry was called once with the gateway config.
expect(buildSpy).toHaveBeenCalledTimes(1);
const cfg = buildSpy.mock.calls[0]![0];
expect(cfg.backends[0]!.id).toBe('gw-a');
// /v1/models comes from config (sanity check the mount wired the
// gateway sub-app), and the gateway's router has the gw-a id.
const res = await request(localApp)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(200);
expect(res.body.data.map((m: { id: string }) => m.id)).toContain('gw-a');
await localMount.stop();
});
it('CRITICAL-3: /health LiteLLM-compat — gateway answers when running, bridge fallback when off', async () => {
// Adversarial-review regression: server.ts used to register
// app.get('/health', ...) BEFORE mountGateway, so the bridge
// {status:'ok'} handler always won. Phase 1 promised LiteLLM-shape
// `/health` JSON same-process — that was silently broken.
//
// The fix has two parts:
// 1. classifyGatewayPath('/health') === 'gateway-when-enabled'
// 2. server.ts registers the bridge `/health` handler AFTER
// mountGateway, so the gate's sub-app dispatch wins when the
// gateway is running.
//
// This test simulates that registration order locally.
const localApp = express();
const localCm = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
const localRegistry = fakeRegistry();
const localMount = mountGateway({
app: localApp,
configManager: localCm,
repo: null,
buildRegistry: () => localRegistry,
promRegistry: null,
});
// The bridge `/health` fallback (registered AFTER mountGateway).
localApp.get('/health', (_req, res) => res.json({ status: 'ok' }));
// Off → bridge fallback answers.
let res = await request(localApp).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
// On → gateway answers with LiteLLM shape.
localCm.setConfig(gatewayConfigBlock({ enabled: true }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(localMount.getState()).toBe('running');
res = await request(localApp).get('/health');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('healthy_endpoints');
expect(res.body).toHaveProperty('unhealthy_endpoints');
expect(res.body).toHaveProperty('healthy_count');
expect(res.body).toHaveProperty('unhealthy_count');
expect(res.body).not.toHaveProperty('status'); // not the bridge shape
// /health/liveness is gateway-only — 200 from gateway, 404 when off.
res = await request(localApp).get('/health/liveness');
expect(res.status).toBe(200);
// Flip off — bridge fallback again.
localCm.setConfig(gatewayConfigBlock({ enabled: false }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
res = await request(localApp).get('/health');
expect(res.body).toEqual({ status: 'ok' });
// /health/liveness now 404 (gateway-only + gateway off).
res = await request(localApp).get('/health/liveness');
expect(res.status).toBe(404);
await localMount.stop();
});
it('CRITICAL-3: /v1/models stays 404 when gateway disabled (gateway-only path)', async () => {
// Sanity check the gateway-only branch is unchanged: when off, /v1/*
// returns 404 with the gateway-not-found shape — not bridge fall-through.
const res = await request(app).get('/v1/models');
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found:/);
});
it('configsEquivalent is key-order insensitive (no spurious bounce on YAML round-trip)', async () => {
// Bring the gateway up with a baseline config.
configManager.setConfig(
gatewayConfigBlock({
enabled: true,
backends: [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
}),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('running');
// Spy on registry.subscribe — a bounce re-subscribes, so the
// subscribe-count growing tells us a bounce happened. We use the
// fake-registry's listener tracking indirectly via the running
// log line; instead, observe by checking that /v1/models stays
// available across "the same config in a different key order".
//
// Build a config with the SAME semantic shape but each backend
// object's keys reordered.
const reorderedBackend = {
maxSlots: 4,
model: 'qwen3:8b',
endpoint: 'http://localhost:9/v1',
id: 'gpu-a',
};
configManager.setConfig({
gateway: {
enabled: true,
listenPort: 4000,
backends: [reorderedBackend],
virtualKeys: [{ team: 'alpha', key: 'sk-test' }], // also reordered
},
} as unknown as Parameters<typeof configManager.setConfig>[0]);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
// Should still be running (no false bounce). Hit /v1/models to
// confirm the gateway sub-app is still up.
expect(mount.getState()).toBe('running');
const res = await request(app)
.get('/v1/models')
.set('Authorization', 'Bearer sk-test');
expect(res.status).toBe(200);
});
it('F1: rapid double-toggle does NOT drop the 2nd config (pending replay)', async () => {
// Fire 4 transitions back-to-back. The mutex serialises them, but
// F1 says we must not drop intent that arrived during a
// starting/stopping window.
const cfgEnabled = gatewayConfigBlock({ enabled: true });
const cfgDisabled = gatewayConfigBlock({ enabled: false });
configManager.setConfig(cfgEnabled);
configManager.setConfig(cfgDisabled);
configManager.setConfig(cfgEnabled);
configManager.setConfig(cfgDisabled);
// Drain the mutex chain.
for (let i = 0; i < 20; i++) await new Promise(r => setImmediate(r));
// The final intent was 'disabled' — the handle must converge there
// even though multiple transitions arrived during in-flight ones.
expect(mount.getState()).toBe('disabled');
});
it('F2: stop() on a misconfigured handle clears the state to disabled', async () => {
// Park the mount in 'misconfigured' (enabled:true + empty backends).
configManager.setConfig(
gatewayConfigBlock({ enabled: true, backends: [] }),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('misconfigured');
expect(mount.getErrors().length).toBeGreaterThan(0);
// Call stop(). Without the F2 fix, state stays 'misconfigured'
// because stopGateway() returns early when shared is null.
await mount.stop();
expect(mount.getState()).toBe('disabled');
expect(mount.getErrors()).toEqual([]);
});
it('F2: applyConfig({enabled:false}) on misconfigured clears the state', async () => {
// Same scenario via the config-change path (the more common one in
// production — admin opens the form, sees the misconfigured badge,
// unchecks Enable, hits Save).
configManager.setConfig(
gatewayConfigBlock({ enabled: true, backends: [] }),
);
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('misconfigured');
configManager.setConfig(gatewayConfigBlock({ enabled: false }));
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
expect(mount.getState()).toBe('disabled');
expect(mount.getErrors()).toEqual([]);
});
it('rapid enable -> disable transitions serialize via mutex (no interleaving)', async () => {
// Fire enable + disable back-to-back without waiting.
const p1 = mount.applyConfig({
enabled: true,
listenPort: 4000,
requestTimeoutSec: 600,
upstreamTimeoutSec: 30,
shutdownGracefulSec: 30,
backends: [
{ id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
],
virtualKeys: [{ key: 'sk-test', team: 'alpha' }],
});
const p2 = mount.applyConfig({
enabled: false,
listenPort: 4000,
requestTimeoutSec: 600,
upstreamTimeoutSec: 30,
shutdownGracefulSec: 30,
backends: [],
virtualKeys: [],
});
await Promise.all([p1, p2]);
expect(mount.getState()).toBe('disabled');
});
});
+488
View File
@@ -0,0 +1,488 @@
/**
* Phase 3c — same-process gateway mount.
*
* Mounts the AAO Gateway's Express sub-app on the worker bridge so a
* single AAO process can serve both the worker UI (`/api/local/*` etc.)
* and the gateway endpoints (`/v1/*`, `/health`) on the same port.
*
* Path scoping
* ────────────
* The gateway owns these paths:
* - POST /v1/chat/completions
* - GET /v1/models
* - GET /health, /health/liveness
* No conflict with the worker bridge's `/api/*` / `/ui/*` / `/auth/*`
* roots. `/metrics` deliberately stays with the worker — gateway counters
* are registered into the worker's prom-client registry so one scrape
* endpoint serves both label spaces (no port conflict).
*
* Dynamic enable / disable
* ────────────────────────
* We mount a small "gate" middleware ahead of the gateway sub-app that
* returns 404 for any gateway path while `gateway.enabled !== true`,
* matching the behaviour an operator sees when the gateway block is
* absent. Flipping the flag at runtime (via ConfigManager
* `config-changed`) requires no Express remount — the routes are
* always registered, the gate just stops short-circuiting them.
*
* Lifecycle
* ─────────
* false -> true: createSharedGatewayDependencies + start + flip gate open
* true -> false: flip gate closed + shared.stop() (drains in-flight
* SSE via streamRegistry.signalShutdown)
*
* Hot edits to backends / virtual_keys are picked up by the gateway
* automatically because createSharedGatewayDependencies reads them
* through closures over the latest GatewayConfig snapshot at start
* time. A backend list edit currently requires an enable -> disable ->
* enable cycle to take effect; full hot-reload of backends without a
* disable is Phase 4 scope.
*/
import { type Express, type Request, type Response, type NextFunction } from 'express';
import { logger } from '../logger.js';
import type { ConfigManager } from '../config-manager.js';
import type { Repository } from '../db/repository.js';
import {
createBackendStatusRegistry,
type BackendStatusRegistry,
} from '../engine/backend-status-registry.js';
import { buildDirectProbe } from '../engine/backend-probes.js';
import type { Registry as PromRegistry } from 'prom-client';
import {
readGatewayConfig,
validateGatewayConfig,
type GatewayConfig,
} from '../gateway/config.js';
import { createGatewayApp } from '../gateway/server.js';
import {
createSharedGatewayDependencies,
type SharedGatewayDependencies,
} from '../gateway/shared-dependencies.js';
import { buildWorkerDefsFromBackends } from '../gateway/bootstrap.js';
/**
* Path roots the gateway sub-app exclusively owns in same-process mode.
* These are gated by the 404 middleware while the gateway is not
* running — clients see the same JSON the not-found handler in
* gateway/server.ts emits.
*
* `/health/liveness` is also gateway-only (more specific path stays
* with the gateway's k8s liveness probe).
*/
const GATEWAY_ONLY_PREFIXES = ['/v1/'];
/**
* Classification of a request path's relationship to the gateway:
*
* - `'gateway-only'`: the gateway exclusively owns this path. When
* the gateway isn't running, the gate returns 404 to mimic the
* "block absent" behaviour an operator sees in disabled mode.
* Examples: `/v1/*`, `/health/liveness`.
*
* - `'gateway-when-enabled'`: gateway can answer this path when
* running, but the bridge has its own handler that must respond
* when the gateway is off. The gate FORWARDS to gateway while
* running, FALLS THROUGH otherwise.
* Examples: `/health` (LiteLLM-compat JSON when on, bridge ok JSON
* when off — CRITICAL-3 fix).
*
* - `false`: path is not gateway-related; gate never touches it.
*/
export type GatewayPathKind = 'gateway-only' | 'gateway-when-enabled' | false;
/**
* Classify a path against the gateway URL surface. Pure function —
* exported so the bridge / tests can reuse the same logic that the
* gate middleware uses.
*/
export function classifyGatewayPath(path: string): GatewayPathKind {
// A1: handle both /v1 (no trailing slash) and /v1/* consistently.
// Previously `/v1/` (exact) was missing from the prefix match and
// bare `/v1` silently bypassed the gate.
if (path === '/v1' || path === '/v1/') return 'gateway-only';
for (const prefix of GATEWAY_ONLY_PREFIXES) {
if (path.startsWith(prefix)) return 'gateway-only';
}
if (path === '/health/liveness') return 'gateway-only';
if (path === '/health') return 'gateway-when-enabled';
return false;
}
/**
* Back-compat boolean classifier kept for callers that don't care
* about the gateway-when-enabled tri-state. Returns true ONLY for
* gateway-only paths (NOT gateway-when-enabled) so existing
* test asserts like `isGatewayPath('/health') === false` still hold.
*/
export function isGatewayPath(path: string): boolean {
return classifyGatewayPath(path) === 'gateway-only';
}
export type GatewayMountState =
| 'disabled' // config says enabled !== true
| 'starting' // shared.start() in progress
| 'running' // gate open, deps live
| 'stopping' // shared.stop() in progress (drain)
| 'misconfigured'; // enabled === true but validateGatewayConfig found errors
export interface GatewayMountHandle {
/** Current state. Reflects the in-memory flag, not config on disk. */
getState(): GatewayMountState;
/** Validation errors from the most-recent attempt (or empty). */
getErrors(): string[];
/**
* Apply a new config snapshot. The bridge calls this from a
* `ConfigManager#onConfigChanged` listener; tests can drive it
* directly. Returns true when the call resulted in a state change.
*/
applyConfig(next: GatewayConfig): Promise<boolean>;
/**
* Forcibly stop the gateway (used by graceful shutdown). Safe to call
* when already stopped.
*/
stop(): Promise<void>;
}
export interface MountGatewayOptions {
app: Express;
configManager: ConfigManager;
repo: Repository | null;
/**
* DEPRECATED — kept for source compatibility but no longer used.
* The gateway now owns its own BackendStatusRegistry over
* `gateway.backends[]` (CRITICAL-2 fix). Passing the worker bridge's
* registry here is silently ignored — the worker registry probes
* `provider.workers[]`, whose ids do NOT match gateway.backends[].id,
* which used to make `/health` empty + least-busy routing blind.
* Remove this field in Phase 4 once the bridge call site is updated.
*/
backendStatusRegistry?: BackendStatusRegistry;
/** Shared with worker /metrics. Null disables gateway metrics. */
promRegistry: PromRegistry | null;
/** Prefix for gateway counters inside the shared registry. */
metricsPrefix?: string;
/** Test hook — defaults to globalThis.fetch. */
fetchImpl?: typeof fetch;
/**
* Test hook for the per-gateway BackendStatusRegistry. Defaults to
* the real `createBackendStatusRegistry` over `gateway.backends`. Tests
* can substitute a fake to drive snapshots deterministically without
* spinning up real probes.
*/
buildRegistry?: (config: GatewayConfig) => BackendStatusRegistry;
}
/**
* Mount the gateway sub-app on `app` and return a handle the caller
* uses to wire the ConfigManager event subscription. The gateway is
* NOT started here — call `applyConfig()` with the current config
* snapshot from the bridge to bring it up if `enabled: true`.
*/
export function mountGateway(opts: MountGatewayOptions): GatewayMountHandle {
const { app, configManager, repo, promRegistry } = opts;
const metricsPrefix = opts.metricsPrefix ?? 'aao_gateway';
const buildRegistry =
opts.buildRegistry ??
((config: GatewayConfig): BackendStatusRegistry =>
createBackendStatusRegistry({
// Per CRITICAL-2: the gateway needs a registry keyed by
// gateway.backends[].id, not the worker bridge's
// provider.workers[].id. Same-host double-probe with the
// worker registry is acceptable (probes are cheap) and the
// two registries' lifetimes are now independent.
getWorkers: () => buildWorkerDefsFromBackends(config.backends),
probeDirect: buildDirectProbe(),
// Gateway backends are never proxy=true (LiteLLM is what we're
// replacing) — supply a stub so the registry contract holds.
probeProxy: async () => [],
}));
let state: GatewayMountState = 'disabled';
let lastErrors: string[] = [];
let shared: SharedGatewayDependencies | null = null;
let gatewaySubApp: Express | null = null;
let activeConfig: GatewayConfig | null = null;
// Per-gateway registry over gateway.backends — owned + stopped here,
// independent of any worker registry the bridge separately keeps.
let ownedRegistry: BackendStatusRegistry | null = null;
// F1: pending config replay. applyConfig() during 'starting' /
// 'stopping' (an in-flight transition still draining through the
// mutex) stores the latest intent here so the next mutex turn can
// replay it instead of dropping it.
let pendingConfig: GatewayConfig | null = null;
// Serialize start/stop transitions so a rapid toggle can't interleave.
let mutex: Promise<void> = Promise.resolve();
// --- 404 gate -------------------------------------------------------
// Mounted exactly once. Returns 404 for gateway-only paths while
// state != 'running'. For 'gateway-when-enabled' paths (e.g.
// `/health`) we FALL THROUGH so the bridge's own handler can answer
// when the gateway is off. Non-gateway paths fall through
// unconditionally. We must register this BEFORE the sub-app
// dispatch middleware so the gate can short-circuit before sub-app
// routing.
app.use((req: Request, res: Response, next: NextFunction) => {
const kind = classifyGatewayPath(req.path);
if (!kind) return next();
if (state === 'running' && gatewaySubApp) return next();
if (kind === 'gateway-when-enabled') return next(); // bridge owns when off
// gateway-only and gateway not running → 404 (mimic gateway not-found
// body shape so clients see the same JSON whether the gateway is
// off or the path is genuinely missing).
res.status(404).json({ error: `not found: ${req.method} ${req.path}` });
});
// Sub-app dispatch middleware. Permanently registered; when
// `gatewaySubApp` is null (gateway never enabled this process) it
// forwards. When set + running, it routes through the gateway's
// Express app for any gateway-classified path — including
// 'gateway-when-enabled' so /health returns the LiteLLM-shape JSON
// (CRITICAL-3 fix).
app.use((req, res, next) => {
if (!gatewaySubApp) return next();
if (state !== 'running') return next();
if (classifyGatewayPath(req.path) === false) return next();
gatewaySubApp(req, res, next);
});
const startGateway = async (config: GatewayConfig): Promise<void> => {
const errors = validateGatewayConfig(config);
if (errors.length > 0) {
lastErrors = errors;
state = 'misconfigured';
for (const e of errors) {
logger.warn(`[bridge-gateway] config error blocking start: ${e}`);
}
return;
}
state = 'starting';
lastErrors = [];
// Build + start the per-gateway BackendStatusRegistry first so the
// gateway router sees backend ids from gateway.backends (not the
// worker bridge's provider.workers list, which would 404-on-status
// for the gateway's ids — CRITICAL-2).
ownedRegistry = buildRegistry(config);
ownedRegistry.start();
shared = createSharedGatewayDependencies({
config,
registry: ownedRegistry,
repo,
promRegistry,
prefix: metricsPrefix,
});
shared.start();
const { app: subApp } = createGatewayApp({
config,
registry: ownedRegistry,
fetchImpl: opts.fetchImpl,
dbLookup: shared.dbLookup,
touchLastUsed: shared.touchLastUsed,
postAuthMiddleware: shared.postAuthMiddleware,
usageRecorder: shared.usageRecorder ?? undefined,
streamRegistry: shared.streamRegistry,
inflight: shared.inflight,
metrics: shared.metrics ?? undefined,
// Phase 3c: same-process mode does NOT mount /metrics from the
// gateway side — gateway counters land in the shared worker
// registry that already serves /metrics. Pass undefined here so
// createGatewayApp skips the metrics endpoint mount.
metricsRegistry: undefined,
});
gatewaySubApp = subApp;
activeConfig = config;
state = 'running';
logger.info(
`[bridge-gateway] gateway enabled (same-process) backends=${config.backends.length} virtual_keys=${config.virtualKeys.length}`,
);
};
const stopGateway = async (): Promise<void> => {
if (!shared) return;
state = 'stopping';
try {
await shared.stop();
} catch (e) {
logger.warn(`[bridge-gateway] shared.stop threw: ${e instanceof Error ? e.message : String(e)}`);
}
// Tear down the per-gateway registry's probe loop — independent of
// any worker registry the bridge keeps running.
if (ownedRegistry) {
try {
await ownedRegistry.stop();
} catch (e) {
logger.warn(
`[bridge-gateway] ownedRegistry.stop threw: ${e instanceof Error ? e.message : String(e)}`,
);
}
ownedRegistry = null;
}
shared = null;
gatewaySubApp = null;
activeConfig = null;
state = 'disabled';
logger.info('[bridge-gateway] gateway disabled');
};
const transition = async (next: GatewayConfig): Promise<boolean> => {
const prevState = state;
if (next.enabled !== true) {
if (state === 'running' || state === 'misconfigured') {
await stopGateway();
// F2: after stopGateway() for the misconfigured branch (where
// shared was never assigned) the early return inside
// stopGateway leaves `state` at 'misconfigured'. Force the
// disable + clear errors here so applyConfig({enabled:false})
// unconditionally leaves the handle in a clean state — the
// status endpoint should never see a misconfigured handle
// after the operator turned the gateway off.
state = 'disabled';
lastErrors = [];
return true;
}
// F1: starting / stopping mid-transition — record the latest
// intent so the in-flight transition can replay it via the
// mutex chain instead of dropping it on the floor.
// NOTE: defensive-only. Under the current mutex chain
// (`applyConfig = mutex.then(...)`), transition() runs only after
// the prior start/stopGateway settles, so `state` is never
// observed as 'starting' / 'stopping' here. Kept to survive
// future refactors that allow concurrent transition() calls.
if (state === 'starting' || state === 'stopping') {
pendingConfig = next;
return false;
}
// already disabled — clear any stale validation errors
if (lastErrors.length > 0) {
lastErrors = [];
return true;
}
return false;
}
// next.enabled === true
if (state === 'disabled' || state === 'misconfigured') {
await startGateway(next);
return prevState !== state;
}
// F1: enable while another transition is in flight → queue the
// config and let the mutex finalizer replay it.
// NOTE: defensive-only — see the matching branch above. The mutex
// chain already serialises transition() calls, so this is dead
// under the current model but cheap insurance for future refactors.
if (state === 'starting' || state === 'stopping') {
pendingConfig = next;
return false;
}
// Already running. Backends / keys list changed? Bounce so the
// shared deps pick up the new snapshot. Full in-place hot reload
// is Phase 4 scope.
if (state === 'running' && activeConfig && !configsEquivalent(activeConfig, next)) {
logger.info('[bridge-gateway] gateway config changed; bouncing same-process mount');
await stopGateway();
await startGateway(next);
return true;
}
return false;
};
/**
* Mutex-chained applyConfig that drains the F1 pending queue. After
* the current transition settles, replay any pendingConfig stored
* during 'starting' / 'stopping' so configs that arrived
* mid-transition aren't dropped. Returning the OUTER promise (not
* the inner replay) preserves the public contract: callers see their
* own transition's settlement, the replay's result lands on
* whatever listener cares.
*/
const applyConfigInternal = async (next: GatewayConfig): Promise<boolean> => {
const changed = await transition(next);
if (pendingConfig) {
const replay = pendingConfig;
pendingConfig = null;
const drain = mutex.then(() => applyConfigInternal(replay));
mutex = drain.then(() => undefined, () => undefined);
drain.catch((e) => {
logger.warn(
`[bridge-gateway] pending config replay threw: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
return changed;
};
const handle: GatewayMountHandle = {
getState: () => state,
getErrors: () => lastErrors.slice(),
applyConfig: (next) => {
// Serialize through the mutex to avoid interleaved start/stop.
const run = mutex.then(() => applyConfigInternal(next));
mutex = run.then(() => undefined, () => undefined);
return run;
},
stop: async () => {
const run = mutex.then(async () => {
await stopGateway();
// F2: stop() must always land in a clean 'disabled' state with
// no stale validation errors. stopGateway() early-returns when
// shared is null (e.g. handle parked in 'misconfigured' state
// before any successful start), leaving state untouched. Force
// the cleanup here.
state = 'disabled';
lastErrors = [];
});
mutex = run.then(() => undefined, () => undefined);
await run;
},
};
// Subscribe to config changes so an admin enabling the gateway from
// Settings UI takes effect without a server restart.
configManager.onConfigChanged((cfg) => {
const next = readGatewayConfig(cfg);
handle.applyConfig(next).catch((e) => {
logger.warn(`[bridge-gateway] applyConfig from config-changed threw: ${e instanceof Error ? e.message : String(e)}`);
});
});
return handle;
}
/**
* Stable JSON.stringify that sorts object keys recursively so two
* objects whose YAML round-trip produced different key orders still
* compare equal. Plain JSON.stringify is key-order-sensitive — when
* the bridge re-serialises config.yaml the key order can flip and a
* spurious bounce would fire on an otherwise no-op save.
*
* Arrays preserve their order (intentional — `backends[]` /
* `virtualKeys[]` ordering is semantic from the operator's
* perspective; first match wins in router lookups).
*/
function stableStringify(value: unknown): string {
return JSON.stringify(value, (_key, val) => {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
const sorted: Record<string, unknown> = {};
for (const k of Object.keys(val as Record<string, unknown>).sort()) {
sorted[k] = (val as Record<string, unknown>)[k];
}
return sorted;
}
return val;
});
}
/**
* Shallow equivalence on the subset of fields that affect the gateway's
* runtime behavior. Used to decide whether a config-changed event
* requires a bounce. Uses stableStringify so YAML round-trip key-order
* changes don't cause false-positive bounces.
*/
function configsEquivalent(a: GatewayConfig, b: GatewayConfig): boolean {
if (a.listenPort !== b.listenPort) return false;
if (a.requestTimeoutSec !== b.requestTimeoutSec) return false;
if (a.upstreamTimeoutSec !== b.upstreamTimeoutSec) return false;
if (a.shutdownGracefulSec !== b.shutdownGracefulSec) return false;
if (stableStringify(a.backends) !== stableStringify(b.backends)) return false;
if (stableStringify(a.virtualKeys) !== stableStringify(b.virtualKeys)) return false;
return true;
}
+46
View File
@@ -0,0 +1,46 @@
import { EventEmitter } from 'events';
export interface JobStreamEvent {
type: 'prompt_progress' | 'text' | 'tool_use' | 'tool_use_delta' | 'tool_result' | 'done';
// prompt_progress
processed?: number;
total?: number;
timeMs?: number;
cache?: number;
// text
text?: string;
// tool_use / tool_result
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolIsError?: boolean;
callId?: string;
// tool_use_delta (live tool-call argument streaming)
name?: string;
chunk?: string;
}
class JobEventBus extends EventEmitter {
constructor() {
super();
this.setMaxListeners(200);
}
emitJob(jobId: string, event: JobStreamEvent): void {
this.emit(`job:${jobId}`, event);
}
onJob(jobId: string, handler: (event: JobStreamEvent) => void): void {
this.on(`job:${jobId}`, handler);
}
offJob(jobId: string, handler: (event: JobStreamEvent) => void): void {
this.off(`job:${jobId}`, handler);
}
hasListeners(jobId: string): boolean {
return this.listenerCount(`job:${jobId}`) > 0;
}
}
export const jobEventBus = new JobEventBus();
+61
View File
@@ -0,0 +1,61 @@
import { type Request, type Response } from 'express';
import { join, resolve, sep } from 'path';
export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: number): string {
const base = worktreeDir ?? '/tmp/maestro/workspaces';
return join(base, 'local', String(taskId));
}
export function ensurePathWithin(baseDir: string, requestedPath: string): string {
const resolvedBase = resolve(baseDir);
const resolvedPath = resolve(baseDir, requestedPath);
if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
throw new Error('Path escapes workspace');
}
return resolvedPath;
}
export function serializeLocalFileEntry(relativePath: string, name: string, isDirectory: boolean, size: number, mtime: Date) {
return {
name,
path: relativePath ? `${relativePath}/${name}` : name,
kind: isDirectory ? 'directory' : 'file',
size,
modifiedAt: mtime.toISOString(),
};
}
export function getOwnerFilter(req: Request): { ownerId?: string } {
if (!req.user) return {};
if (req.user.role === 'admin') return {};
return { ownerId: req.user.id };
}
export function checkTaskOwnership(req: Request, res: Response, task: { ownerId?: string | null } | null): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
if (req.user && req.user.role !== 'admin' && task.ownerId !== req.user?.id) {
res.status(404).json({ error: 'Task not found' });
return false;
}
return true;
}
type TaskLike = {
ownerId?: string | null;
visibility?: 'private' | 'org' | 'public' | null;
visibilityScopeOrgId?: string | null;
};
// Read-side permission check honoring the full visibility model.
// Writes should continue to use checkTaskOwnership (owner-or-admin only).
export function canViewTask(req: Request, res: Response, task: TaskLike | null): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
const user = req.user as Express.User | undefined;
if (!user) return true;
if (user.role === 'admin') return true;
if (task.ownerId && task.ownerId === user.id) return true;
if (task.visibility === 'public') return true;
if (task.visibility === 'org' && task.visibilityScopeOrgId && user.orgIds?.includes(task.visibilityScopeOrgId)) return true;
res.status(404).json({ error: 'Task not found' });
return false;
}
+176
View File
@@ -0,0 +1,176 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { ensurePathWithin, serializeLocalFileEntry, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
export function mountLocalFilesApi(app: Application, repo: Repository): void {
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
});
res.json({ basePath: section, path: relativeDir, entries });
} catch (err) {
logger.error(`Local files list API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
app.get('/api/local/tasks/:taskId/files/content', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
logger.error(`Local file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/local/tasks/:taskId/files/raw', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
logger.error(`Local file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' });
}
});
app.put('/api/local/tasks/:taskId/files/content', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) {
res.status(409).json({ error: 'Cannot edit files while job is running' });
return;
}
const section = String(req.body?.section ?? '');
if (section !== 'output') {
res.status(400).json({ error: 'Only output files can be edited' });
return;
}
const relativePath = String(req.body?.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const content = req.body?.content;
if (typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
// PUT (inline edit) is output-only; section is narrowed to 'output' above.
const rootDir = join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
writeFileSync(filePath, content, 'utf-8');
res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'Path escapes workspace') {
res.status(400).json({ error: message });
return;
}
logger.error(`Local file update API error: ${err}`);
res.status(500).json({ error: 'Failed to update file' });
}
});
}
+913
View File
@@ -0,0 +1,913 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
describe('POST /api/local/tasks with visibility', () => {
let tempDir = '';
let repo: Repository;
let app: express.Application;
let aliceUser: Express.User;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-api-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const real = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
aliceUser = {
...real,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = aliceUser;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('creates task with owner_id and visibility=org', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'org',
visibilityScopeOrgId: '10',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('org');
expect(res.body.task.visibilityScopeOrgId).toBe('10');
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('rejects visibility=org with org not in user orgs', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'org',
visibilityScopeOrgId: '99',
});
expect(res.status).toBe(400);
});
it('defaults visibility to private when not provided', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('private');
expect(res.body.task.visibilityScopeOrgId).toBeNull();
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('rejects invalid visibility enum values', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'bogus',
});
expect(res.status).toBe(400);
});
it('clears visibilityScopeOrgId when visibility is public', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
visibility: 'public',
visibilityScopeOrgId: '10',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('public');
expect(res.body.task.visibilityScopeOrgId).toBeNull();
});
});
describe('DELETE /api/local/tasks/:id owner-or-admin', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
it('non-owner non-admin gets 404 on DELETE (even when visibility=public)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({
title: 't',
body: 'b',
ownerId: alice.id,
visibility: 'public',
});
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const app = buildAppForUser(bobUser);
// Bob CANNOT delete task owned by alice
const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
expect(delRes.status).toBe(404);
// Task still exists (Bob's DELETE was rejected)
const after = await repo.getLocalTask(task.id);
expect(after).not.toBeNull();
});
it('admin can DELETE any task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({
title: 't',
body: 'b',
ownerId: alice.id,
visibility: 'private',
});
const adminUser: Express.User = {
id: 'admin-id',
email: '[email protected]',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const app = buildAppForUser(adminUser);
const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
expect(delRes.status).toBe(200);
expect(delRes.body.ok).toBe(true);
const after = await repo.getLocalTask(task.id);
expect(after).toBeNull();
});
it('owner can DELETE own task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-perm-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't',
body: 'b',
ownerId: alice.id,
visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const delRes = await request(app).delete(`/api/local/tasks/${task.id}`);
expect(delRes.status).toBe(200);
expect(delRes.body.ok).toBe(true);
});
});
describe('PATCH /api/local/tasks/:id visibility', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
it('owner can change visibility from private to org with valid orgId', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'org', visibilityScopeOrgId: '10' });
expect(res.status).toBe(200);
expect(res.body.task.visibility).toBe('org');
expect(res.body.task.visibilityScopeOrgId).toBe('10');
});
it('rejects invalid visibility enum with 400', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'bogus' });
expect(res.status).toBe(400);
});
it('rejects visibility=org without a scope org the user belongs to', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'org', visibilityScopeOrgId: '99' });
expect(res.status).toBe(400);
});
it('non-owner non-admin gets 404 on PATCH', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'public',
});
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const app = buildAppForUser(bobUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'private' });
expect(res.status).toBe(404);
const after = await repo.getLocalTask(task.id);
expect(after!.visibility).toBe('public');
});
it('cascades visibility change to the spawn job and its subtask descendants', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: ['10'], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'private',
});
const spawnJob = await repo.createJob({
repo: `local/task-${task.id}`, issueNumber: task.id, instruction: 'run',
ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
});
const subJob = await repo.createJob({
repo: `subtask/${spawnJob.id}`, issueNumber: 1, instruction: 'sub',
parentJobId: spawnJob.id, subtaskDepth: 1,
ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
});
const grandSubJob = await repo.createJob({
repo: `subtask/${subJob.id}`, issueNumber: 1, instruction: 'sub-sub',
parentJobId: subJob.id, subtaskDepth: 2,
ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null,
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'org', visibilityScopeOrgId: '10' });
expect(res.status).toBe(200);
for (const id of [spawnJob.id, subJob.id, grandSubJob.id]) {
const after = await repo.getJob(id);
expect(after!.visibility).toBe('org');
expect(after!.visibilityScopeOrgId).toBe('10');
}
});
it('nulls the scope on descendants when visibility moves back to public', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-patch-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: ['10'], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({
title: 't', body: 'b', ownerId: alice.id, visibility: 'org', visibilityScopeOrgId: '10',
});
const spawnJob = await repo.createJob({
repo: `local/task-${task.id}`, issueNumber: task.id, instruction: 'run',
ownerId: alice.id, visibility: 'org', visibilityScopeOrgId: '10',
});
const app = buildAppForUser(aliceUser);
const res = await request(app)
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'public' });
expect(res.status).toBe(200);
const after = await repo.getJob(spawnJob.id);
expect(after!.visibility).toBe('public');
expect(after!.visibilityScopeOrgId).toBeNull();
});
});
describe('GET /api/local/tasks visibility filter', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
async function seedThreeTasks(aliceId: string) {
const priv = await repo.createLocalTask({ title: 'priv', body: 'b', ownerId: aliceId, visibility: 'private' });
const org = await repo.createLocalTask({ title: 'org', body: 'b', ownerId: aliceId, visibility: 'org', visibilityScopeOrgId: '10' });
const pub = await repo.createLocalTask({ title: 'pub', body: 'b', ownerId: aliceId, visibility: 'public' });
return { priv, org, pub };
}
it('owner sees all three visibilities', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(aliceUser)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title).sort()).toEqual(['org', 'priv', 'pub']);
});
it('admin sees all three', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const admin: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(admin)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks).toHaveLength(3);
});
it('same-org bystander sees org + public (not private)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const bob = repo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
const bobUser: Express.User = {
...bob,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(bobUser)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title).sort()).toEqual(['org', 'pub']);
});
it('different-org bystander sees only public', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-list-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const carol = repo.createUser({ email: '[email protected]', name: 'c', role: 'user', status: 'active' });
const carolUser: Express.User = {
...carol,
orgIds: ['20'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
await seedThreeTasks(alice.id);
const res = await request(buildAppForUser(carolUser)).get('/api/local/tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toEqual(['pub']);
});
});
describe('PUT /api/local/tasks/:taskId/feedback ownership', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
it('non-owner non-admin gets 404 on feedback (even when visibility=public)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'public' });
const bobUser: Express.User = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(bobUser))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'good', tags: [] });
expect(res.status).toBe(404);
});
it('owner can submit feedback on own task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const res = await request(buildAppForUser(aliceUser))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'good', tags: [] });
expect(res.status).toBe(200);
});
it('admin can submit feedback on any task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-fb-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const adminUser: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(adminUser))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'good', tags: [] });
expect(res.status).toBe(200);
});
});
describe('POST /api/local/tasks/:taskId/comments and /cancel ownership', () => {
let tempDir = '';
let repo: Repository;
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
});
return app;
}
function makeBob(): Express.User {
return {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
it('non-owner non-admin gets 404 on POST /comments (private task)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const res = await request(buildAppForUser(makeBob()))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hi from bob', author: 'user' });
expect(res.status).toBe(404);
// Comment was NOT recorded
const comments = await repo.listLocalTaskComments(task.id);
expect(comments).toHaveLength(0);
});
it('non-owner non-admin gets 404 on POST /comments even when task is public', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'public' });
const res = await request(buildAppForUser(makeBob()))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hi from bob', author: 'user' });
expect(res.status).toBe(404);
});
it('non-owner non-admin gets 404 on POST /cancel', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cancel-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private' });
const res = await request(buildAppForUser(makeBob()))
.post(`/api/local/tasks/${task.id}/cancel`);
expect(res.status).toBe(404);
});
it('owner can post comments to own task', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: join(tempDir, 'ws-alice') });
const res = await request(buildAppForUser(aliceUser))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hi from alice', author: 'user' });
expect(res.status).toBe(201);
});
});
describe('POST /api/local/tasks browserSessionProfileId owner check', () => {
let tempDir = '';
let repo: Repository;
let sessRepo: BrowserSessionRepo;
let alice: { id: string };
let bob: { id: string };
let aliceProfileId: number;
let bobProfileId: number;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-bsp-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
sessRepo = new BrowserSessionRepo(repo.getDb());
alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
bob = repo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
aliceProfileId = sessRepo.createProfile({
ownerId: alice.id,
label: 'alice-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
bobProfileId = sessRepo.createProfile({
ownerId: bob.id,
label: 'bob-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
sessRepo,
});
return app;
}
function asUser(u: { id: string }, email: string): Express.User {
return {
id: u.id, email, name: 'x', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
it('accepts a valid profile owned by the requesting user (201)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: aliceProfileId });
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('rejects a profile owned by a different user (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: bobProfileId });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not owned by you|not found/i);
});
it('rejects a positive integer that does not match any profile (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: 999999 });
expect(res.status).toBe(400);
});
it('rejects a non-positive-integer profile id (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto', browserSessionProfileId: -3 });
expect(res.status).toBe(400);
});
it('without the field, behavior is unchanged (201, profile null)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/local/tasks')
.send({ body: 'hello', piece: 'auto' });
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId ?? null).toBeNull();
});
});
describe('POST /api/local/tasks/:id/continue', () => {
let tempDir = '';
let repo: Repository;
let app: express.Application;
let aliceUser: Express.User;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cont-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const real = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
aliceUser = {
...real,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = aliceUser;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
pieceExists: (name: string) => name === 'manual-writer' || name === 'ssh-ops',
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
async function setupTaskWithTerminalJob(opts: { status?: string } = {}) {
const task = await repo.createLocalTask({
title: 't',
body: 'b',
pieceName: 'manual-writer',
ownerId: aliceUser.id,
});
const prev = await repo.createJob({
repo: localTaskRepoName(task.id),
issueNumber: task.id,
instruction: 'go',
pieceName: 'manual-writer',
ownerId: aliceUser.id,
});
await repo.updateJob(prev.id, { status: opts.status ?? 'succeeded' });
await repo.addLocalTaskComment(task.id, 'agent', '✅ 完了\n\nmanual at output/manual.md', 'result');
return { task, prev };
}
it('happy path: terminal succeeded job + valid piece + instruction → 201', async () => {
const { task, prev } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'use output/manual.md to set up foo' });
expect(res.status).toBe(201);
expect(res.body.jobId).toBeTruthy();
const newJob = await repo.getJob(res.body.jobId);
expect(newJob?.pieceName).toBe('ssh-ops');
expect(newJob?.continuedFromJobId).toBe(prev.id);
const updatedTask = await repo.getLocalTask(task.id);
expect(updatedTask?.pieceName).toBe('ssh-ops');
});
it('posts a handoff-kind comment naming both pieces', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'go' });
expect(res.status).toBe(201);
const comments = await repo.listLocalTaskComments(task.id);
const handoff = comments.find((c) => c.kind === 'handoff');
expect(handoff).toBeTruthy();
expect(handoff?.body).toContain('manual-writer');
expect(handoff?.body).toContain('ssh-ops');
});
it('returns 409 job_in_progress when prev job is running', async () => {
const { task } = await setupTaskWithTerminalJob({ status: 'running' });
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'go' });
expect(res.status).toBe(409);
expect(res.body.error).toBe('job_in_progress');
expect(res.body.currentStatus).toBe('running');
});
it('returns 409 no_previous_job when no jobs exist for the task', async () => {
const task = await repo.createLocalTask({
title: 't',
body: 'b',
pieceName: 'manual-writer',
ownerId: aliceUser.id,
});
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: 'go' });
expect(res.status).toBe(409);
expect(res.body.error).toBe('no_previous_job');
});
it('returns 400 piece_not_found for unknown piece', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'no-such-piece', instruction: 'go' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('piece_not_found');
});
it('returns 400 instruction_required for whitespace-only instruction', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('instruction_required');
});
it('returns 400 piece_required when piece field is missing', async () => {
const { task } = await setupTaskWithTerminalJob();
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ instruction: 'go' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('piece_required');
});
it('all DB-valid terminal states allow continuation', async () => {
// jobs.status CHECK constraint permits these four terminal states.
// 'aborted' is intentionally absent — the worker maps abort outcomes to
// 'failed' (see worker.ts handlePieceResult), so the endpoint's TERMINAL
// list also excludes it (see commit 974ef89).
for (const status of ['succeeded', 'failed', 'waiting_human', 'cancelled']) {
const { task } = await setupTaskWithTerminalJob({ status });
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'ssh-ops', instruction: `from ${status}` });
expect(res.status, `status=${status}`).toBe(201);
}
});
});
+670
View File
@@ -0,0 +1,670 @@
import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { logger } from '../logger.js';
import { resolveJobScheduling } from '../scheduling.js';
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
import { jobEventBus, type JobStreamEvent } from './job-events.js';
export interface LocalTasksApiOptions {
repo: Repository;
worktreeDir?: string;
generateTitle?: (body: string) => Promise<string>;
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
/**
* Server-side validator for piece names accepted by the
* /continue endpoint. Returns true if the piece is loadable.
* When unset, /continue rejects all requests with 500 (misconfiguration).
*/
pieceExists?: (name: string) => boolean;
/**
* Optional. When set, accepting browserSessionProfileId on task create
* verifies the profile belongs to the requesting user. Without it, the
* field is silently dropped (legacy / no-auth deployments).
*/
sessRepo?: BrowserSessionRepo;
/**
* Optional. Returns the current upload size limit (MB) for task creation
* and comment posting. Called per request so config changes take effect
* without a server restart. Clamped to [1, 1000] MB. Default: 50.
*/
getMaxUploadMb?: () => number;
}
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
const { repo, worktreeDir, sessRepo } = opts;
const resolveUploadLimit = (): string => {
const raw = opts.getMaxUploadMb?.() ?? 50;
const mb = Number.isFinite(raw) ? Math.max(1, Math.min(1000, Math.floor(raw))) : 50;
return `${mb}mb`;
};
const dynamicJson = () => (req: Request, res: Response, next: express.NextFunction) =>
express.json({ limit: resolveUploadLimit() })(req, res, next);
app.get('/api/local/tasks', async (req: Request, res: Response) => {
try {
const viewer = req.user as Express.User | undefined;
const tasks = await repo.listLocalTasks(viewer ? { viewer } : {});
res.json({ tasks });
} catch (err) {
logger.error(`Local tasks list API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local tasks' });
}
});
app.post('/api/local/tasks', dynamicJson(), async (req: Request, res: Response) => {
try {
const validation = validateCreateTaskBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const body = validation.data;
// Visibility extraction + validation
const rawVisibility = req.body?.visibility ?? 'private';
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const visibility = rawVisibility as 'private' | 'org' | 'public';
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
// Optional browser session profile binding. Owner-scoped check
// (sessRepo.getProfileById enforces owner_id = req.user.id) prevents
// user A from binding user B's profile to their task.
let browserSessionProfileId: number | null = null;
const rawProfileId = req.body?.browserSessionProfileId;
if (rawProfileId !== undefined && rawProfileId !== null && rawProfileId !== '') {
const n = Number(rawProfileId);
if (!Number.isInteger(n) || n <= 0) {
res.status(400).json({ error: 'browserSessionProfileId must be a positive integer' });
return;
}
if (sessRepo) {
const userId = (req.user as Express.User | undefined)?.id;
if (!userId) {
res.status(400).json({ error: 'browserSessionProfileId requires an authenticated user' });
return;
}
const owned = sessRepo.getProfileById(n, userId);
if (!owned) {
res.status(400).json({ error: 'browser session profile not found or not owned by you' });
return;
}
}
browserSessionProfileId = n;
}
let taskTitle = (body.title ?? '').trim();
const rawPiece = (body.piece ?? 'auto').trim();
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
// タイトル生成と piece 分類を並列実行
const [generatedTitle, autoSelectedPiece] = await Promise.all([
// タイトル生成
(!taskTitle && opts.generateTitle)
? Promise.race([
opts.generateTitle(body.body.trim()),
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
]).catch((e: unknown) => { logger.warn(`Title generation failed: ${e}`); return ''; })
: Promise.resolve(''),
// piece 分類('auto' の場合のみ); userId を渡し per-user カタログを使用
(rawPiece === 'auto' && opts.selectPiece)
? opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id).catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
: Promise.resolve(rawPiece),
]);
if (!taskTitle) {
taskTitle = generatedTitle || body.body.trim().slice(0, 40).replace(/\n/g, ' ');
}
const piece = autoSelectedPiece;
const profile = body.profile ?? 'auto';
const outputFormat = body.outputFormat ?? 'markdown';
const askPolicy = body.askPolicy ?? 'low';
const priority = body.priority ?? 'medium';
const scheduling = resolveJobScheduling({
role: profile,
pieceName: piece,
instruction: body.body.trim(),
});
// Per-task options (e.g. { mcpDisabled, skillsDisabled })
const rawOptions = req.body?.options;
const taskOptions: Record<string, unknown> =
rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)
? rawOptions as Record<string, unknown>
: {};
const task = await repo.createLocalTask({
title: taskTitle,
body: body.body.trim(),
pieceName: piece,
profile,
outputFormat,
askPolicy,
priority,
ownerId: req.user?.id,
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId,
options: taskOptions,
});
const workspacePath = getLocalWorkspacePath(worktreeDir, task.id);
mkdirSync(join(workspacePath, 'input'), { recursive: true });
mkdirSync(join(workspacePath, 'output'), { recursive: true });
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
await repo.updateLocalTask(task.id, { workspacePath });
for (const att of body.attachments ?? []) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64'));
}
await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request');
const metadataBlock = [
'---',
`ui_profile: ${scheduling.role}`,
`ui_output_format: ${outputFormat}`,
`ui_ask_policy: ${askPolicy}`,
`ui_priority: ${priority}`,
'---',
].join('\n');
const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${metadataBlock}`.trim();
// Merge task options into job payload so the worker can read them at runtime.
const hasOptions = Object.keys(taskOptions).length > 0;
const job = await repo.createJob({
repo: localTaskRepoName(task.id),
issueNumber: task.id,
instruction,
pieceName: piece,
role: scheduling.role,
ownerId: task.ownerId,
visibility: task.visibility,
visibilityScopeOrgId: task.visibilityScopeOrgId,
browserSessionProfileId: task.browserSessionProfileId ?? null,
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
});
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
if (rawPiece === 'auto') {
await repo.addAuditLog(job.id, 'piece_auto_selected', 'piece-classifier', {
selectedPiece: piece,
});
}
const created = await repo.getLocalTask(task.id);
res.status(201).json({ task: created, jobId: job.id });
} catch (err) {
logger.error(`Create local task API error: ${err}`);
res.status(500).json({ error: 'Failed to create local task' });
}
});
app.get('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
res.json({ task });
} catch (err) {
logger.error(`Local task detail API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task' });
}
});
app.put('/api/local/tasks/:taskId/feedback', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const validation = validateFeedbackBody(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
await repo.updateFeedback(taskId, validation.data);
const updated = await repo.getLocalTask(taskId);
res.json({ task: updated });
} catch (err) {
logger.error(`Local task feedback API error: ${err}`);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
app.put('/api/local/tasks/:taskId/mission', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Partial-replace: only string fields are written. Anything else
// (null, undefined, non-string) is treated as "leave unchanged".
// To clear a field, send an empty string.
const body = (req.body ?? {}) as Record<string, unknown>;
const patch: Record<string, string> = {};
for (const key of ['goal', 'done', 'open', 'clarifications'] as const) {
const v = body[key];
if (typeof v === 'string') patch[key] = v;
}
if (Object.keys(patch).length === 0) {
res.status(400).json({ error: 'No mission fields provided. Send goal, done, open, or clarifications as strings.' });
return;
}
const merged = await repo.updateMissionBrief(taskId, patch);
res.json({ missionBrief: merged });
} catch (err) {
logger.error(`Local task mission API error: ${err}`);
res.status(500).json({ error: 'Failed to update mission brief' });
}
});
app.get('/api/local/tasks/:taskId/comments', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
const comments = await repo.listLocalTaskComments(taskId);
res.json({ comments });
} catch (err) {
logger.error(`Local task comments API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch local task comments' });
}
});
app.post('/api/local/tasks/:taskId/comments', dynamicJson(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const commentValidation = validateCommentBody(req.body);
if (!commentValidation.valid) {
res.status(400).json({ error: commentValidation.error });
return;
}
const { body, author, attachments } = commentValidation;
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Save attachments to input/
if (attachments && attachments.length > 0 && task?.workspacePath) {
const inputDir = join(task.workspacePath, 'input');
mkdirSync(inputDir, { recursive: true });
for (const att of attachments) {
if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(inputDir, safeName), Buffer.from(att.contentBase64, 'base64'));
}
}
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
// running / dispatching / waiting_subtasks 中: コメント保存のみ(agent-loop が注入する)
const isActive = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
const commentKind = isActive ? 'interjection' : 'comment';
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind);
if (isActive) {
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
return;
}
const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
// Build instruction with attachment info
const savedFileNames = (attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
const instruction = savedFileNames.length > 0
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
: body;
const job = await repo.createJob({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction,
pieceName: task!.pieceName,
askCount,
resumeMovement,
role: prevJob?.requiredRole,
ownerId: task!.ownerId,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
});
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
res.status(201).json({ comment, jobId: job.id });
} catch (err) {
logger.error(`Local task comment create API error: ${err}`);
res.status(500).json({ error: 'Failed to post local task comment' });
}
});
app.patch('/api/local/tasks/:taskId', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
const updates: { visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
if (req.body.visibility !== undefined) {
const v = req.body.visibility;
if (!['private', 'org', 'public'].includes(v)) {
res.status(400).json({ error: 'invalid visibility' }); return;
}
updates.visibility = v;
}
if (req.body.visibilityScopeOrgId !== undefined) {
updates.visibilityScopeOrgId = req.body.visibilityScopeOrgId ?? null;
}
if (updates.visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
const scopeId = updates.visibilityScopeOrgId ?? task!.visibilityScopeOrgId ?? null;
if (!scopeId || !orgIds.includes(scopeId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return;
}
updates.visibilityScopeOrgId = scopeId;
}
if (updates.visibility && updates.visibility !== 'org') {
updates.visibilityScopeOrgId = null;
}
await repo.updateLocalTask(taskId, updates);
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
await repo.updateJobsVisibilityForTask(taskId, {
visibility: refreshed.visibility ?? 'private',
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
});
}
res.json({ task: refreshed });
} catch (err) {
logger.error(`Patch local task API error: ${err}`);
res.status(500).json({ error: 'Failed to update task' });
}
});
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return;
await repo.deleteLocalTask(taskId);
res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('has an active job')) {
res.status(409).json({ error: 'Cannot delete task with running jobs' });
return;
}
logger.error(`Delete local task API error: ${err}`);
res.status(500).json({ error: 'Failed to delete local task' });
}
});
app.post('/api/local/tasks/:taskId/cancel', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) {
res.status(404).json({ error: 'No running job found' });
return;
}
const cancelled = repo.requestJobCancel(latestJob.id);
if (!cancelled) {
res.status(409).json({ error: 'Job is no longer running' });
return;
}
await repo.addAuditLog(latestJob.id, 'job_cancel_requested', 'local-ui', { taskId });
logger.info(`Cancel requested for job ${latestJob.id} (task ${taskId})`);
res.json({ ok: true, jobId: latestJob.id });
} catch (err) {
logger.error(`Cancel local task API error: ${err}`);
res.status(500).json({ error: 'Failed to cancel task' });
}
});
app.post('/api/local/tasks/:taskId/continue', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const piece = typeof req.body?.piece === 'string' ? req.body.piece.trim() : '';
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
if (!piece) {
res.status(400).json({ error: 'piece_required' });
return;
}
if (!instruction.trim()) {
res.status(400).json({ error: 'instruction_required' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Piece existence check (server-side; UI dropdown is best-effort).
if (!opts.pieceExists) {
logger.error('[local-tasks-api] /continue invoked but pieceExists option not configured');
res.status(500).json({ error: 'piece_validation_unavailable' });
return;
}
if (!opts.pieceExists(piece)) {
res.status(400).json({ error: 'piece_not_found', piece });
return;
}
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!prevJob) {
res.status(409).json({ error: 'no_previous_job' });
return;
}
// jobs.status CHECK には 'aborted' が無い (worker が abort 結果を 'failed' に集約するため)。
// 'waiting_subtasks' は子 job 待機の中間状態で、そこから別 piece に切り替えると孤立するので除外。
const TERMINAL: ReadonlyArray<string> = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
if (!TERMINAL.includes(prevJob.status)) {
res.status(409).json({ error: 'job_in_progress', currentStatus: prevJob.status });
return;
}
const job = await repo.createJob({
repo: localTaskRepoName(taskId),
issueNumber: taskId,
instruction: instruction.trim(),
pieceName: piece,
continuedFromJobId: prevJob.id,
ownerId: task!.ownerId,
role: prevJob.requiredRole,
visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null,
});
await repo.updateLocalTask(taskId, { pieceName: piece });
// Surface the handoff in the timeline so the user (and the LLM, when
// it later inspects task comments) can see when piece switches happened.
await repo.addLocalTaskComment(
taskId,
'system',
`🔄 Continued: piece="${prevJob.pieceName}" → piece="${piece}"`,
'handoff',
);
await repo.addAuditLog(job.id, 'job_queued_local_continue', 'local-ui', {
taskId,
fromPiece: prevJob.pieceName,
toPiece: piece,
prevJobId: prevJob.id,
});
res.status(201).json({ jobId: job.id });
} catch (err) {
logger.error(`Local task continue API error: ${err}`);
res.status(500).json({ error: 'Failed to continue task' });
}
});
// ── SSE stream: real-time job events ──────────────────────────────────────
app.get('/api/local/tasks/:taskId/stream', async (req: Request, res: Response) => {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'invalid taskId' }); return; }
try {
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : {});
if (!task) { res.status(404).json({ error: 'task not found' }); return; }
const runningJob = task.latestJob;
if (!runningJob || (runningJob.status !== 'running' && runningJob.status !== 'dispatching')) {
res.status(204).end();
return;
}
const jobId = runningJob.id;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Text delta batching (50ms flush)
let textBuf = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const TEXT_FLUSH_MS = 50;
// Tool-call argument delta batching, keyed by callId (50ms flush).
const toolBuf = new Map<string, { name: string; chunk: string }>();
let toolFlushTimer: ReturnType<typeof setTimeout> | null = null;
const flushText = () => {
if (textBuf) {
const data = JSON.stringify({ type: 'text_delta', text: textBuf });
res.write(`data: ${data}\n\n`);
textBuf = '';
}
flushTimer = null;
};
const flushToolDeltas = () => {
for (const [callId, { name, chunk }] of toolBuf) {
if (res.writableEnded) break;
res.write(`data: ${JSON.stringify({ type: 'tool_use_delta', callId, name, chunk })}\n\n`);
}
toolBuf.clear();
toolFlushTimer = null;
};
const handler = (event: JobStreamEvent) => {
if (res.writableEnded) return;
if (event.type === 'text') {
textBuf += event.text ?? '';
if (!flushTimer) flushTimer = setTimeout(flushText, TEXT_FLUSH_MS);
return;
}
if (event.type === 'tool_use_delta') {
const callId = event.callId ?? '';
// chunk is a full snapshot of args-so-far; keep the LATEST per
// callId (replace, not append) so each flush sends the newest
// complete prefix. Coalesces many snapshots into one per 50ms.
toolBuf.set(callId, {
name: event.name ?? toolBuf.get(callId)?.name ?? '',
chunk: event.chunk ?? '',
});
if (!toolFlushTimer) toolFlushTimer = setTimeout(flushToolDeltas, TEXT_FLUSH_MS);
return;
}
// Flush pending text + tool deltas before non-streaming events
if (textBuf) flushText();
if (toolBuf.size) flushToolDeltas();
if (event.type === 'prompt_progress') {
const effective = (event.processed ?? 0) - (event.cache ?? 0);
const effectiveTotal = (event.total ?? 0) - (event.cache ?? 0);
const percent = effectiveTotal > 0 ? Math.round(effective / effectiveTotal * 100) : 0;
res.write(`data: ${JSON.stringify({ type: 'prompt_progress', percent, processed: event.processed, total: event.total, cache: event.cache, timeMs: event.timeMs })}\n\n`);
} else if (event.type === 'done') {
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
cleanup();
res.end();
} else {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
};
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(': heartbeat\n\n');
}, 15_000);
const cleanup = () => {
jobEventBus.offJob(jobId, handler);
clearInterval(heartbeat);
if (flushTimer) { clearTimeout(flushTimer); flushText(); }
if (toolFlushTimer) { clearTimeout(toolFlushTimer); flushToolDeltas(); }
};
jobEventBus.onJob(jobId, handler);
req.on('close', cleanup);
} catch (err) {
logger.error(`Local task stream API error: ${err}`);
if (!res.headersSent) res.status(500).json({ error: 'stream failed' });
}
});
}
+327
View File
@@ -0,0 +1,327 @@
import { describe, it, expect, afterEach } from 'vitest';
import express from 'express';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry } from '../mcp/registry.js';
import { createTokenManager } from '../mcp/token-manager.js';
import { createToolCache } from '../mcp/tool-cache.js';
import { createAdminRouter, createUserRouter, createUserServersRouter } from './mcp-api.js';
import request from 'supertest';
const openDbs: Database.Database[] = [];
function makeApp(opts: { currentRole: 'admin' | 'user' | 'anon'; userId?: string }) {
const validKey = 'a'.repeat(64);
process.env.MCP_ENCRYPTION_KEY = validKey;
const db = new Database(':memory:');
openDbs.push(db);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`); // runMigrations needs this
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');
db.prepare('INSERT INTO users(id) VALUES(?)').run('u2');
const reg = createRegistry(db);
const tm = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const cache = createToolCache(db, 600);
const userId = opts.userId ?? 'u1';
const requireAdmin: express.RequestHandler = (_req, res, next) => {
if (opts.currentRole === 'admin') next();
else res.status(403).json({ error: 'admin required' });
};
const requireAuth: express.RequestHandler = (_req, res, next) => {
if (opts.currentRole !== 'anon') next();
else res.status(401).json({ error: 'unauth' });
};
const app = express();
app.use(express.json());
app.use(
'/api/mcp/servers',
createAdminRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
app.use(
'/api/mcp/connections',
createUserRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
app.use(
'/api/mcp/user-servers',
createUserServersRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
return { app, db, reg, tm };
}
describe('mcp-api', () => {
afterEach(() => {
while (openDbs.length) {
const db = openDbs.pop();
try {
db?.close();
} catch {
/* ignore */
}
}
delete process.env.MCP_ENCRYPTION_KEY;
});
it('non-admin cannot POST /api/mcp/servers', async () => {
const { app } = makeApp({ currentRole: 'user' });
const res = await request(app).post('/api/mcp/servers').send({ id: 'canva' });
expect(res.status).toBe(403);
});
it('admin can upsert (oauth) + list + delete', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'canva',
name: 'Canva',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
oauthClientId: 'cid',
oauthClientSecret: 'secret',
});
expect(post.status).toBe(200);
const list = await request(app).get('/api/mcp/servers');
expect(list.body.servers).toHaveLength(1);
// authKind and ownerId included in response
expect(list.body.servers[0].authKind).toBe('oauth');
expect(list.body.servers[0].ownerId).toBeNull();
// Secret must not leak
expect(JSON.stringify(list.body.servers)).not.toContain('secret');
const del = await request(app).delete('/api/mcp/servers/canva');
expect(del.status).toBe(200);
});
it('admin can upsert api_key server', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'myapi',
name: 'My API',
url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key',
staticToken: 'sk-test-admin',
});
expect(post.status).toBe(200);
const list = await request(app).get('/api/mcp/servers');
expect(list.body.servers[0].authKind).toBe('api_key');
// Static token must not leak
expect(JSON.stringify(list.body.servers)).not.toContain('sk-test-admin');
});
it('admin POST api_key fails without staticToken', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'myapi',
name: 'My API',
url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key',
});
expect(post.status).toBe(400);
});
it('user sees connection state with authKind + ownerId', async () => {
const { app, reg, tm } = makeApp({ currentRole: 'user' });
reg.upsert({
id: 'canva',
name: 'Canva',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
const accessTokenLiteral = 'access-token-do-not-leak-xyz';
const refreshTokenLiteral = 'refresh-token-do-not-leak-xyz';
tm.saveTokens({
userId: 'u1',
serverId: 'canva',
accessToken: accessTokenLiteral,
refreshToken: refreshTokenLiteral,
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
scope: null,
});
const res = await request(app).get('/api/mcp/connections');
expect(res.body.connections).toHaveLength(1);
expect(res.body.connections[0]).toMatchObject({
serverId: 'canva',
serverName: 'Canva',
connected: true,
authKind: 'oauth',
ownerId: null,
});
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain(accessTokenLiteral);
expect(serialized).not.toContain(refreshTokenLiteral);
});
it('connections GET uses listEnabledForUser (includes user-owned servers)', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
// Global server
reg.upsert({
id: 'global-server',
name: 'Global',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
// User-owned server
reg.upsert({
id: 'u1-server',
name: 'U1 Server',
url: 'http://127.0.0.1:2/mcp',
authKind: 'api_key',
ownerId: 'u1',
staticToken: 'sk-u1',
});
const res = await request(app).get('/api/mcp/connections');
expect(res.status).toBe(200);
const ids = res.body.connections.map((c: { serverId: string }) => c.serverId);
expect(ids).toContain('global-server');
expect(ids).toContain('u1-server');
});
it('user can POST /api/mcp/user-servers with api_key', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
const post = await request(app).post('/api/mcp/user-servers').send({
id: 'my-tool',
name: 'My Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
staticToken: 'sk-test',
});
expect(post.status).toBe(200);
// Server should appear in listEnabledForUser
const servers = reg.listEnabledForUser('u1');
expect(servers.find((s) => s.id === 'my-tool')).toBeTruthy();
expect(servers.find((s) => s.id === 'my-tool')?.ownerId).toBe('u1');
});
it('user cannot DELETE another user\'s server (403)', async () => {
// u2 creates a server, u1 tries to delete it
const { db, reg } = makeApp({ currentRole: 'user', userId: 'u2' });
reg.upsert({
id: 'u2-tool',
name: 'U2 Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
ownerId: 'u2',
staticToken: 'sk-u2',
});
// Now make an app as u1
const validKey = 'a'.repeat(64);
process.env.MCP_ENCRYPTION_KEY = validKey;
const reg2 = createRegistry(db);
const tm2 = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const cache2 = createToolCache(db, 600);
const app2 = express();
app2.use(express.json());
app2.use('/api/mcp/user-servers', createUserServersRouter({
db,
registry: reg2,
tokenManager: tm2,
toolCache: cache2,
requireAdmin: (_req, _res, next) => next(),
requireAuth: (_req, _res, next) => next(),
getUserId: () => 'u1',
insecureLocalTestMode: true,
}));
const del = await request(app2).delete('/api/mcp/user-servers/u2-tool');
expect(del.status).toBe(403);
});
it('user cannot DELETE a global server via user-servers route', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
reg.upsert({
id: 'global-tool',
name: 'Global Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthScopes: null,
});
const del = await request(app).delete('/api/mcp/user-servers/global-tool');
expect(del.status).toBe(403);
});
it('id collision: POST user-servers fails 409 if id already exists', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
// Pre-create a global server with same id
reg.upsert({
id: 'existing',
name: 'Existing',
url: 'http://127.0.0.1:9/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthScopes: null,
});
const post = await request(app).post('/api/mcp/user-servers').send({
id: 'existing',
name: 'My Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
staticToken: 'sk-test',
});
expect(post.status).toBe(409);
});
it('DELETE /api/mcp/connections returns 400 for api_key global server', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
reg.upsert({
id: 'apikey-global',
name: 'API Key Global',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
ownerId: null,
staticToken: 'sk-global',
});
const del = await request(app).delete('/api/mcp/connections/apikey-global');
expect(del.status).toBe(400);
});
});
+418
View File
@@ -0,0 +1,418 @@
import { Router, type Request } from 'express';
import type Database from 'better-sqlite3';
import type { McpRegistry } from '../mcp/registry.js';
import type { McpTokenManager } from '../mcp/token-manager.js';
import type { McpToolCache } from '../mcp/tool-cache.js';
import { fetchDiscovery } from '../mcp/discovery.js';
import { createMcpClient } from '../mcp/client-factory.js';
import { logger } from '../logger.js';
export interface McpApiDeps {
db: Database.Database;
registry: McpRegistry;
tokenManager: McpTokenManager;
toolCache: McpToolCache;
requireAdmin: import('express').RequestHandler;
requireAuth: import('express').RequestHandler;
getUserId: (req: Request) => string | null;
insecureLocalTestMode?: boolean;
allowPrivateAddresses?: boolean;
}
const ID_REGEX = /^[a-z0-9_-]{1,64}$/;
export function createAdminRouter(deps: McpApiDeps): Router {
const router = Router();
router.get('/', deps.requireAdmin, (_req, res) => {
const servers = deps.registry.listPublic();
const enriched = servers.map((s) => ({
...s,
toolCount: deps.toolCache.getForServer(s.id).length,
}));
res.json({ servers: enriched });
});
router.post('/', deps.requireAdmin, async (req, res) => {
const body = req.body as Partial<{
id: string;
name: string;
url: string;
authKind: string;
oauthClientId: string;
oauthClientSecret: string;
oauthScopes: string;
staticToken: string;
enabled: boolean;
}>;
if (!body.id || !ID_REGEX.test(body.id)) {
res.status(400).json({ error: 'id must match [a-z0-9_-]{1,64}' });
return;
}
if (!body.name || !body.url) {
res.status(400).json({ error: 'missing required fields' });
return;
}
const authKind = (body.authKind ?? 'oauth') as 'oauth' | 'api_key';
if (authKind === 'oauth') {
if (!body.oauthClientId || !body.oauthClientSecret) {
res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' });
return;
}
} else if (authKind === 'api_key') {
if (!body.staticToken) {
res.status(400).json({ error: 'authKind api_key requires staticToken' });
return;
}
} else {
res.status(400).json({ error: `unknown authKind: ${authKind}` });
return;
}
const adminId = deps.getUserId(req);
deps.registry.upsert({
id: body.id,
name: body.name,
url: body.url,
authKind,
ownerId: null,
oauthClientId: body.oauthClientId,
oauthClientSecret: body.oauthClientSecret,
oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken,
enabled: body.enabled !== false,
createdBy: adminId,
});
// Attempt discovery for OAuth only (best effort — defer on failure).
if (authKind === 'oauth') {
try {
const meta = await fetchDiscovery(body.url, {
insecureLocalTestMode: deps.insecureLocalTestMode,
});
deps.registry.setDiscovery(body.id, meta);
} catch (err) {
logger.warn(
`[mcp:api] discovery deferred for server=${body.id}: ${(err as Error).message}`,
);
}
}
// Auto list_tools for api_key servers (token available immediately).
// For OAuth servers, defer until callback handler.
if (authKind === 'api_key' && body.staticToken) {
try {
const server = deps.registry.getDecrypted(body.id);
if (server && server.staticToken) {
const { client, close } = await createMcpClient(
server,
server.staticToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
allowPrivateAddresses: deps.allowPrivateAddresses,
callTimeoutMs: 30_000,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(body.id, list.tools);
logger.info(`[mcp:api] auto list_tools server=${body.id} count=${list.tools.length}`);
} finally {
await close();
}
}
} catch (err) {
logger.warn(
`[mcp:api] auto list_tools failed (deferred to manual refresh) server=${body.id}: ${(err as Error).message}`,
);
}
}
res.json({ ok: true });
});
router.delete('/:id', deps.requireAdmin, (req, res) => {
deps.registry.delete(req.params.id);
res.json({ ok: true });
});
router.post('/:id/tools/refresh', deps.requireAdmin, async (req, res) => {
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
// For api_key servers, use the static token; for oauth servers use 'anonymous'
// (the OAuth token is per-user and not available at admin-refresh time).
const accessToken = server.authKind === 'api_key' && server.staticToken
? server.staticToken
: 'anonymous';
try {
const { client, close } = await createMcpClient(
server,
accessToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
callTimeoutMs: 30_000,
allowPrivateAddresses: deps.allowPrivateAddresses,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(server.id, list.tools);
res.json({ ok: true, count: list.tools.length });
} finally {
await close();
}
} catch (err) {
logger.warn(
`[mcp:api] list_tools failed server=${server.id}: ${(err as Error).message}`,
);
res.status(502).json({ error: 'list_tools failed', detail: (err as Error).message });
}
});
return router;
}
export function createUserRouter(deps: McpApiDeps): Router {
const router = Router();
router.get('/', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const servers = deps.registry.listEnabledForUser(userId);
const out = servers.map((s) => ({
serverId: s.id,
serverName: s.name,
connected: deps.tokenManager.hasToken(userId, s.id),
authKind: s.authKind,
ownerId: s.ownerId,
}));
res.json({ connections: out });
});
router.delete('/:id', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
// For api_key global servers, DELETE has no meaningful effect
if (server.authKind === 'api_key' && server.ownerId === null) {
res.status(400).json({ error: 'api_key servers do not use per-user tokens; disconnect is not applicable' });
return;
}
// User-owned servers should be managed via /api/mcp/user-servers, not this route
if (server.ownerId !== null) {
res.status(400).json({ error: 'use /api/mcp/user-servers/:id to delete a user-owned server' });
return;
}
deps.tokenManager.deleteToken(userId, req.params.id);
res.json({ ok: true });
});
return router;
}
export function createUserServersRouter(deps: McpApiDeps): Router {
const router = Router();
router.get('/', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const servers = deps.registry.listEnabledForOwner(userId);
const enriched = servers.map((s) => ({
...s,
toolCount: deps.toolCache.getForServer(s.id).length,
}));
res.json({ servers: enriched });
});
router.post('/', deps.requireAuth, async (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const body = req.body as Partial<{
id: string;
name: string;
url: string;
authKind: string;
oauthClientId: string;
oauthClientSecret: string;
oauthScopes: string;
staticToken: string;
enabled: boolean;
}>;
if (!body.id || !ID_REGEX.test(body.id)) {
res.status(400).json({ error: 'id must match [a-z0-9_-]{1,64}' });
return;
}
if (!body.name || !body.url) {
res.status(400).json({ error: 'missing required fields: name, url' });
return;
}
const authKind = (body.authKind ?? 'oauth') as 'oauth' | 'api_key';
if (authKind === 'api_key') {
if (!body.staticToken) {
res.status(400).json({ error: 'authKind api_key requires staticToken' });
return;
}
} else if (authKind === 'oauth') {
if (!body.oauthClientId || !body.oauthClientSecret) {
res.status(400).json({ error: 'authKind oauth requires oauthClientId and oauthClientSecret' });
return;
}
} else {
res.status(400).json({ error: `unknown authKind: ${authKind}` });
return;
}
// Check for id collision with any existing server (global or other-user-owned)
const existing = deps.registry.getDecrypted(body.id);
if (existing) {
res.status(409).json({ error: `server id '${body.id}' already exists` });
return;
}
deps.registry.upsert({
id: body.id,
name: body.name,
url: body.url,
authKind,
ownerId: userId,
oauthClientId: body.oauthClientId,
oauthClientSecret: body.oauthClientSecret,
oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken,
enabled: body.enabled !== false,
createdBy: userId,
});
// Auto list_tools for api_key servers (token available immediately).
// For OAuth servers, defer until callback handler.
if (authKind === 'api_key' && body.staticToken) {
try {
const server = deps.registry.getDecrypted(body.id);
if (server && server.staticToken) {
const { client, close } = await createMcpClient(
server,
server.staticToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
allowPrivateAddresses: deps.allowPrivateAddresses,
callTimeoutMs: 30_000,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(body.id, list.tools);
logger.info(
`[mcp:api] auto list_tools server=${body.id} count=${list.tools.length}`,
);
} finally {
await close();
}
}
} catch (err) {
logger.warn(
`[mcp:api] auto list_tools failed (deferred to manual refresh) server=${body.id}: ${(err as Error).message}`,
);
}
}
res.json({ ok: true });
});
router.delete('/:id', deps.requireAuth, (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
if (server.ownerId !== userId) {
res.status(403).json({ error: 'forbidden: you do not own this server' });
return;
}
deps.registry.delete(req.params.id);
res.json({ ok: true });
});
router.post('/:id/tools/refresh', deps.requireAuth, async (req, res) => {
const userId = deps.getUserId(req);
if (!userId) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
const server = deps.registry.getDecrypted(req.params.id);
if (!server) {
res.status(404).json({ error: 'unknown server' });
return;
}
if (server.ownerId !== userId) {
res.status(403).json({ error: 'forbidden: you do not own this server' });
return;
}
const accessToken = server.authKind === 'api_key' && server.staticToken
? server.staticToken
: 'anonymous';
try {
const { client, close } = await createMcpClient(
server,
accessToken,
{
insecureLocalTestMode: deps.insecureLocalTestMode,
callTimeoutMs: 30_000,
allowPrivateAddresses: deps.allowPrivateAddresses,
},
);
try {
const list = (await client.listTools()) as {
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
};
deps.toolCache.replaceForServer(server.id, list.tools);
res.json({ ok: true, count: list.tools.length });
} finally {
await close();
}
} catch (err) {
logger.warn(
`[mcp:api] user list_tools failed server=${server.id}: ${(err as Error).message}`,
);
res.status(502).json({ error: 'list_tools failed', detail: (err as Error).message });
}
});
return router;
}
+245
View File
@@ -0,0 +1,245 @@
/**
* memory-api.test.ts — unit tests for /api/local/memory router
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createMemoryApi } from './memory-api.js';
import { upsertMemoryEntry } from '../user-folder/memory.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const USER_A = 'user-a';
/**
* App with req.user injected (authenticated).
*/
function makeApp(userId: string, dataDir: string): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' };
next();
});
app.use('/api/local/memory', createMemoryApi({ dataDir }));
return app;
}
/**
* App with no req.user — simulates missing auth.
*/
function makeUnauthApp(dataDir: string): express.Application {
const app = express();
app.use(express.json());
// No req.user set; authActive defaults to true inside the router
app.use('/api/local/memory', createMemoryApi({ dataDir }));
return app;
}
// ── Setup / Teardown ───────────────────────────────────────────────────────────
describe('Memory API', () => {
let tmpDir: string;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'memory-api-test-'));
app = makeApp(USER_A, tmpDir);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
// ── GET /entries ─────────────────────────────────────────────────────────────
describe('GET /entries', () => {
it('returns empty entries and null index when no memory exists', async () => {
const res = await request(app).get('/api/local/memory/entries');
expect(res.status).toBe(200);
expect(res.body.entries).toEqual([]);
expect(res.body.index).toBeNull();
});
it('returns parsed entries and index when entries exist', async () => {
// Seed directly via the memory helper
upsertMemoryEntry(tmpDir, USER_A, {
name: 'my-fact',
type: 'user',
description: 'A test fact',
body: 'some body content',
});
const res = await request(app).get('/api/local/memory/entries');
expect(res.status).toBe(200);
const entries = res.body.entries as Array<{ name: string; description: string; type: string; body: string }>;
expect(entries).toHaveLength(1);
expect(entries[0]!.name).toBe('my-fact');
expect(entries[0]!.description).toBe('A test fact');
expect(entries[0]!.type).toBe('user');
expect(entries[0]!.body.trim()).toBe('some body content');
// Index should contain the entry line
expect(typeof res.body.index).toBe('string');
expect(res.body.index).toContain('my-fact');
});
it('returns 401 when the request is unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp).get('/api/local/memory/entries');
expect(res.status).toBe(401);
});
});
// ── PUT /entries/:name ────────────────────────────────────────────────────────
describe('PUT /entries/:name', () => {
it('creates an entry and writes it to disk', async () => {
const res = await request(app)
.put('/api/local/memory/entries/my-note')
.send({ description: 'A useful note', type: 'reference', body: 'Details here.' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.name).toBe('my-note');
// Verify file is on disk
const factPath = join(tmpDir, USER_A, 'memory', 'my-note.md');
expect(existsSync(factPath)).toBe(true);
// Verify GET returns it
const getRes = await request(app).get('/api/local/memory/entries');
expect(getRes.status).toBe(200);
const names = (getRes.body.entries as Array<{ name: string }>).map(e => e.name);
expect(names).toContain('my-note');
});
it('updates an existing entry (upsert)', async () => {
// Create first
await request(app)
.put('/api/local/memory/entries/upsert-me')
.send({ description: 'Old description', type: 'user', body: 'old body' });
// Update
const res = await request(app)
.put('/api/local/memory/entries/upsert-me')
.send({ description: 'New description', type: 'feedback', body: 'new body' });
expect(res.status).toBe(200);
const getRes = await request(app).get('/api/local/memory/entries');
const entry = (getRes.body.entries as Array<{ name: string; description: string; type: string }>)
.find(e => e.name === 'upsert-me');
expect(entry).toBeDefined();
expect(entry!.description).toBe('New description');
expect(entry!.type).toBe('feedback');
});
it('returns 400 + rejected_bad_name for a name that is too long', async () => {
// Build a 65-char name using only URL-safe chars to avoid Express routing weirdness.
// isValidMemoryName rejects names longer than 64 chars.
const longName = 'a'.repeat(64) + 'b'; // 65 chars, all alphanumeric
const res = await request(app)
.put(`/api/local/memory/entries/${longName}`)
.send({ description: 'desc', type: 'user', body: 'body' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_bad_name');
});
it('returns 400 + rejected_unknown_type for invalid type', async () => {
const res = await request(app)
.put('/api/local/memory/entries/valid-name')
.send({ description: 'A description', type: 'bogus-type', body: 'body content' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_unknown_type');
});
it('returns 400 + rejected_body_too_large when body exceeds maxEntryBodyBytes', async () => {
// Default maxEntryBodyBytes is 8192; send 9000 bytes
const bigBody = 'x'.repeat(9000);
const res = await request(app)
.put('/api/local/memory/entries/big-entry')
.send({ description: 'too big', type: 'user', body: bigBody });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_body_too_large');
});
it('returns 400 + rejected_bad_description for multi-line description', async () => {
const res = await request(app)
.put('/api/local/memory/entries/multi-line-desc')
.send({ description: 'line one\nline two', type: 'user', body: 'body' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('rejected_bad_description');
});
it('accepts all valid types', async () => {
for (const type of ['user', 'feedback', 'project', 'reference'] as const) {
const res = await request(app)
.put(`/api/local/memory/entries/type-test-${type}`)
.send({ description: `type is ${type}`, type, body: 'body' });
expect(res.status).toBe(200);
}
});
it('returns 401 when unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp)
.put('/api/local/memory/entries/my-note')
.send({ description: 'desc', type: 'user', body: 'body' });
expect(res.status).toBe(401);
});
});
// ── DELETE /entries/:name ─────────────────────────────────────────────────────
describe('DELETE /entries/:name', () => {
it('removes an entry and updates the index', async () => {
// Seed entry
upsertMemoryEntry(tmpDir, USER_A, {
name: 'delete-me',
type: 'project',
description: 'Temporary fact',
body: 'will be deleted',
});
const res = await request(app).delete('/api/local/memory/entries/delete-me');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.name).toBe('delete-me');
// Fact file should be gone from memory dir (moved to trash)
const factPath = join(tmpDir, USER_A, 'memory', 'delete-me.md');
expect(existsSync(factPath)).toBe(false);
// GET should return empty entries
const getRes = await request(app).get('/api/local/memory/entries');
const names = (getRes.body.entries as Array<{ name: string }>).map(e => e.name);
expect(names).not.toContain('delete-me');
});
it('returns 404 for a nonexistent entry', async () => {
const res = await request(app).delete('/api/local/memory/entries/does-not-exist');
expect(res.status).toBe(404);
});
it('returns 404 for an invalid name (do not leak existence)', async () => {
// Name exceeding 64 chars → treated as 404 to not reveal system structure
const res = await request(app).delete(`/api/local/memory/entries/${'z'.repeat(65)}`);
expect(res.status).toBe(404);
});
it('returns 401 when unauthenticated', async () => {
const unauthApp = makeUnauthApp(tmpDir);
const res = await request(unauthApp).delete('/api/local/memory/entries/some-entry');
expect(res.status).toBe(401);
});
});
});
+194
View File
@@ -0,0 +1,194 @@
/**
* memory-api.ts — REST router for user memory entries
*
* Mounted at /api/local/memory
*
* Routes:
* GET /entries — list parsed entries + MEMORY.md index
* PUT /entries/:name — upsert entry (frontmatter validation enforced)
* DELETE /entries/:name — remove entry + update index
*
* Auth: all routes require an authenticated user (req.user).
* Owner: each operation scopes to req.user.id — no cross-user access.
*/
import { Router, type Request, type Response } from 'express';
import { join } from 'path';
import { logger } from '../logger.js';
import {
isValidMemoryName,
MEMORY_TYPES,
type MemoryType,
listMemoryEntries,
readMemoryIndexFromDir,
upsertMemoryEntry,
removeMemoryEntry,
} from '../user-folder/memory.js';
import { userMemoryDir } from '../user-folder/paths.js';
import { withUserLock } from '../engine/reflection/user-lock.js';
import { loadConfig } from '../config.js';
// ── Types ──────────────────────────────────────────────────────────────────────
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
// ── Deps ───────────────────────────────────────────────────────────────────────
export interface MemoryApiDeps {
/** Root data dir (same as userFolderRoot / dataDir in the rest of the app). */
dataDir: string;
/** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */
authActive?: boolean;
}
// ── Factory ────────────────────────────────────────────────────────────────────
export function createMemoryApi(deps: MemoryApiDeps): Router {
const { dataDir } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
// JSON body parser for this router
r.use((_req, _res, next) => {
// body-parser already applied globally only for certain routes; apply here too
next();
});
// ── Auth gate ──────────────────────────────────────────────────────────────
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// ── GET /entries ───────────────────────────────────────────────────────────
r.get('/entries', (req: Request, res: Response) => {
const u = getUser(req)!;
const memDir = userMemoryDir(dataDir, u.id);
try {
const entries = listMemoryEntries(memDir);
const index = readMemoryIndexFromDir(memDir);
res.json({ entries, index });
} catch (err) {
logger.error(`[memory-api] GET /entries failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list memory entries' });
}
});
// ── PUT /entries/:name ─────────────────────────────────────────────────────
// Validation thresholds (name pattern, four-value type, body byte cap) are
// shared with the reflection applier's semantic validator at
// `src/engine/reflection/semantic-validator.ts`. The rejection codes here
// match the ReflectionRejectionCode union so the UI can render either source
// consistently. If you add/rename a code, update both files together.
r.put('/entries/:name', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { name } = req.params;
// Validate name (mirrors semantic-validator's rejected_bad_name path)
if (!isValidMemoryName(name)) {
res.status(400).json({ error: 'rejected_bad_name' });
return;
}
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'rejected_bad_request' });
return;
}
const { description, type, body: entryBody } = body as {
description?: unknown;
type?: unknown;
body?: unknown;
};
// Validate description
if (typeof description !== 'string' || description.trim() === '') {
res.status(400).json({ error: 'rejected_bad_description' });
return;
}
if (description.includes('\n') || description.includes('\r')) {
res.status(400).json({ error: 'rejected_bad_description' });
return;
}
// Validate type
if (!MEMORY_TYPES.includes(type as MemoryType)) {
res.status(400).json({ error: 'rejected_unknown_type' });
return;
}
// Validate body
if (typeof entryBody !== 'string') {
res.status(400).json({ error: 'rejected_bad_body' });
return;
}
// Body byte-length cap from config
const cfg = loadConfig();
const maxBodyBytes = cfg.reflection.maxEntryBodyBytes;
if (Buffer.byteLength(entryBody, 'utf-8') > maxBodyBytes) {
res.status(400).json({ error: 'rejected_body_too_large' });
return;
}
try {
const result = await withUserLock(dataDir, u.id, async () => {
return upsertMemoryEntry(dataDir, u.id, {
name,
type: type as MemoryType,
description: description.trim(),
body: entryBody,
});
});
logger.info(`[memory-api] PUT /entries/${name} user=${u.id} path=${result.path}`);
res.json({ ok: true, name, path: result.path });
} catch (err) {
logger.error(`[memory-api] PUT /entries/${name} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to upsert memory entry' });
}
});
// ── DELETE /entries/:name ──────────────────────────────────────────────────
r.delete('/entries/:name', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { name } = req.params;
// Validate name — do not leak existence for invalid names
if (!isValidMemoryName(name)) {
res.status(404).json({ error: 'not_found' });
return;
}
try {
const removed = await withUserLock(dataDir, u.id, async () => {
return removeMemoryEntry(dataDir, u.id, name);
});
if (!removed) {
res.status(404).json({ error: 'not_found' });
return;
}
logger.info(`[memory-api] DELETE /entries/${name} user=${u.id}`);
res.json({ ok: true, name });
} catch (err) {
logger.error(`[memory-api] DELETE /entries/${name} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete memory entry' });
}
});
return r;
}
+203
View File
@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runMigrations } from '../db/migrate.js';
import { NotesRepository } from '../notes/notes-repository.js';
import { NotesService } from '../notes/notes-service.js';
import { createNotesApi } from './notes-api.js';
describe('notes-api', () => {
let tmpRoot: string;
let db: Database.Database;
let service: NotesService;
let auditCalls: any[];
function makeApp(userId: string, orgIds: string[] = []): express.Application {
const app = express();
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user', orgIds };
next();
});
app.use('/api/notes', createNotesApi({ service }));
return app;
}
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'notes-api-test-'));
db = new Database(join(tmpRoot, 'test.db'));
runMigrations(db);
db.prepare(`INSERT INTO users (id, email, name) VALUES ('alice','[email protected]','Alice'),('bob','[email protected]','Bob')`).run();
const repo = new NotesRepository(db);
auditCalls = [];
service = new NotesService({
db, repo, userFolderRoot: tmpRoot,
getUserOrgIds: () => ['team1'],
audit: (action, actor, target) => auditCalls.push({ action, actor, target }),
});
});
afterEach(() => {
db.close();
rmSync(tmpRoot, { recursive: true, force: true });
});
describe('GET /discover', () => {
it('returns visible notes for the consumer', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/discover');
expect(res.status).toBe(200);
expect(res.body.rows.map((r: any) => r.file_name)).toEqual(['pub.md']);
});
it('supports keyword search via q', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\ntitle: kubernetes\nvisibility: public\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/discover?q=kubernetes');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(1);
});
it('paginates', async () => {
for (let i = 0; i < 12; i++) {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: `f${i}.md`, content: `---\nvisibility: public\n---\nbody ${i}` });
}
const res = await request(makeApp('bob')).get('/api/notes/discover?limit=5&offset=5');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(5);
});
it('returns 401 if unauthenticated (authActive=true, default)', async () => {
const app = express();
app.use('/api/notes', createNotesApi({ service }));
const res = await request(app).get('/api/notes/discover');
expect(res.status).toBe(401);
});
it('synthesizes local user when authActive=false (no-auth deployment)', async () => {
// Regression for issue #347: /api/notes/discover returned 401 in
// no-auth deployments because the route had no synthetic-local
// fallback that user-folder-api / dashboard-api already had,
// breaking the Notes panel under User Folder.
db.prepare(`INSERT INTO users (id, email, name) VALUES ('local','[email protected]','Local')`).run();
service.writeNote({ ownerId: 'local', folder: 'cve', fileName: 'mine.md', content: '---\nvisibility: private\n---\nlocal note' });
const app = express();
app.use('/api/notes', createNotesApi({ service, authActive: false }));
const res = await request(app).get('/api/notes/discover?owner_id=me');
expect(res.status, JSON.stringify(res.body)).toBe(200);
const names = res.body.rows.map((r: any) => r.file_name);
expect(names).toContain('mine.md');
});
it('resolves owner_id=me to the caller user.id', async () => {
// Alice writes notes; the FileTree under the Notes tab queries
// /discover?owner_id=me — must resolve to alice, not the literal string "me".
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'a.md', content: '---\nvisibility: private\n---\nmine' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'b.md', content: '---\nvisibility: public\n---\nmine2' });
service.writeNote({ ownerId: 'bob', folder: 'cve', fileName: 'c.md', content: '---\nvisibility: public\n---\nbob' });
const res = await request(makeApp('alice')).get('/api/notes/discover?owner_id=me');
expect(res.status).toBe(200);
const names = res.body.rows.map((r: any) => r.file_name).sort();
expect(names).toEqual(['a.md', 'b.md']);
});
});
describe('GET /file', () => {
it('returns note body for visible note and logs audit', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody content' });
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=cve&file_name=pub.md');
expect(res.status).toBe(200);
expect(res.body.body).toContain('body content');
expect(auditCalls).toHaveLength(1);
expect(auditCalls[0]).toMatchObject({ action: 'read_note', actor: 'bob' });
});
it('returns 404 for private note', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=cve&file_name=priv.md');
expect(res.status).toBe(404);
expect(auditCalls).toHaveLength(0);
});
it('rejects invalid folder/file_name', async () => {
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=../etc&file_name=passwd');
expect(res.status).toBe(400);
});
});
describe('reindex and inject-preview', () => {
it('POST /reindex rebuilds the index for the requester', async () => {
const fs = require('fs');
fs.mkdirSync(join(tmpRoot, 'alice', 'notes', 'cve'), { recursive: true });
fs.writeFileSync(join(tmpRoot, 'alice', 'notes', 'cve', 'foo.md'), '---\nvisibility: public\n---\nbody');
const res = await request(makeApp('alice')).post('/api/notes/reindex?owner_id=me');
expect(res.status).toBe(200);
expect(res.body.indexed).toBe(1);
});
it('GET /inject-preview shows what will be injected', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody A' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'bar.md', content: '---\nvisibility: public\n---\nbody B' });
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'inject', enabled: true });
const res = await request(makeApp('bob')).get('/api/notes/inject-preview');
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(2);
expect(res.body.total_kb).toBeGreaterThan(0);
expect(res.body.budget_kb).toBeDefined();
});
});
describe('subscriptions', () => {
it('GET /subscriptions returns the user\'s subscriptions', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody' });
// Create a subscription via PUT
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
const res = await request(makeApp('bob')).get('/api/notes/subscriptions');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(1);
expect(res.body.rows[0]).toMatchObject({ publisher_user_id: 'alice', folder: 'cve', mode: 'search' });
});
it('PUT /subscriptions creates a subscription if folder is visible', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody' });
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'inject', enabled: true });
expect(res.status).toBe(200);
});
it('PUT /subscriptions rejects if folder has no visible notes', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
expect(res.status).toBe(403);
});
it('PUT /subscriptions rejects invalid mode', async () => {
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'bogus', enabled: true });
expect(res.status).toBe(400);
});
it('DELETE /subscriptions removes a subscription', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody' });
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
const del = await request(makeApp('bob'))
.delete('/api/notes/subscriptions?publisher_user_id=alice&folder=cve');
expect(del.status).toBe(200);
const list = await request(makeApp('bob')).get('/api/notes/subscriptions');
expect(list.body.rows).toHaveLength(0);
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import { Router, Request, Response, NextFunction, json as expressJson } from 'express';
import { NotesService } from '../notes/notes-service.js';
export interface NotesApiDeps {
service: NotesService;
/**
* Whether the bridge wired the auth subsystem (Passport / OAuth /
* sessions). When `false`, requests will not have a populated
* `req.user` because the upstream `requireAuth` middleware was not
* mounted; the route fills in a synthetic `local` user so the notes
* UI works in no-auth single-user mode (mirrors user-folder-api and
* dashboard-api). Defaults to `true` for backwards compatibility
* with the previous (auth-only) call sites.
*/
authActive?: boolean;
}
const NAME_RE = /^[a-zA-Z0-9._-]+$/;
export function createNotesApi(deps: NotesApiDeps): Router {
const router = Router();
const authActive = deps.authActive ?? true;
router.use((req: Request, res: Response, next: NextFunction) => {
if (!authActive && !(req as any).user) {
// No-auth single-user deployment: synthesize a `local` user so
// per-user storage (and the notes index) has a stable owner.
// Real auth flows are unaffected because Passport populates
// req.user before we get here.
//
// orgIds defaults to [] explicitly — NotesService reads
// user.orgIds.length when computing org-scoped visibility, and
// crashes on the undefined access otherwise.
(req as any).user = { id: 'local', role: 'user', orgIds: [] };
}
if (!(req as any).user) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
// `/api/notes` is NOT behind the `/api/local` requireAuth prefix, so it
// must enforce account status itself. Otherwise a disabled/pending user
// holding a still-valid session keeps notes access (parity with
// requireAuth in auth.ts). The synthetic no-auth `local` user has no
// status field and is unaffected.
const status = (req as any).user.status;
if (authActive && status !== undefined && status !== 'active') {
res.status(403).json({ error: 'account is not active' });
return;
}
next();
});
// Size-limited JSON parser replaces the hand-rolled unbounded reader below.
router.use(expressJson({ limit: '64kb' }));
router.get('/discover', (req, res) => {
const user = (req as any).user;
const rawOwnerId = req.query.owner_id ? String(req.query.owner_id) : undefined;
// `me` is a UI-convenient alias for the caller's own id (matches /reindex).
// Without this alias the FileTree under the Notes tab silently shows zero
// entries when it queries `?owner_id=me`, because the DB stores rows under
// the real user.id and never matches the literal string "me".
const ownerId = rawOwnerId === 'me' ? user.id : rawOwnerId;
const folder = req.query.folder ? String(req.query.folder) : undefined;
const q = req.query.q ? String(req.query.q) : undefined;
const limit = req.query.limit ? Math.max(1, Math.min(200, parseInt(String(req.query.limit), 10) || 50)) : 50;
const offset = req.query.offset ? Math.max(0, parseInt(String(req.query.offset), 10) || 0) : 0;
try {
const rows = deps.service.discoverNotes({ user, ownerId, folder, q, limit, offset });
res.json({ rows });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
router.get('/file', (req, res) => {
const user = (req as any).user;
const rawOwnerId = String(req.query.owner_id ?? '');
const ownerId = rawOwnerId === 'me' ? user.id : rawOwnerId;
const folder = String(req.query.folder ?? '');
const fileName = String(req.query.file_name ?? '');
if (!NAME_RE.test(folder) || !NAME_RE.test(fileName)) {
res.status(400).json({ error: 'invalid folder or file_name' });
return;
}
try {
const out = deps.service.getCrossUserNote({ user, ownerId, folder, fileName });
if (!out) {
res.status(404).json({ error: 'not found or no permission' });
return;
}
res.json({ fm: out.fm, body: out.body, content: out.content });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
router.get('/subscriptions', (req, res) => {
const user = (req as any).user;
const rows = deps.service.listSubscriptions(user.id);
res.json({ rows });
});
router.put('/subscriptions', (req, res) => {
const user = (req as any).user;
const body = (req as any).body ?? {};
const publisherUserId = String(body.publisher_user_id ?? '');
const folder = String(body.folder ?? '');
const mode = String(body.mode ?? '');
const enabled = body.enabled === false ? 0 : 1;
if (!publisherUserId || !NAME_RE.test(folder)) {
res.status(400).json({ error: 'invalid publisher_user_id or folder' });
return;
}
if (mode !== 'search' && mode !== 'inject') {
res.status(400).json({ error: 'invalid mode (must be search or inject)' });
return;
}
try {
deps.service.upsertSubscription({
consumerUser: user,
publisherUserId, folder, mode, enabled,
});
res.json({ ok: true });
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('no visible notes')) {
res.status(403).json({ error: msg });
} else {
res.status(400).json({ error: msg });
}
}
});
router.delete('/subscriptions', (req, res) => {
const user = (req as any).user;
const publisherUserId = String(req.query.publisher_user_id ?? '');
const folder = String(req.query.folder ?? '');
if (!publisherUserId || !NAME_RE.test(folder)) {
res.status(400).json({ error: 'invalid publisher_user_id or folder' });
return;
}
deps.service.deleteSubscription({ consumerUserId: user.id, publisherUserId, folder });
res.json({ ok: true });
});
router.post('/reindex', (req, res) => {
const user = (req as any).user;
const ownerIdParam = req.query.owner_id ? String(req.query.owner_id) : 'me';
const ownerId = ownerIdParam === 'me' ? user.id : ownerIdParam;
if (ownerId !== user.id && user.role !== 'admin') {
res.status(403).json({ error: 'forbidden: only owner or admin can reindex' });
return;
}
try {
const stats = deps.service.reindex(ownerId);
res.json({ ok: true, ...stats });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/inject-preview', (req, res) => {
const user = (req as any).user;
const preview = deps.service.injectPreview(user);
res.json(preview);
});
return router;
}
+250
View File
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import express, { type Request, type Response, type NextFunction } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { VapidKeyStore } from '../vapid-store.js';
import { PushService } from '../push-service.js';
import { mountNotificationsApi, resetRateLimitsForTest } from './notifications-api.js';
// Mock web-push so the test never makes real network calls.
vi.mock('web-push', () => {
const sendNotification = vi.fn().mockResolvedValue({ statusCode: 201 });
return {
default: {
sendNotification,
setVapidDetails: vi.fn(),
generateVAPIDKeys: () => ({
publicKey: 'BPubKeyMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMaterialMa',
privateKey: 'privateKeyMaterialMaterialMaterialMaterial',
}),
},
};
});
const SUBJECT = 'https://aao.example/';
function buildApp(opts: {
repo: Repository;
pushService: PushService | null;
vapidStore: VapidKeyStore | null;
userId: string;
}): express.Application {
const app = express();
const requireAuth = (req: Request, _res: Response, next: NextFunction) => {
(req as unknown as { user: { id: string; role: string } }).user = {
id: opts.userId,
role: 'user',
};
next();
};
mountNotificationsApi(app, {
repo: opts.repo,
pushService: opts.pushService,
vapidStore: opts.vapidStore,
requireAuth,
});
return app;
}
describe('/api/notifications/*', () => {
let tempDir = '';
let repo: Repository;
let store: VapidKeyStore;
let service: PushService;
let app: express.Application;
let userId = '';
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-notif-api-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
store = new VapidKeyStore(join(tempDir, 'vapid.json'), join(tempDir, 'vapid-history'));
store.loadOrGenerate(SUBJECT);
service = new PushService(repo, store);
const user = repo.createUser({ email: '[email protected]', name: 'u', role: 'user', status: 'active' });
userId = user.id;
app = buildApp({ repo, pushService: service, vapidStore: store, userId });
resetRateLimitsForTest();
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
describe('GET /vapid-public-key', () => {
it('returns public key + keyId, never private key', async () => {
const r = await request(app).get('/api/notifications/vapid-public-key');
expect(r.status).toBe(200);
expect(r.body.publicKey).toBeTruthy();
expect(r.body.keyId).toBeTruthy();
expect(r.body).not.toHaveProperty('privateKey');
});
it('503 when push disabled (no service)', async () => {
const appOff = buildApp({ repo, pushService: null, vapidStore: null, userId });
const r = await request(appOff).get('/api/notifications/vapid-public-key');
expect(r.status).toBe(503);
});
});
describe('subscriptions lifecycle', () => {
const validBody = {
endpoint: 'https://fcm.googleapis.com/fcm/send/abc',
p256dh: 'pubkey',
auth: 'authsec',
userAgent: 'Chrome on Pixel',
};
it('POST → GET → DELETE full cycle', async () => {
const post = await request(app).post('/api/notifications/subscriptions').send(validBody);
expect(post.status).toBe(200);
const id = post.body.id;
const list = await request(app).get('/api/notifications/subscriptions');
expect(list.status).toBe(200);
expect(list.body.subscriptions).toHaveLength(1);
expect(list.body.subscriptions[0]).not.toHaveProperty('p256dh');
expect(list.body.subscriptions[0]).not.toHaveProperty('auth');
expect(list.body.subscriptions[0]).not.toHaveProperty('endpoint');
expect(list.body.subscriptions[0].endpointHost).toBe('fcm.googleapis.com');
const del = await request(app).delete(`/api/notifications/subscriptions/${id}`);
expect(del.status).toBe(200);
const list2 = await request(app).get('/api/notifications/subscriptions');
expect(list2.body.subscriptions).toHaveLength(0);
});
it('rejects non-https endpoint', async () => {
const r = await request(app).post('/api/notifications/subscriptions').send({
...validBody, endpoint: 'http://insecure.example/x',
});
expect(r.status).toBe(400);
});
it('rejects missing p256dh / auth', async () => {
const r1 = await request(app).post('/api/notifications/subscriptions').send({
endpoint: validBody.endpoint, auth: 'a',
});
expect(r1.status).toBe(400);
const r2 = await request(app).post('/api/notifications/subscriptions').send({
endpoint: validBody.endpoint, p256dh: 'p',
});
expect(r2.status).toBe(400);
});
it('DELETE: cannot delete another user\'s subscription (returns 404)', async () => {
// create another user with a subscription
const other = repo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
const otherSub = repo.upsertPushSubscription({
userId: other.id,
endpoint: 'https://fcm.googleapis.com/fcm/send/other',
p256dh: 'p', auth: 'a', vapidKeyId: store.getCurrent().keyId,
});
const del = await request(app).delete(`/api/notifications/subscriptions/${otherSub.id}`);
expect(del.status).toBe(404);
// still there in DB
expect(repo.getPushSubscriptionById(otherSub.id)).not.toBeNull();
});
it('endpoint UNIQUE: re-POST from a different user transfers ownership', async () => {
// Seed userA's existing subscription with the same endpoint
const other = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
repo.upsertPushSubscription({
userId: other.id, endpoint: validBody.endpoint,
p256dh: 'oldp', auth: 'olda', vapidKeyId: store.getCurrent().keyId,
});
// Current user (userId) re-subscribes the same endpoint
const r = await request(app).post('/api/notifications/subscriptions').send(validBody);
expect(r.status).toBe(200);
const meSubs = repo.listPushSubscriptionsForUser(userId);
const otherSubs = repo.listPushSubscriptionsForUser(other.id);
expect(meSubs).toHaveLength(1);
expect(otherSubs).toHaveLength(0);
});
it('rate limit: 11th subscribe in same hour returns 429', async () => {
for (let i = 0; i < 10; i++) {
const r = await request(app).post('/api/notifications/subscriptions').send({
...validBody, endpoint: `https://fcm.googleapis.com/fcm/send/${i}`,
});
expect(r.status).toBe(200);
}
const r11 = await request(app).post('/api/notifications/subscriptions').send({
...validBody, endpoint: 'https://fcm.googleapis.com/fcm/send/11',
});
expect(r11.status).toBe(429);
expect(r11.body.retryAfter).toBeGreaterThan(0);
});
});
describe('preferences', () => {
it('GET returns defaults for new users', async () => {
const r = await request(app).get('/api/notifications/preferences');
expect(r.status).toBe(200);
expect(r.body.enabled).toBe(true);
expect(r.body.events).toEqual({
running: true, succeeded: true, failed: true, waiting_human: true,
});
expect(r.body.includeDetails).toBe(false);
expect(r.body.v1Migrated).toBe(false);
});
it('PUT applies partial update', async () => {
const r = await request(app).put('/api/notifications/preferences').send({
enabled: false, events: { succeeded: false },
});
expect(r.status).toBe(200);
expect(r.body.enabled).toBe(false);
expect(r.body.events.succeeded).toBe(false);
expect(r.body.events.running).toBe(true);
});
it('PUT rejects non-boolean fields', async () => {
const r = await request(app).put('/api/notifications/preferences').send({
enabled: 'yes',
});
expect(r.status).toBe(400);
});
it('migrate-from-localstorage: first call applies, second returns 409', async () => {
const r1 = await request(app)
.post('/api/notifications/preferences/migrate-from-localstorage')
.send({ enabled: true, events: { running: false } });
expect(r1.status).toBe(200);
expect(r1.body.prefs.v1Migrated).toBe(true);
expect(r1.body.prefs.events.running).toBe(false);
const r2 = await request(app)
.post('/api/notifications/preferences/migrate-from-localstorage')
.send({ enabled: false });
expect(r2.status).toBe(409);
});
});
describe('test endpoint', () => {
it('returns 200 ok when push enabled', async () => {
const r = await request(app).post('/api/notifications/test');
expect(r.status).toBe(200);
expect(r.body.ok).toBe(true);
});
it('503 when push disabled', async () => {
const appOff = buildApp({ repo, pushService: null, vapidStore: null, userId });
const r = await request(appOff).post('/api/notifications/test');
expect(r.status).toBe(503);
});
it('rate limit: 6th test in same hour returns 429', async () => {
for (let i = 0; i < 5; i++) {
const r = await request(app).post('/api/notifications/test');
expect(r.status).toBe(200);
}
const r6 = await request(app).post('/api/notifications/test');
expect(r6.status).toBe(429);
});
});
});
+291
View File
@@ -0,0 +1,291 @@
import { type Application, type Request, type Response, type NextFunction } from 'express';
import express from 'express';
import { logger } from '../logger.js';
import type { Repository, NotifyEventType, NotificationPrefsUpdate } from '../db/repository.js';
import type { VapidKeyStore } from '../vapid-store.js';
import type { PushService } from '../push-service.js';
/**
* `/api/notifications/*` routes for Web Push V2.
* Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
*
* When `pushService` is null (push.enabled === false), all POST/DELETE/test
* endpoints return 503; read endpoints still work so the UI can show
* "管理者により無効化されています".
*/
type AuthedUser = { id: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as unknown as { user?: AuthedUser }).user;
}
function requireUser(req: Request, res: Response): AuthedUser | null {
const user = getUser(req);
if (!user) {
res.status(401).json({ error: 'auth required' });
return null;
}
return user;
}
// ── Per-user in-memory sliding-window rate limiter ─────────────────────
// Maps `${key}:${userId}` → array of recent request timestamps (ms).
// Window is fixed at 1 hour; sufficient for low-frequency notification ops.
const rateBuckets = new Map<string, number[]>();
const ONE_HOUR_MS = 60 * 60 * 1000;
function rateLimit(key: string, maxPerHour: number) {
return (req: Request, res: Response, next: NextFunction): void => {
const user = getUser(req);
if (!user) {
res.status(401).json({ error: 'auth required' });
return;
}
const bucketKey = `${key}:${user.id}`;
const now = Date.now();
const cutoff = now - ONE_HOUR_MS;
const bucket = (rateBuckets.get(bucketKey) ?? []).filter(t => t > cutoff);
if (bucket.length >= maxPerHour) {
res.status(429).json({
error: 'rate limit exceeded',
retryAfter: Math.ceil((bucket[0]! + ONE_HOUR_MS - now) / 1000),
});
return;
}
bucket.push(now);
rateBuckets.set(bucketKey, bucket);
next();
};
}
/** Test hook — only used by unit tests. */
export function resetRateLimitsForTest(): void {
rateBuckets.clear();
}
// ── Input validation ──────────────────────────────────────────────────
function validateSubscriptionInput(body: unknown): {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string;
} | { error: string } {
if (!body || typeof body !== 'object') return { error: 'body required' };
const b = body as Record<string, unknown>;
if (typeof b.endpoint !== 'string') return { error: 'endpoint required' };
if (!b.endpoint.startsWith('https://')) return { error: 'endpoint must be https' };
if (b.endpoint.length > 2048) return { error: 'endpoint too long' };
if (typeof b.p256dh !== 'string' || b.p256dh.length === 0 || b.p256dh.length > 200) {
return { error: 'p256dh must be 1..200 chars' };
}
if (typeof b.auth !== 'string' || b.auth.length === 0 || b.auth.length > 200) {
return { error: 'auth must be 1..200 chars' };
}
const ua = typeof b.userAgent === 'string' ? b.userAgent.slice(0, 200) : undefined;
return { endpoint: b.endpoint, p256dh: b.p256dh, auth: b.auth, ...(ua ? { userAgent: ua } : {}) };
}
function validatePrefsInput(body: unknown): NotificationPrefsUpdate | { error: string } {
if (!body || typeof body !== 'object') return { error: 'body required' };
const b = body as Record<string, unknown>;
const update: NotificationPrefsUpdate = {};
if (b.enabled !== undefined) {
if (typeof b.enabled !== 'boolean') return { error: 'enabled must be boolean' };
update.enabled = b.enabled;
}
if (b.events !== undefined) {
if (!b.events || typeof b.events !== 'object') return { error: 'events must be object' };
const events = b.events as Record<string, unknown>;
const eventUpdate: Partial<Record<NotifyEventType, boolean>> = {};
for (const key of ['running', 'succeeded', 'failed', 'waiting_human'] as const) {
if (events[key] !== undefined) {
if (typeof events[key] !== 'boolean') return { error: `events.${key} must be boolean` };
eventUpdate[key] = events[key] as boolean;
}
}
update.events = eventUpdate as Record<NotifyEventType, boolean>;
}
if (b.includeDetails !== undefined) {
if (typeof b.includeDetails !== 'boolean') return { error: 'includeDetails must be boolean' };
update.includeDetails = b.includeDetails;
}
return update;
}
// ── Public DTOs (do NOT leak p256dh / auth / privateKey / etc.) ────────
function toPublicSubscription(sub: {
id: string;
endpoint: string;
userAgent: string | null;
createdAt: string;
lastSuccessAt: string | null;
lastFailureAt: string | null;
failureCount: number;
}) {
return {
id: sub.id,
// Truncate endpoint to scheme + host for UI display; full URL is sensitive.
endpointHost: (() => {
try { return new URL(sub.endpoint).host; } catch { return 'unknown'; }
})(),
userAgent: sub.userAgent,
createdAt: sub.createdAt,
lastSuccessAt: sub.lastSuccessAt,
lastFailureAt: sub.lastFailureAt,
failureCount: sub.failureCount,
};
}
// ── Mount ──────────────────────────────────────────────────────────────
export interface NotificationsApiDeps {
repo: Repository;
pushService: PushService | null;
vapidStore: VapidKeyStore | null;
/** Plugged in by server.ts when auth is active; identity transform otherwise. */
requireAuth: (req: Request, res: Response, next: NextFunction) => void;
}
export function mountNotificationsApi(app: Application, deps: NotificationsApiDeps): void {
const { repo, pushService, vapidStore, requireAuth } = deps;
const json = express.json({ limit: '64kb' });
// GET /vapid-public-key — always 200 when push is enabled; required before subscribe.
app.get('/api/notifications/vapid-public-key', requireAuth, (req, res) => {
const user = requireUser(req, res); if (!user) return;
if (!pushService || !vapidStore) {
res.status(503).json({ error: 'push disabled' });
return;
}
const k = vapidStore.getCurrent();
res.json({ publicKey: k.publicKey, keyId: k.keyId });
});
// GET /subscriptions — caller's own devices.
app.get('/api/notifications/subscriptions', requireAuth, (req, res) => {
const user = requireUser(req, res); if (!user) return;
const subs = repo.listPushSubscriptionsForUser(user.id);
res.json({ subscriptions: subs.map(toPublicSubscription) });
});
// POST /subscriptions — register/upsert (endpoint UNIQUE moves ownership).
app.post(
'/api/notifications/subscriptions',
requireAuth,
json,
rateLimit('push:subscribe', 10),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
if (!pushService || !vapidStore) {
res.status(503).json({ error: 'push disabled' });
return;
}
const parsed = validateSubscriptionInput(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const current = vapidStore.getCurrent();
const { id } = repo.upsertPushSubscription({
userId: user.id,
endpoint: parsed.endpoint,
p256dh: parsed.p256dh,
auth: parsed.auth,
userAgent: parsed.userAgent ?? null,
vapidKeyId: current.keyId,
});
res.json({ id });
},
);
// DELETE /subscriptions/:id — only delete your own.
app.delete(
'/api/notifications/subscriptions/:id',
requireAuth,
rateLimit('push:unsubscribe', 30),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
const sub = repo.getPushSubscriptionById(req.params.id!);
if (!sub || sub.userId !== user.id) {
res.status(404).json({ error: 'not found' });
return;
}
repo.deletePushSubscription(sub.id);
res.json({ ok: true });
},
);
// GET /preferences — auto-creates a default row on first access.
app.get('/api/notifications/preferences', requireAuth, (req, res) => {
const user = requireUser(req, res); if (!user) return;
res.json(repo.getUserNotificationPrefs(user.id));
});
// PUT /preferences — partial update.
app.put(
'/api/notifications/preferences',
requireAuth,
json,
rateLimit('push:prefs', 30),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
const parsed = validatePrefsInput(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
repo.upsertUserNotificationPrefs(user.id, parsed);
res.json(repo.getUserNotificationPrefs(user.id));
},
);
// POST /preferences/migrate-from-localstorage — one-shot V1 → V2.
app.post(
'/api/notifications/preferences/migrate-from-localstorage',
requireAuth,
json,
rateLimit('push:migrate', 3),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
const parsed = validatePrefsInput(req.body);
if ('error' in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const flipped = repo.markV1MigrationComplete(user.id);
if (!flipped) {
res.status(409).json({ error: 'already migrated' });
return;
}
repo.upsertUserNotificationPrefs(user.id, parsed);
res.json({ ok: true, prefs: repo.getUserNotificationPrefs(user.id) });
},
);
// POST /test — send a test push.
app.post(
'/api/notifications/test',
requireAuth,
rateLimit('push:test', 5),
(req, res) => {
const user = requireUser(req, res); if (!user) return;
if (!pushService) {
res.status(503).json({ error: 'push disabled' });
return;
}
pushService.enqueue({
event: 'succeeded',
taskId: 0,
taskTitle: 'テスト通知',
pieceName: 'V2 Web Push 動作確認',
ownerId: user.id,
});
res.json({ ok: true });
},
);
logger.info('[notifications-api] mounted (/api/notifications/*)');
}
+170
View File
@@ -0,0 +1,170 @@
import { Router } from 'express';
import httpProxy from 'http-proxy';
const { createProxyServer } = httpProxy;
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
import express from 'express';
import type { Server } from 'http';
import type { SessionManager, BrowserSession } from '../engine/browser-session.js';
import type { UpgradeAuthChecker } from './auth.js';
import { logger } from '../logger.js';
/**
* noVNC upgrade を許可するかの判定コールバック。
* server.ts が Repository を見て構築する。novnc-proxy 側は Repository を
* 知らずに済ませるための薄いインターフェース。
*/
export type NovncSessionAuthorizer = (
session: BrowserSession,
user: Express.User,
) => Promise<boolean>;
const __dirname = dirname(fileURLToPath(import.meta.url));
/** vendor/noVNC の Web 配布物 (vnc.html を含む) ディレクトリ */
function novncStaticDir(): string {
return resolve(__dirname, '../../vendor/noVNC');
}
/**
* vendor/noVNC/vnc.html が配置済みかをチェックする。
* Browser タブの iframe が `/novnc/vnc.html` を読みに行くので、
* これが無いと express.static が 404 を返し catch-all ハンドラが
* `{"error":"Not found"}` を返してしまう。
* Docker / 新規セットアップ時の取り違いを早めに検知するため、
* API 層と起動時ログの両方で参照する。
*/
export function isNovncStaticInstalled(): boolean {
return existsSync(resolve(novncStaticDir(), 'vnc.html'));
}
/**
* noVNC クライアント URL を生成するヘルパー(全箇所で統一使用)。
*
* `path` は **絶対パス** (`/novnc/<sid>/websockify`) で渡す必要がある:
* - noVNC v1.6.0 は `new URL(path, location.href)` で WebSocket URL を組み立てる。
* vnc.html 自体が `/novnc/vnc.html` 配下にあるため、相対パス
* (`novnc/<sid>/websockify`) を渡すと `/novnc/novnc/<sid>/websockify` と
* `/novnc/` が二重になり接続できなくなる。
* - noVNC v1.5.0 系は単純文字列連結 (`/${path}`) で組み立てるため、絶対パスを渡すと
* `//novnc/...` と先頭スラッシュが二重になる。Express の upgrade ハンドラ側で
* `^\/+novnc\/` と寛容にマッチさせて両系統を吸収する。
*/
export function buildNovncPath(sessionId: string): string {
return `/novnc/vnc.html?path=/novnc/${sessionId}/websockify&autoconnect=true&resize=scale`;
}
export function createNovncRouter(): Router {
const router = Router();
if (!isNovncStaticInstalled()) {
logger.warn(
'[novnc-proxy] vendor/noVNC/vnc.html が見つかりません。Browser タブの iframe で 404 が出ます。' +
' scripts/setup-novnc.sh を実行するか、Dockerfile で noVNC tarball を展開してください。',
);
}
router.use(express.static(novncStaticDir()));
return router;
}
/**
* noVNC WebSocket プロキシをセットアップする。
*
* セキュリティ (auth 有効時):
* - kind === 'pool' (CAPTCHA Pool): user.role === 'admin' のみ通す
* - kind === 'task' (Task Session): authorizeSession コールバックでタスク
* visibility を判定し、見られるユーザーだけ通す
* - 旧モデル (kind 未設定 / userId 直接マッチ): 従来どおり owner / admin
* - authenticateUpgrade 未設定 (dev モード) は session 存在確認だけで通す
*/
export function setupNovncWebSocketProxy(
server: Server,
getSessionManager: () => SessionManager | null,
authenticateUpgrade?: UpgradeAuthChecker,
authorizeSession?: NovncSessionAuthorizer,
): void {
const proxy = createProxyServer({ ws: true });
proxy.on('error', (err, _req, res) => {
logger.warn(`[novnc-proxy] WebSocket proxy error: ${err.message}`);
// res is a net.Socket for WebSocket upgrades, not http.ServerResponse
if (res && 'writeHead' in res && typeof res.writeHead === 'function') {
try { res.writeHead(502); res.end(); } catch {}
} else if (res && 'destroy' in res && typeof res.destroy === 'function') {
res.destroy();
}
});
server.on('upgrade', (req, socket, head) => {
const url = req.url ?? '';
// Match /novnc/:sessionId/websockify (先頭スラッシュ二重 //novnc/... も許容)
// 旧 noVNC (v1.5.x) は単純文字列連結で URL を組むため、絶対パスを `path` に
// 渡すと先頭が `//` で来る。新 (v1.6.0+) は `new URL()` で正規化して `/` 1個。
const match = url.match(/^\/+novnc\/([^/]+)\/websockify/);
if (!match) return; // Let other upgrade handlers (if any) handle it
const sessionId = match[1]!;
const sm = getSessionManager();
if (!sm) {
logger.warn(`[novnc-proxy] SessionManager not available, rejecting WebSocket for ${sessionId}`);
socket.destroy();
return;
}
const session = sm.getSession(sessionId);
if (!session) {
logger.warn(`[novnc-proxy] Unknown session ${sessionId}, rejecting WebSocket`);
socket.destroy();
return;
}
const performProxy = (): void => {
proxy.ws(req, socket, head, {
target: `http://127.0.0.1:${session.novncPort}`,
});
};
if (!authenticateUpgrade) {
// 認証無効モード: 従来どおり session 存在確認のみで接続を許可
performProxy();
return;
}
// 認証有効モード: cookie からユーザーを解決して session の種別ごとに認可
authenticateUpgrade(req).then(async (user) => {
if (!user) {
logger.warn(`[novnc-proxy] Unauthenticated WebSocket attempt for session ${sessionId}, rejecting`);
socket.destroy();
return;
}
let allowed: boolean;
if (authorizeSession) {
// 新モデル: pool / task に応じて authorizeSession で判定
try {
allowed = await authorizeSession(session, user);
} catch (err) {
logger.warn(`[novnc-proxy] authorizeSession threw for session ${sessionId}: ${(err as Error).message}, rejecting`);
socket.destroy();
return;
}
} else {
// 旧モデル fallback: owner-or-admin
const isOwner = session.userId === user.id;
const isAdmin = user.role === 'admin';
allowed = isOwner || isAdmin;
}
if (!allowed) {
logger.warn(`[novnc-proxy] User ${user.id} (role=${user.role}) denied access to session ${sessionId} (kind=${session.kind} taskId=${session.taskId ?? '-'} owner=${session.userId ?? '-'})`);
socket.destroy();
return;
}
performProxy();
}).catch((err) => {
logger.warn(`[novnc-proxy] Auth check failed for session ${sessionId}: ${(err as Error).message}`);
socket.destroy();
});
});
}
+499
View File
@@ -0,0 +1,499 @@
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { mountPiecesApi } from './pieces-api.js';
function makeGeneralPieceYaml(): string {
return [
'name: general',
'description: 汎用タスク',
'max_movements: 25',
'initial_movement: understand',
'movements:',
' - name: understand',
' edit: false',
' persona: analyst',
' instruction: |',
' タスクを確認する。',
' allowed_tools: [Read, Glob]',
' default_next: execute',
' rules:',
' - condition: 方針が立った',
' next: execute',
' - name: execute',
' edit: true',
' persona: worker',
' instruction: |',
' 作業を実行する。',
' allowed_tools: [Read, Write]',
' default_next: COMPLETE',
' rules:',
' - condition: 完了',
' next: COMPLETE',
].join('\n');
}
function makeMinimalPieceYaml(name: string, description = 'x'): string {
return [
`name: ${name}`,
`description: ${description}`,
'max_movements: 1',
'initial_movement: only',
'movements:',
' - name: only',
' edit: false',
' persona: p',
' instruction: i',
' allowed_tools: [Read]',
' default_next: COMPLETE',
' rules: []',
].join('\n');
}
describe('Pieces API (no auth — legacy behavior)', () => {
let app: express.Application;
let piecesDir: string;
beforeEach(() => {
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-'));
piecesDir = join(tempDir, 'pieces');
mkdirSync(piecesDir);
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
app = express();
app.use(express.json());
mountPiecesApi(app, { piecesDir });
});
it('GET /api/pieces returns piece list', async () => {
const res = await request(app).get('/api/pieces');
expect(res.status).toBe(200);
expect(res.body.pieces).toHaveLength(1);
expect(res.body.pieces[0].name).toBe('general');
expect(res.body.pieces[0].source).toBe('builtin');
expect(res.body.pieces[0].custom).toBe(false);
});
it('GET /api/pieces/:name returns full piece', async () => {
const res = await request(app).get('/api/pieces/general');
expect(res.status).toBe(200);
expect(res.body.piece.name).toBe('general');
expect(res.body.piece.movements).toHaveLength(2);
expect(res.body.source).toBe('builtin');
});
it('GET /api/pieces/:name returns 404 for unknown', async () => {
const res = await request(app).get('/api/pieces/nonexistent');
expect(res.status).toBe(404);
});
it('PUT /api/pieces/:name updates piece', async () => {
const res = await request(app)
.put('/api/pieces/general')
.send({
name: 'general',
description: '更新済み',
max_movements: 30,
initial_movement: 'understand',
movements: [
{ name: 'understand', edit: false, persona: 'analyst', instruction: 'テスト', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('POST /api/pieces creates new piece', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'custom',
description: 'カスタム',
max_movements: 10,
initial_movement: 'work',
movements: [
{ name: 'work', edit: true, persona: 'worker', instruction: '作業する', allowed_tools: ['Read', 'Write'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(201);
expect(res.body.ok).toBe(true);
});
it('POST /api/pieces rejects duplicate name', async () => {
const res = await request(app)
.post('/api/pieces')
.send({ name: 'general', description: 'x', max_movements: 1, initial_movement: 'a', movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }] });
expect(res.status).toBe(409);
});
it('POST /api/pieces rejects rules[].next: COMPLETE (Phase 6b)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'phase6b-reject',
description: 'should be rejected',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
rules: [{ condition: 'ok', next: 'COMPLETE' }] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/rules\[\]\.next cannot be "COMPLETE"/);
});
it('POST /api/pieces accepts default_next: COMPLETE (engine-internal sentinel)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'phase6b-default-ok',
description: 'default_next is fine',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(201);
});
// Phase 4: per-movement SSH connection allowlist validation.
it('POST /api/pieces rejects SshExec without allowed_ssh_connections', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-missing-allowlist',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/allowed_ssh_connections is required/);
});
it('POST /api/pieces accepts SshExec with UUID allowlist', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-uuid-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces accepts SshExec with ["*"] wildcard', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-wildcard-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['*'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces accepts SshExec with empty allowlist (explicit deny)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-empty-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: [],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces rejects allowed_ssh_connections with bad format', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-bad-format',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: ['BAD-NOT-LOWERCASE'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/must be '\*' or a lowercase hex/);
});
it('POST /api/pieces rejects non-array allowed_ssh_connections', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-non-array',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
allowed_ssh_connections: 'not-an-array',
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/must be an array/);
});
it('POST /api/pieces accepts allowed_ssh_connections without SSH tools (no-op)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'ssh-noop',
description: 'x',
max_movements: 1,
initial_movement: 'only',
movements: [
{
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
default_next: 'COMPLETE', rules: [],
},
],
});
expect(res.status).toBe(201);
});
it('DELETE /api/pieces/:name deletes piece', async () => {
await request(app).post('/api/pieces').send({
name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }],
});
const res = await request(app).delete('/api/pieces/deleteme');
expect(res.status).toBe(200);
});
it('DELETE /api/pieces/general is forbidden', async () => {
const res = await request(app).delete('/api/pieces/general');
expect(res.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// Auth-aware tests (per-user custom pieces + non-admin write authz)
// ---------------------------------------------------------------------------
type UserShape = { id: string; role: 'admin' | 'user' };
function makeAuthApp(piecesDir: string, userPiecesRootDir: string, user: UserShape | null): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
if (user) (req as any).user = user;
next();
});
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
return app;
}
describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
let piecesDir: string;
let userPiecesRootDir: string;
beforeEach(() => {
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-auth-'));
piecesDir = join(tempDir, 'pieces');
userPiecesRootDir = join(tempDir, 'users');
mkdirSync(piecesDir);
mkdirSync(userPiecesRootDir);
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'built-in chat'));
});
it('GET /api/pieces returns built-ins for any authenticated non-admin', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
expect(res.body.pieces.map((p: any) => p.name).sort()).toEqual(['chat', 'general']);
for (const p of res.body.pieces) {
expect(p.source).toBe('builtin');
expect(p.custom).toBe(false);
}
});
it('GET /api/pieces merges caller\'s user-custom pieces (own only)', async () => {
// Alice has my-tool, Bob has bob-tool
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-tool.yaml'), makeMinimalPieceYaml('my-tool', "alice's piece"));
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-tool.yaml'), makeMinimalPieceYaml('bob-tool', "bob's piece"));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
expect(Object.keys(byName).sort()).toEqual(['chat', 'general', 'my-tool']);
expect(byName['my-tool'].source).toBe('user-custom');
expect(byName['my-tool'].ownerId).toBe('alice');
expect(byName['my-tool'].custom).toBe(true);
// Bob's piece must not appear for Alice
expect(byName['bob-tool']).toBeUndefined();
});
it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.get('/api/pieces');
expect(res.status).toBe(200);
const general = res.body.pieces.find((p: any) => p.name === 'general');
expect(general.source).toBe('user-custom');
expect(general.description).toBe('alice override');
});
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.post('/api/pieces')
.send({
name: 'alice-custom',
description: 'alice piece',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(201);
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'alice-custom.yaml'))).toBe(true);
// Built-in dir untouched
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
});
it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
.post('/api/pieces')
.send({
name: 'admin-piece',
description: 'admin piece',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(201);
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(true);
});
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/general')
.send({
name: 'general',
description: 'should not write',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(403);
});
it('PUT /api/pieces/:name on own user-custom by owner returns 200', async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-piece.yaml'), makeMinimalPieceYaml('my-piece', 'v1'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/my-piece')
.send({
name: 'my-piece',
description: 'v2',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(200);
});
it("PUT /api/pieces/:name — non-admin cannot edit another user's piece", async () => {
// Bob has a piece. Alice tries to edit it via the same name.
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-only.yaml'), makeMinimalPieceYaml('bob-only', 'bob'));
// Alice doesn't have bob-only — the request 404s (she can't see it), proving isolation.
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.put('/api/pieces/bob-only')
.send({
name: 'bob-only',
description: 'hijacked',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(res.status).toBe(404);
});
it('DELETE /api/pieces/:name on built-in by non-admin returns 403', async () => {
// Use a deletable built-in (not general/chat which are protected separately)
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'extra'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.delete('/api/pieces/extra');
expect(res.status).toBe(403);
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
});
it('DELETE /api/pieces/:name on own user-custom by owner returns 200', async () => {
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'), makeMinimalPieceYaml('goner', 'gone'));
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
.delete('/api/pieces/goner');
expect(res.status).toBe(200);
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
});
it('admin can edit and delete built-in pieces', async () => {
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
name: 'admin-target',
description: 'after',
max_movements: 1,
initial_movement: 'only',
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
});
expect(putRes.status).toBe(200);
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
expect(delRes.status).toBe(200);
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
});
});
+356
View File
@@ -0,0 +1,356 @@
import { type Application, type Request, type Response } from 'express';
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { parse, stringify } from 'yaml';
import { patchYaml } from './yaml-patch.js';
import { detectDrift, type DriftStatus } from '../engine/reflection/drift-detect.js';
import { userPiecesDir } from '../user-folder/paths.js';
import { logger } from '../logger.js';
export type PieceSource = 'builtin' | 'global-custom' | 'user-custom';
interface PieceSummary {
name: string;
description: string;
triggers?: { keywords: string[] };
drift?: DriftStatus;
requiredMcp?: string[];
/** Backward-compat: true for any non-builtin (global-custom OR user-custom). */
custom: boolean;
source: PieceSource;
/** Set only when source === 'user-custom'. */
ownerId?: string;
}
function loadPieceFile(filePath: string): any {
const raw = readFileSync(filePath, 'utf-8');
return parse(raw);
}
function listPieceFiles(piecesDir: string): string[] {
return readdirSync(piecesDir)
.filter(f => f.endsWith('.yaml'))
.map(f => join(piecesDir, f));
}
// Phase 4 (SSH): movements using these tools must declare allowed_ssh_connections.
// Kept inline (not imported from engine/) so this API module stays decoupled
// from SSH internals — pieces can be validated even when SSH is disabled.
const SSH_TOOL_NAMES = new Set(['SshExec', 'SshUpload', 'SshDownload']);
const ALLOWED_SSH_ID = /^[a-f0-9-]{8,}$/;
function validatePiece(piece: any): string | null {
if (!piece.name || !/^[a-z0-9-]+$/.test(piece.name)) return 'name must be lowercase alphanumeric with hyphens';
if (!Array.isArray(piece.movements) || piece.movements.length === 0) return 'movements must be non-empty array';
// Required so `while (steps < piece.max_movements)` actually iterates;
// otherwise the run aborts with "Exceeded max movements (undefined)".
if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) {
return 'max_movements is required (positive integer)';
}
const names = new Set(piece.movements.map((m: any) => m.name));
if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement';
// Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS.
// Terminal moves (COMPLETE/ABORT/ASK) go through the `complete` tool now.
// default_next is engine-internal (context overflow / ASK limit / SpawnSubTask
// unavailable fallback) and still accepts COMPLETE/ABORT/ASK.
const validRuleNexts = new Set([...names, 'WAIT_SUBTASKS']);
const validDefaultNexts = new Set([...names, 'COMPLETE', 'ABORT', 'ASK', 'WAIT_SUBTASKS']);
for (const m of piece.movements) {
if (m.default_next && !validDefaultNexts.has(m.default_next)) {
return `movement "${m.name}": default_next "${m.default_next}" is invalid`;
}
if (Array.isArray(m.rules)) {
for (const r of m.rules) {
if (!validRuleNexts.has(r.next)) {
if (r.next === 'COMPLETE' || r.next === 'ABORT' || r.next === 'ASK') {
return `movement "${m.name}": rules[].next cannot be "${r.next}" (use the \`complete\` tool for terminal moves)`;
}
return `movement "${m.name}": rule next "${r.next}" is invalid`;
}
}
}
// Phase 4: allowed_ssh_connections consistency + format
const list = m.allowed_ssh_connections;
const tools = Array.isArray(m.allowed_tools) ? m.allowed_tools : [];
const hasSshTool = tools.some((t: unknown) => typeof t === 'string' && SSH_TOOL_NAMES.has(t));
if (list === undefined) {
if (hasSshTool) {
return `movement "${m.name}": allowed_ssh_connections is required when allowed_tools contains SSH tool(s)`;
}
} else if (!Array.isArray(list)) {
return `movement "${m.name}": allowed_ssh_connections must be an array`;
} else {
for (let i = 0; i < list.length; i++) {
const entry = list[i];
if (typeof entry !== 'string') {
return `movement "${m.name}": allowed_ssh_connections[${i}] must be a string`;
}
if (entry !== '*' && !ALLOWED_SSH_ID.test(entry)) {
return `movement "${m.name}": allowed_ssh_connections[${i}]="${entry}" must be '*' or a lowercase hex/hyphen id (8+ chars)`;
}
}
}
}
return null;
}
const VALID_PIECE_NAME = /^[a-z0-9-]+$/;
function validateName(name: string): boolean {
return VALID_PIECE_NAME.test(name);
}
export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
if (customPiecesDir) {
const customPath = join(customPiecesDir, `${name}.yaml`);
if (existsSync(customPath)) return { path: customPath, custom: true };
}
const builtinPath = join(piecesDir, `${name}.yaml`);
if (existsSync(builtinPath)) return { path: builtinPath, custom: false };
return null;
}
export interface PiecesApiOptions {
piecesDir: string;
/** Optional admin-managed shared custom dir (global to all users). */
customPiecesDir?: string;
/**
* Root of per-user data (typically `./data/users`). When set, each authenticated
* user can read/create/update/delete pieces under `{userPiecesRootDir}/{userId}/pieces/`.
* When unset, per-user piece support is disabled and non-admin POST returns 503.
*/
userPiecesRootDir?: string;
}
type AuthedUser = { id: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as any).user as AuthedUser | undefined;
}
function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
// No req.user → legacy (auth disabled or test/internal). Treat as admin so
// existing callers without auth middleware continue to work.
return !user || user.role === 'admin';
}
/**
* Lookup priority for a given caller:
* 1. Caller's own user-custom dir (overrides everything below).
* 2. Global custom dir (admin-managed, all users see).
* 3. Built-in dir.
*/
function findPieceForCaller(
opts: PiecesApiOptions,
user: AuthedUser | undefined,
name: string,
): { path: string; source: PieceSource; ownerId?: string } | null {
if (opts.userPiecesRootDir && user) {
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, user.id), `${name}.yaml`);
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId: user.id };
}
if (opts.customPiecesDir) {
const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
if (existsSync(gcPath)) return { path: gcPath, source: 'global-custom' };
}
const biPath = join(opts.piecesDir, `${name}.yaml`);
if (existsSync(biPath)) return { path: biPath, source: 'builtin' };
return null;
}
/**
* Mount the pieces REST API. Read endpoints (GET) require only authentication
* (any logged-in user can list/read pieces visible to them). Write endpoints
* (POST/PUT/DELETE) enforce per-piece authorization:
* - built-in / global-custom: admin only
* - user-custom: owner or admin
*/
export function mountPiecesApi(
app: Application,
optsOrPiecesDir: PiecesApiOptions | string,
legacyCustomPiecesDir?: string,
): void {
// Backwards-compatible signature: mountPiecesApi(app, piecesDir, customPiecesDir?)
const opts: PiecesApiOptions = typeof optsOrPiecesDir === 'string'
? { piecesDir: optsOrPiecesDir, customPiecesDir: legacyCustomPiecesDir }
: optsOrPiecesDir;
app.get('/api/pieces', (req: Request, res: Response) => {
try {
const user = getUser(req);
const seen = new Set<string>();
const pieces: PieceSummary[] = [];
// Order matters: user-custom overrides global-custom, which overrides built-in.
const sources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
if (opts.userPiecesRootDir && user) {
const ucDir = userPiecesDir(opts.userPiecesRootDir, user.id);
if (existsSync(ucDir)) sources.push({ dir: ucDir, source: 'user-custom', ownerId: user.id });
}
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
sources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
}
if (existsSync(opts.piecesDir)) {
sources.push({ dir: opts.piecesDir, source: 'builtin' });
}
for (const { dir, source, ownerId } of sources) {
for (const f of listPieceFiles(dir)) {
try {
const p = loadPieceFile(f);
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
if (seen.has(name)) continue;
seen.add(name);
// Drift is meaningful only for global-custom that shadows a built-in.
let drift: DriftStatus | undefined;
if (source === 'global-custom' && existsSync(opts.piecesDir)) {
const builtinPath = join(opts.piecesDir, `${name}.yaml`);
drift = detectDrift(f, builtinPath);
}
pieces.push({
name,
description: p.description,
triggers: p.triggers,
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
custom: source !== 'builtin',
source,
ownerId,
drift,
});
} catch {
// skip malformed piece files
}
}
}
res.json({ pieces });
} catch (e) {
res.status(500).json({ error: `Failed to list pieces: ${e}` });
}
});
app.get('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try {
const user = getUser(req);
const found = findPieceForCaller(opts, user, req.params.name);
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
const piece = loadPieceFile(found.path);
res.json({
piece: {
...piece,
requiredMcp: Array.isArray(piece.required_mcp) ? piece.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
},
custom: found.source !== 'builtin',
source: found.source,
ownerId: found.ownerId,
});
} catch (e) {
res.status(500).json({ error: `Failed to read piece: ${e}` });
}
});
app.put('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try {
const user = getUser(req);
const found = findPieceForCaller(opts, user, req.params.name);
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
// Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
if (found.source !== 'user-custom') {
if (!isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
return;
}
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
// Different user's user-custom — and not admin. Should be unreachable since
// findPieceForCaller scopes user-custom to the caller, but guard anyway.
res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
return;
}
const error = validatePiece(req.body);
if (error) { res.status(400).json({ ok: false, error }); return; }
if (req.body.name !== req.params.name) {
res.status(400).json({ ok: false, error: 'Body name must match URL parameter' }); return;
}
// Use parseDocument + setIn so untouched regions keep their original
// formatting (block styles, inline arrays, blank lines, comments).
// Full re-serialization via stringify would e.g. convert `instruction: |`
// to `instruction: >`, changing runtime prompt behavior. See #151.
const originalText = readFileSync(found.path, 'utf-8');
const patched = patchYaml(originalText, req.body);
writeFileSync(found.path, patched, 'utf-8');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to update piece: ${e}` });
}
});
app.post('/api/pieces', (req: Request, res: Response) => {
try {
const error = validatePiece(req.body);
if (error) { res.status(400).json({ ok: false, error }); return; }
const user = getUser(req);
const adminOrLegacy = isAdminOrLegacy(user);
// Determine destination dir:
// - admin / legacy → preserve existing behavior (write to piecesDir).
// - non-admin user → write to their user-custom dir.
let destDir: string;
if (adminOrLegacy) {
destDir = opts.piecesDir;
} else {
if (!opts.userPiecesRootDir) {
res.status(503).json({ ok: false, error: 'User pieces directory not configured on this server' });
return;
}
destDir = userPiecesDir(opts.userPiecesRootDir, user!.id);
mkdirSync(destDir, { recursive: true });
}
// Reject if any visible-to-caller piece with this name already exists
// (built-in, global-custom, or caller's user-custom).
if (findPieceForCaller(opts, user, req.body.name)) {
res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
}
const filePath = join(destDir, `${req.body.name}.yaml`);
writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} actor=${user?.id ?? 'legacy'}`);
res.status(201).json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to create piece: ${e}` });
}
});
app.delete('/api/pieces/:name', (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
if (req.params.name === 'general' || req.params.name === 'chat') {
res.status(403).json({ ok: false, error: 'Cannot delete general piece' }); return;
}
try {
const user = getUser(req);
const found = findPieceForCaller(opts, user, req.params.name);
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
// Authz mirrors PUT: built-in / global-custom → admin; user-custom → owner.
if (found.source !== 'user-custom') {
if (!isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: 'Only admins can delete built-in or global-custom pieces' });
return;
}
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
return;
}
unlinkSync(found.path);
logger.info(`[pieces-api] deleted piece=${req.params.name} source=${found.source} actor=${user?.id ?? 'legacy'}`);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to delete piece: ${e}` });
}
});
}
+414
View File
@@ -0,0 +1,414 @@
/**
* reflection-api.test.ts
*
* Unit tests for the reflection history REST API.
* Uses an in-memory SQLite repository + temp filesystem for snapshots.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { writeSnapshot, type WriteSnapshotMeta } from '../engine/reflection/snapshot.js';
import { createReflectionApi } from './reflection-api.js';
// ── Fixtures ──────────────────────────────────────────────────────────────────
const OWNER_ID = 'u-reflect-owner';
const OTHER_ID = 'u-reflect-other';
function makeMeta(overrides: Partial<WriteSnapshotMeta> = {}): WriteSnapshotMeta {
return {
originalJobId: 'j-test-001',
userId: OWNER_ID,
pieceName: 'chat',
outcome: 'applied',
reasoning: 'User prefers concise answers.',
modelUsed: 'qwen2.5:3b',
tokensIn: 800,
tokensOut: 60,
ratingAtTime: null,
memoryChanges: 1,
pieceEdited: false,
...overrides,
};
}
// Create an Express app wired up with the reflection API, with auth disabled
// (auth gate injects synthetic user from X-Test-User-Id header via a middleware).
function buildApp(dataDir: string, repo: Repository, userId: string = OWNER_ID) {
const app = express();
app.use(express.json());
// Inject user from query param for tests (simulates requireAuth)
app.use((req, _res, next) => {
const testUserId = req.headers['x-test-user-id'] as string | undefined;
if (testUserId) {
(req as any).user = { id: testUserId, role: testUserId === 'admin' ? 'admin' : 'user' };
}
next();
});
app.use(
'/api/local/reflection',
createReflectionApi({ dataDir, repo, authActive: false }),
);
return app;
}
// Helper to set the user for a request
function asUser(agent: request.SuperTest<request.Test>, userId: string) {
return { userId };
}
// ── Test suite ────────────────────────────────────────────────────────────────
describe('reflection-api', () => {
let tmpDir: string;
let repo: Repository;
let dbPath: string;
let app: express.Application;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'reflect-api-'));
dbPath = join(tmpDir, 'test.db');
repo = new Repository(dbPath);
app = buildApp(tmpDir, repo);
});
afterEach(() => {
repo.close();
rmSync(tmpDir, { recursive: true, force: true });
});
// ── GET /history — paged listing ──────────────────────────────────────────
describe('GET /history', () => {
it('returns empty list when no snapshots exist', async () => {
const res = await request(app)
.get('/api/local/reflection/history')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.items).toEqual([]);
expect(res.body.nextCursor).toBeNull();
});
it('returns items most-recent first with correct paging', async () => {
const deps = { dataDir: tmpDir };
const dateA = new Date('2026-05-10T08:00:00Z');
const dateB = new Date('2026-05-11T09:00:00Z');
const dateC = new Date('2026-05-12T10:00:00Z');
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-a' }), undefined, undefined, dateA);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-b' }), undefined, undefined, dateB);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-c' }), undefined, undefined, dateC);
const res = await request(app)
.get('/api/local/reflection/history?limit=2')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
const { items, nextCursor } = res.body;
// Most recent first
expect(items).toHaveLength(2);
expect(items[0].jobId).toBe('j-c');
expect(items[1].jobId).toBe('j-b');
// nextCursor should be the ts of the last returned item
expect(nextCursor).toBe(items[1].ts);
});
it('pages correctly with before cursor', async () => {
const deps = { dataDir: tmpDir };
const dateA = new Date('2026-05-10T08:00:00Z');
const dateB = new Date('2026-05-11T09:00:00Z');
const dateC = new Date('2026-05-12T10:00:00Z');
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-a' }), undefined, undefined, dateA);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-b' }), undefined, undefined, dateB);
await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-c' }), undefined, undefined, dateC);
// First page: limit=2 gives j-c, j-b; cursor = j-b.ts
const page1 = await request(app)
.get('/api/local/reflection/history?limit=2')
.set('x-test-user-id', OWNER_ID);
const cursor = page1.body.nextCursor;
// Second page: before=cursor should give j-a
const page2 = await request(app)
.get(`/api/local/reflection/history?limit=2&before=${encodeURIComponent(cursor)}`)
.set('x-test-user-id', OWNER_ID);
expect(page2.status).toBe(200);
expect(page2.body.items).toHaveLength(1);
expect(page2.body.items[0].jobId).toBe('j-a');
expect(page2.body.nextCursor).toBeNull(); // last page
});
it('returns 401 when unauthenticated (auth active)', async () => {
// Build an app with authActive=true (no synthetic user injection)
const authApp = express();
authApp.use(express.json());
authApp.use('/api/local/reflection', createReflectionApi({ dataDir: tmpDir, repo, authActive: true }));
const res = await request(authApp).get('/api/local/reflection/history');
expect(res.status).toBe(401);
});
});
// ── GET /history/:snapshotId — detail ─────────────────────────────────────
describe('GET /history/:snapshotId', () => {
it('returns full detail for owner', async () => {
const deps = { dataDir: tmpDir };
const before = { 'pref-terse.md': 'old content' };
const after = { 'pref-terse.md': 'new content' };
const fixedDate = new Date('2026-05-11T12:00:00Z');
const { snapshotId } = await writeSnapshot(deps, before, after, makeMeta(), undefined, undefined, fixedDate);
const res = await request(app)
.get(`/api/local/reflection/history/${snapshotId}`)
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.snapshotId).toBe(snapshotId);
expect(res.body.outcome).toBe('applied');
expect(res.body.beforeFiles).toHaveProperty('pref-terse.md', 'old content');
expect(res.body.afterFiles).toHaveProperty('pref-terse.md', 'new content');
expect(typeof res.body.diff).toBe('string');
});
it('returns 404 for non-owner (no existence leak)', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
const res = await request(app)
.get(`/api/local/reflection/history/${snapshotId}`)
.set('x-test-user-id', OTHER_ID);
expect(res.status).toBe(404);
});
it('returns 404 for non-existent snapshot', async () => {
const res = await request(app)
.get('/api/local/reflection/history/99999999T000000Z-ghost')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(404);
});
});
// ── POST /history/:snapshotId/revert — idempotent revert ──────────────────
describe('POST /history/:snapshotId/revert', () => {
it('reverts successfully on first call', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
const res = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.reverted).toBe(true);
});
it('is idempotent: second call returns { reverted: false }', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OWNER_ID);
const res2 = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OWNER_ID);
expect(res2.status).toBe(200);
expect(res2.body.reverted).toBe(false);
});
it('returns 404 for non-owner', async () => {
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());
const res = await request(app)
.post(`/api/local/reflection/history/${snapshotId}/revert`)
.set('x-test-user-id', OTHER_ID);
expect(res.status).toBe(404);
});
});
// ── GET /metrics — graceful when table absent ─────────────────────────────
describe('GET /metrics', () => {
it('returns zero counts when reflection_metrics table does not exist', async () => {
const res = await request(app)
.get('/api/local/reflection/metrics')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
applied: 0,
partial: 0,
abstained: 0,
rejected: 0,
failed: 0,
tokensIn: 0,
tokensOut: 0,
pieceEdits: 0,
});
});
it('returns zero counts for a user with no rows (table exists)', async () => {
// Create the table manually
repo.getDb().exec(`
CREATE TABLE IF NOT EXISTS reflection_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
outcome TEXT NOT NULL,
piece_edited INTEGER NOT NULL DEFAULT 0,
tokens_in INTEGER,
tokens_out INTEGER,
created_at TEXT NOT NULL
)
`);
const res = await request(app)
.get('/api/local/reflection/metrics')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.applied).toBe(0);
expect(res.body.tokensIn).toBe(0);
});
it('aggregates correctly when rows exist', async () => {
// Phase 8.1's reflection_metrics table is auto-created by Repository
// init (NOT NULL on reflection_job_id, INTEGER created_at). Insert
// through that real schema.
const now = Date.now();
const ins = repo.getDb().prepare(`
INSERT INTO reflection_metrics
(reflection_job_id, original_job_id, user_id, piece_name, outcome,
memory_changes, piece_edited, tokens_in, tokens_out, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
ins.run('r-1', 'j-1', OWNER_ID, 'chat', 'applied', 2, 1, 500, 40, 100, now);
ins.run('r-2', 'j-2', OWNER_ID, 'chat', 'rejected', 0, 0, 300, 20, 80, now);
// Other user's row — should NOT appear
ins.run('r-3', 'j-3', OTHER_ID, 'chat', 'applied', 1, 0, 999, 99, 50, now);
const res = await request(app)
.get('/api/local/reflection/metrics')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body.applied).toBe(1);
expect(res.body.rejected).toBe(1);
expect(res.body.pieceEdits).toBe(1);
expect(res.body.tokensIn).toBe(800);
expect(res.body.tokensOut).toBe(60);
// Other user's rows excluded
expect(res.body.tokensIn).not.toBe(1799);
});
});
// ── GET /latest-for-task/:taskId — ReflectionBadge feed ──────────────────
describe('GET /latest-for-task/:taskId', () => {
it('returns null when no snapshot for the task', async () => {
const res = await request(app)
.get('/api/local/reflection/latest-for-task/42')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
it('returns null for invalid taskId', async () => {
const res = await request(app)
.get('/api/local/reflection/latest-for-task/notanumber')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(400);
});
it('returns snapshot summary when a matching job+snapshot exists', async () => {
// Insert a job row with repo='local/task-7' owned by OWNER_ID
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-task7-001', 'local/task-7', 1, 'succeeded', 'chat', 'default', 'auto',
'Test task', 1, 1, 0, 0, 'agent', now, now, OWNER_ID);
// Write a snapshot referencing that job
const deps = { dataDir: tmpDir };
const { snapshotId } = await writeSnapshot(
deps,
{ 'pref.md': 'before' },
{ 'pref.md': 'after' },
makeMeta({ originalJobId: 'j-task7-001', memoryChanges: 3, pieceEdited: true }),
undefined,
undefined,
new Date('2026-05-11T15:00:00Z'),
);
const res = await request(app)
.get('/api/local/reflection/latest-for-task/7')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).not.toBeNull();
expect(res.body.snapshotId).toBe(snapshotId);
expect(res.body.outcome).toBe('applied');
expect(res.body.memoryChanges).toBe(3);
expect(res.body.pieceEdited).toBe(true);
});
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();
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-task8-001', 'local/task-8', 1, 'succeeded', 'chat', 'default', 'auto',
'Other task', 1, 1, 0, 0, 'agent', now, now, OTHER_ID);
// Write a snapshot for OTHER_ID
const deps = { dataDir: tmpDir };
await writeSnapshot(
deps,
{},
{},
makeMeta({ originalJobId: 'j-task8-001', userId: OTHER_ID }),
undefined,
undefined,
new Date(),
);
// OWNER_ID should not see OTHER_ID's task
const res = await request(app)
.get('/api/local/reflection/latest-for-task/8')
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
});
});
+315
View File
@@ -0,0 +1,315 @@
/**
* reflection-api.ts — REST router for reflection history + metrics
*
* Mounted at /api/local/reflection
*
* Routes:
* GET /history — paged index listing (limit, before cursor)
* GET /history/:snapshotId — full detail (meta + before/after files + diff)
* POST /history/:snapshotId/revert — idempotent revert
* GET /metrics — outcome counts + token sums (?days=30)
* GET /latest-for-task/:taskId — feeds the ReflectionBadge on OverviewTab
*
* Auth: all routes require an authenticated user (req.user).
* Owner: every operation is scoped to req.user.id — 404 on mismatch (no existence leak).
*/
import { Router, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import {
listSnapshots,
readSnapshot,
revertSnapshotForUser,
type SnapshotDeps,
type SnapshotIndexEntry,
type SnapshotDetail,
} from '../engine/reflection/snapshot.js';
import type { Repository } from '../db/repository.js';
// ── Types ──────────────────────────────────────────────────────────────────────
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
// ── Deps ───────────────────────────────────────────────────────────────────────
export interface ReflectionApiDeps {
/** Root data dir (same as userFolderRoot in the rest of the app). */
dataDir: string;
/** Repository for job lookups (latest-for-task, metrics). */
repo: Repository;
/** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */
authActive?: boolean;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeDeps(dataDir: string): SnapshotDeps {
return { dataDir };
}
// ── Factory ────────────────────────────────────────────────────────────────────
export function createReflectionApi(deps: ReflectionApiDeps): Router {
const { dataDir, repo } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
// ── Auth gate ──────────────────────────────────────────────────────────────
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// ── GET /history ───────────────────────────────────────────────────────────
// Returns paged list of snapshot index entries (most recent first).
// Query params:
// limit — max items to return (default 50, max 200)
// before — ISO timestamp cursor (exclusive, for pagination)
r.get('/history', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawLimit = parseInt(String(req.query.limit ?? '50'), 10);
const limit = isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 200);
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
try {
const items = listSnapshots(makeDeps(dataDir), u.id, { limit, before });
// Compute nextCursor from the last item's ts (if we got a full page)
const nextCursor: string | null =
items.length === limit ? (items[items.length - 1]?.ts ?? null) : null;
res.json({ items, nextCursor });
} catch (err) {
logger.error(`[reflection-api] GET /history failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list reflection history' });
}
});
// ── GET /history/:snapshotId ───────────────────────────────────────────────
// Returns full snapshot detail for the owner. 404 for non-owner or missing.
r.get('/history/:snapshotId', (req: Request, res: Response) => {
const u = getUser(req)!;
const { snapshotId } = req.params;
try {
const detail: SnapshotDetail | null = readSnapshot(makeDeps(dataDir), u.id, snapshotId);
if (!detail) {
// Either doesn't exist or belongs to another user — always 404
res.status(404).json({ error: 'not_found' });
return;
}
// Owner check: the meta.json embeds userId
if (detail.userId !== u.id) {
res.status(404).json({ error: 'not_found' });
return;
}
res.json(detail);
} catch (err) {
logger.error(`[reflection-api] GET /history/${snapshotId} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to read snapshot' });
}
});
// ── POST /history/:snapshotId/revert ──────────────────────────────────────
// Idempotent revert. Returns { reverted: true } on first call, { reverted: false } thereafter.
r.post('/history/:snapshotId/revert', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { snapshotId } = req.params;
// Owner check: read meta first (cheaper than a full revert attempt that fails)
try {
const detail = readSnapshot(makeDeps(dataDir), u.id, snapshotId);
if (!detail || detail.userId !== u.id) {
res.status(404).json({ error: 'not_found' });
return;
}
} catch (err) {
logger.error(`[reflection-api] POST /revert owner-check failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to verify snapshot ownership' });
return;
}
try {
const result = await revertSnapshotForUser(makeDeps(dataDir), u.id, snapshotId);
logger.info(`[reflection-api] POST /revert snapshotId=${snapshotId} user=${u.id} reverted=${result.reverted}`);
res.json(result);
} catch (err) {
logger.error(`[reflection-api] POST /revert failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to revert snapshot' });
}
});
// ── GET /metrics ───────────────────────────────────────────────────────────
// Returns aggregated outcome counts + token sums for the caller.
// Query params:
// days — look-back window in days (default 30)
//
// Gracefully returns zero counts when the reflection_metrics table doesn't
// exist yet (Phase 8.1 creates it and starts inserting rows).
r.get('/metrics', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawDays = parseInt(String(req.query.days ?? '30'), 10);
const days = isNaN(rawDays) || rawDays < 1 ? 30 : Math.min(rawDays, 365);
const zeroMetrics = {
applied: 0,
partial: 0,
abstained: 0,
rejected: 0,
failed: 0,
tokensIn: 0,
tokensOut: 0,
pieceEdits: 0,
};
try {
const db = repo.getDb();
// Check if the table exists before querying (Phase 8.1 creates it)
const tableExists = (db.prepare(`PRAGMA table_info('reflection_metrics')`).all() as Array<{ name: string }>).length > 0;
if (!tableExists) {
res.json(zeroMetrics);
return;
}
// Phase 8.1's reflection_metrics.created_at is INTEGER ms-since-epoch.
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
interface MetricsRow {
outcome: string;
piece_edited: number;
tokens_in: number | null;
tokens_out: number | null;
}
const rows = db
.prepare(
`SELECT outcome, piece_edited, tokens_in, tokens_out
FROM reflection_metrics
WHERE user_id = ? AND created_at >= ?`,
)
.all(u.id, cutoff) as MetricsRow[];
const metrics = { ...zeroMetrics };
for (const row of rows) {
switch (row.outcome) {
case 'applied': metrics.applied++; break;
case 'partial': metrics.partial++; break;
case 'abstained': metrics.abstained++; break;
case 'rejected': metrics.rejected++; break;
case 'failed': metrics.failed++; break;
}
metrics.tokensIn += row.tokens_in ?? 0;
metrics.tokensOut += row.tokens_out ?? 0;
if (row.piece_edited) metrics.pieceEdits++;
}
res.json(metrics);
} catch (err) {
// If the table doesn't exist yet (race window between check and query), return zeros
const msg = String(err);
if (msg.includes('no such table')) {
res.json(zeroMetrics);
return;
}
logger.error(`[reflection-api] GET /metrics failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to load reflection metrics' });
}
});
// ── GET /latest-for-task/:taskId ──────────────────────────────────────────
// Returns the most recent snapshot triggered by the given local task's job,
// or null if none found. Used by the ReflectionBadge on OverviewTab (Phase 7.5).
//
// Owner check: the job must belong to the caller (owner_id match), or the
// user must be an admin. Returns null (not 404) when there's no snapshot —
// the badge simply stays hidden.
r.get('/latest-for-task/:taskId', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawTaskId = parseInt(req.params.taskId, 10);
if (isNaN(rawTaskId)) {
res.status(400).json({ error: 'invalid_task_id' });
return;
}
try {
const db = repo.getDb();
// Find all jobs for this local task, owned by the caller (or any if admin)
const repoName = `local/task-${rawTaskId}`;
interface JobRow {
id: string;
owner_id: string | null;
}
let rows: JobRow[];
if (u.role === 'admin') {
rows = db
.prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`)
.all(repoName) as JobRow[];
} else {
rows = db
.prepare(
`SELECT id, owner_id FROM jobs WHERE repo = ? AND owner_id = ? ORDER BY created_at DESC`,
)
.all(repoName, u.id) as JobRow[];
}
if (rows.length === 0) {
res.json(null);
return;
}
// Collect all job IDs for this task
const jobIds = new Set(rows.map((r) => r.id));
// List all snapshots for this user (no limit — we need to scan for a match)
const allSnapshots = listSnapshots(makeDeps(dataDir), u.id, { limit: 200 });
// Find the most recent snapshot whose originalJobId is in our job set
const match = allSnapshots.find((s: SnapshotIndexEntry) => jobIds.has(s.jobId));
if (!match) {
res.json(null);
return;
}
// Load the full detail so the badge can show outcome + counts
const detail = readSnapshot(makeDeps(dataDir), u.id, match.snapshotId);
if (!detail) {
res.json(null);
return;
}
res.json({
snapshotId: detail.snapshotId,
outcome: detail.outcome,
memoryChanges: detail.memoryChanges,
pieceEdited: detail.pieceEdited,
});
} catch (err) {
logger.error(`[reflection-api] GET /latest-for-task/${rawTaskId} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to load latest reflection for task' });
}
});
return r;
}
+534
View File
@@ -0,0 +1,534 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { Scheduler } from '../scheduler.js';
import { mountScheduledTasksApi } from './scheduled-tasks-api.js';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let app: express.Application;
let repo: Repository;
let scheduler: Scheduler;
let tempDir: string;
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-api-'));
repo = new Repository(join(tempDir, 'test.db'));
scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
app = express();
app.use(express.json());
mountScheduledTasksApi(app, repo, scheduler);
});
afterAll(() => {
repo.close();
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
});
describe('POST /api/scheduled-tasks with visibility', () => {
let vTempDir = '';
let vRepo: Repository;
let vApp: express.Application;
let aliceUser: Express.User;
beforeEach(() => {
vTempDir = mkdtempSync(join(tmpdir(), 'sched-vis-api-'));
vRepo = new Repository(join(vTempDir, 'db.sqlite'));
const real = vRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
aliceUser = {
...real,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const vScheduler = new Scheduler(vRepo, join(vTempDir, 'workspaces'));
vApp = express();
vApp.use(express.json());
vApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = aliceUser;
next();
});
mountScheduledTasksApi(vApp, vRepo, vScheduler);
});
afterEach(() => {
vRepo.close();
rmSync(vTempDir, { recursive: true, force: true });
});
it('creates scheduled task with owner_id set from req.user and visibility=org', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
minute: 0,
visibility: 'org',
visibilityScopeOrgId: '10',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('org');
expect(res.body.task.visibilityScopeOrgId).toBe('10');
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('defaults visibility to private and owner from req.user when not provided', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 10,
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('private');
expect(res.body.task.visibilityScopeOrgId).toBeNull();
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('rejects visibility=org with org not in user orgs', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
visibility: 'org',
visibilityScopeOrgId: '99',
});
expect(res.status).toBe(400);
});
it('rejects invalid visibility enum values', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
visibility: 'bogus',
});
expect(res.status).toBe(400);
});
});
describe('POST /api/scheduled-tasks', () => {
it('should create a daily schedule', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.send({
title: 'テスト日次',
body: 'テストプロンプト',
scheduleType: 'daily',
hour: 9,
minute: 0,
});
expect(res.status).toBe(201);
expect(res.body.task.cronExpression).toBe('0 9 * * *');
expect(res.body.task.isActive).toBe(true);
});
it('should require body', async () => {
const res = await request(app).post('/api/scheduled-tasks').send({ scheduleType: 'daily' });
expect(res.status).toBe(400);
});
});
describe('GET /api/scheduled-tasks', () => {
it('should list all scheduled tasks', async () => {
const res = await request(app).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tasks)).toBe(true);
});
});
describe('PATCH /api/scheduled-tasks/:id', () => {
it('should pause and resume', async () => {
const createRes = await request(app)
.post('/api/scheduled-tasks')
.send({ title: 'pause-test', body: 'test', scheduleType: 'daily', hour: 10 });
const id = createRes.body.task.id;
const pauseRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: false });
expect(pauseRes.body.task.isActive).toBe(false);
const resumeRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: true });
expect(resumeRes.body.task.isActive).toBe(true);
});
});
describe('DELETE /api/scheduled-tasks/:id', () => {
it('should delete a scheduled task', async () => {
const createRes = await request(app)
.post('/api/scheduled-tasks')
.send({ title: 'delete-test', body: 'test', scheduleType: 'daily', hour: 10 });
const id = createRes.body.task.id;
const delRes = await request(app).delete(`/api/scheduled-tasks/${id}`);
expect(delRes.status).toBe(200);
const getRes = await request(app).get(`/api/scheduled-tasks/${id}`);
expect(getRes.status).toBe(404);
});
});
describe('PATCH/DELETE /api/scheduled-tasks/:id owner-or-admin', () => {
let pTempDir = '';
let pRepo: Repository;
afterEach(() => {
pRepo.close();
rmSync(pTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const pScheduler = new Scheduler(pRepo, join(pTempDir, 'workspaces'));
const pApp = express();
pApp.use(express.json());
pApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(pApp, pRepo, pScheduler);
return pApp;
}
function seedTask(ownerId: string, visibility: 'private' | 'org' | 'public' = 'public') {
return pRepo.createScheduledTask({
title: 't',
body: 'b',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId,
visibility,
});
}
it('non-owner non-admin gets 404 on PATCH (even when visibility=public)', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'public');
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(bobUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited' });
expect(res.status).toBe(404);
// Task title not changed
const after = await pRepo.getScheduledTask(task.id);
expect(after?.title).toBe('t');
});
it('non-owner non-admin gets 404 on DELETE', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'public');
const bobUser: Express.User = {
id: 'bob-id',
email: '[email protected]',
name: 'b',
avatarUrl: null,
role: 'user',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(bobUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(404);
const after = await pRepo.getScheduledTask(task.id);
expect(after).not.toBeNull();
});
it('admin can PATCH any scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'private');
const adminUser: Express.User = {
id: 'admin-id',
email: '[email protected]',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(adminUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited-by-admin' });
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('edited-by-admin');
});
it('admin can DELETE any scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'private');
const adminUser: Express.User = {
id: 'admin-id',
email: '[email protected]',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(adminUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const after = await pRepo.getScheduledTask(task.id);
expect(after).toBeNull();
});
it('owner can PATCH own scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await seedTask(alice.id, 'private');
const pApp = buildAppForUser(aliceUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited-by-owner' });
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('edited-by-owner');
});
it('owner can DELETE own scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await seedTask(alice.id, 'private');
const pApp = buildAppForUser(aliceUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
describe('GET /api/scheduled-tasks visibility filter', () => {
let lTempDir = '';
let lRepo: Repository;
afterEach(() => {
lRepo.close();
rmSync(lTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const lScheduler = new Scheduler(lRepo, join(lTempDir, 'workspaces'));
const lApp = express();
lApp.use(express.json());
lApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(lApp, lRepo, lScheduler);
return lApp;
}
it('non-owner does not see private scheduled tasks in list', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const bobUser: Express.User = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(bobUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).not.toContain('alice-private');
});
it('owner sees own private scheduled tasks', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const res = await request(buildAppForUser(aliceUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
});
it('admin sees all scheduled tasks regardless of visibility', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const adminUser: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(adminUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
});
});
describe('POST /api/scheduled-tasks browserSessionProfileId owner check', () => {
let bTempDir = '';
let bRepo: Repository;
let bSessRepo: BrowserSessionRepo;
let alice: { id: string };
let bob: { id: string };
let aliceProfileId: number;
let bobProfileId: number;
beforeEach(() => {
bTempDir = mkdtempSync(join(tmpdir(), 'sched-bsp-'));
bRepo = new Repository(join(bTempDir, 'db.sqlite'));
bSessRepo = new BrowserSessionRepo(bRepo.getDb());
alice = bRepo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
bob = bRepo.createUser({ email: '[email protected]', name: 'b', role: 'user', status: 'active' });
aliceProfileId = bSessRepo.createProfile({
ownerId: alice.id,
label: 'alice-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
bobProfileId = bSessRepo.createProfile({
ownerId: bob.id,
label: 'bob-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
});
afterEach(() => {
bRepo.close();
rmSync(bTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const bScheduler = new Scheduler(bRepo, join(bTempDir, 'workspaces'));
const bApp = express();
bApp.use(express.json());
bApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(bApp, bRepo, bScheduler, { sessRepo: bSessRepo });
return bApp;
}
function asUser(u: { id: string }, email: string): Express.User {
return {
id: u.id, email, name: 'x', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
it('accepts a valid profile owned by the requesting user (201)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: aliceProfileId,
});
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('rejects a profile owned by a different user (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: bobProfileId,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not owned by you|not found/i);
});
it('rejects a positive integer that does not match any profile (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, '[email protected]')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: 999999,
});
expect(res.status).toBe(400);
});
});
+345
View File
@@ -0,0 +1,345 @@
import { type Application, type Request, type Response } from 'express';
import { type Repository } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { convertToCron, calcNextRun, toSqliteDatetime } from '../scheduler.js';
import { type Scheduler } from '../scheduler.js';
export interface ScheduledTasksApiOptions {
/**
* Optional. When set, accepting browserSessionProfileId on create / update
* verifies the profile belongs to the requesting user. Without it, the
* field is silently dropped (legacy / no-auth deployments).
*/
sessRepo?: BrowserSessionRepo;
}
export function mountScheduledTasksApi(
app: Application,
repo: Repository,
scheduler: Scheduler,
apiOpts: ScheduledTasksApiOptions = {},
): void {
const { sessRepo } = apiOpts;
/**
* Validate and resolve a browserSessionProfileId from a request body.
* Returns:
* - { ok: true, value: number | null } when accepted (null = unset / clear).
* - { ok: false, error } when validation fails (caller sends 400).
* Pass an undefined raw to skip validation entirely (PATCH "field absent" case).
*/
function resolveBrowserSessionProfileId(
raw: unknown,
user: Express.User | undefined,
): { ok: true; value: number | null } | { ok: false; error: string } {
if (raw === undefined) return { ok: true, value: null };
if (raw === null || raw === '') return { ok: true, value: null };
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
return { ok: false, error: 'browserSessionProfileId must be a positive integer' };
}
if (sessRepo) {
if (!user?.id) {
return { ok: false, error: 'browserSessionProfileId requires an authenticated user' };
}
const owned = sessRepo.getProfileById(n, user.id);
if (!owned) {
return { ok: false, error: 'browser session profile not found or not owned by you' };
}
}
return { ok: true, value: n };
}
// 一覧取得
app.get('/api/scheduled-tasks', async (req: Request, res: Response) => {
try {
const viewer = req.user as Express.User | undefined;
const tasks = await repo.listScheduledTasks(viewer ? { viewer } : undefined);
res.json({ tasks });
} catch (err) {
res.status(500).json({ error: `Failed to list scheduled tasks: ${err}` });
}
});
// 詳細取得
app.get('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const task = await repo.getScheduledTask(id, { viewer });
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
res.json({ task });
} catch (err) {
res.status(500).json({ error: `Failed to get scheduled task: ${err}` });
}
});
// 新規作成
app.post('/api/scheduled-tasks', async (req: Request, res: Response) => {
try {
const { title, body, piece, profile, outputFormat, scheduleType, hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron, scheduledAt } = req.body;
// task_kind: 'agent' (default) or 'script'
const rawTaskKind = req.body?.taskKind;
const taskKind: 'agent' | 'script' = rawTaskKind === 'script' ? 'script' : 'agent';
let scriptName: string | null = null;
let scriptParams: string | null = null;
if (taskKind === 'script') {
const rawScriptName = req.body?.scriptName;
if (typeof rawScriptName !== 'string' || !rawScriptName.trim()) {
res.status(400).json({ error: 'scriptName is required when taskKind=script' });
return;
}
scriptName = rawScriptName.trim();
const rawScriptParams = req.body?.scriptParams;
if (rawScriptParams !== undefined && rawScriptParams !== null) {
if (typeof rawScriptParams === 'string') {
try {
const parsed = JSON.parse(rawScriptParams);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('scriptParams must be a JSON object');
}
scriptParams = JSON.stringify(parsed);
} catch (err) {
res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
return;
}
} else if (typeof rawScriptParams === 'object' && !Array.isArray(rawScriptParams)) {
scriptParams = JSON.stringify(rawScriptParams);
} else {
res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
return;
}
}
}
if (taskKind === 'agent' && !body) { res.status(400).json({ error: 'body is required' }); return; }
if (!scheduleType) { res.status(400).json({ error: 'scheduleType is required' }); return; }
// Visibility extraction + validation (mirrors POST /api/local/tasks)
const rawVisibility = req.body?.visibility ?? 'private';
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const visibility = rawVisibility as 'private' | 'org' | 'public';
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (visibility === 'org') {
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
const ownerId = (req.user as Express.User | undefined)?.id ?? null;
const profileBinding = resolveBrowserSessionProfileId(
req.body?.browserSessionProfileId,
req.user as Express.User | undefined,
);
if (!profileBinding.ok) {
res.status(400).json({ error: profileBinding.error });
return;
}
const cronExpr = convertToCron(scheduleType, { hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron });
let nextRunAt: string;
if (scheduleType === 'once') {
if (!scheduledAt) { res.status(400).json({ error: 'scheduledAt is required for once type' }); return; }
nextRunAt = toSqliteDatetime(new Date(scheduledAt));
} else {
const next = calcNextRun(cronExpr);
if (!next) { res.status(400).json({ error: 'Failed to calculate next run time' }); return; }
nextRunAt = next;
}
const task = await repo.createScheduledTask({
title: title || null,
body: taskKind === 'script' ? (body ?? '') : body,
pieceName: piece ?? 'auto',
profile: profile ?? 'auto',
outputFormat: outputFormat ?? 'markdown',
cronExpression: cronExpr,
nextRunAt,
ownerId,
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId: profileBinding.value,
taskKind,
scriptName,
scriptParams,
});
res.status(201).json({ task });
} catch (err) {
res.status(400).json({ error: `Failed to create scheduled task: ${err}` });
}
});
// 編集
app.patch('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
res.status(404).json({ error: 'Not found' });
return;
}
const updates: Record<string, any> = {};
if (req.body.title !== undefined) updates.title = req.body.title;
if (req.body.body !== undefined) updates.body = req.body.body;
if (req.body.piece !== undefined) updates.pieceName = req.body.piece;
if (req.body.profile !== undefined) updates.profile = req.body.profile;
if (req.body.outputFormat !== undefined) updates.outputFormat = req.body.outputFormat;
// スケジュール変更
if (req.body.scheduleType) {
const cronExpr = convertToCron(req.body.scheduleType, {
hour: req.body.hour,
minute: req.body.minute,
dayOfWeek: req.body.dayOfWeek,
dayOfMonth: req.body.dayOfMonth,
cronExpression: req.body.cronExpression,
});
updates.cronExpression = cronExpr;
if (req.body.scheduleType === 'once' && req.body.scheduledAt) {
updates.nextRunAt = toSqliteDatetime(new Date(req.body.scheduledAt));
} else {
const next = calcNextRun(cronExpr);
if (next) updates.nextRunAt = next;
}
}
// 一時停止/再開
if (req.body.isActive !== undefined) {
updates.isActive = req.body.isActive;
// 再開時は next_run_at を再計算
if (req.body.isActive && !updates.cronExpression) {
const next = calcNextRun(existing.cronExpression);
if (next) updates.nextRunAt = next;
}
}
// Visibility 変更 (POST と同じバリデーション)
if (req.body.visibility !== undefined) {
const rawVisibility = req.body.visibility;
if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
const rawScopeOrgId = req.body.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (rawVisibility === 'org') {
const orgIds = viewer?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
}
updates.visibility = rawVisibility;
updates.visibilityScopeOrgId = rawVisibility === 'org' ? visibilityScopeOrgId : null;
}
// browserSessionProfileId 変更 (owner check)
if (req.body.browserSessionProfileId !== undefined) {
const binding = resolveBrowserSessionProfileId(req.body.browserSessionProfileId, viewer);
if (!binding.ok) {
res.status(400).json({ error: binding.error });
return;
}
updates.browserSessionProfileId = binding.value;
}
// taskKind / scriptName / scriptParams (PATCH 用)
if (req.body.taskKind !== undefined) {
if (req.body.taskKind !== 'agent' && req.body.taskKind !== 'script') {
res.status(400).json({ error: "taskKind must be 'agent' or 'script'" });
return;
}
updates.taskKind = req.body.taskKind;
}
if (req.body.scriptName !== undefined) {
if (req.body.scriptName === null || req.body.scriptName === '') {
updates.scriptName = null;
} else if (typeof req.body.scriptName === 'string') {
updates.scriptName = req.body.scriptName.trim();
} else {
res.status(400).json({ error: 'scriptName must be a string' });
return;
}
}
if (req.body.scriptParams !== undefined) {
if (req.body.scriptParams === null) {
updates.scriptParams = null;
} else if (typeof req.body.scriptParams === 'string') {
try {
const parsed = JSON.parse(req.body.scriptParams);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('scriptParams must be a JSON object');
}
updates.scriptParams = JSON.stringify(parsed);
} catch (err) {
res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
return;
}
} else if (typeof req.body.scriptParams === 'object' && !Array.isArray(req.body.scriptParams)) {
updates.scriptParams = JSON.stringify(req.body.scriptParams);
} else {
res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
return;
}
}
const updated = await repo.updateScheduledTask(id, updates);
res.json({ task: updated });
} catch (err) {
res.status(400).json({ error: `Failed to update scheduled task: ${err}` });
}
});
// 削除
app.delete('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
res.status(404).json({ error: 'Not found' });
return;
}
const deleted = await repo.deleteScheduledTask(id);
if (!deleted) { res.status(404).json({ error: 'Not found' }); return; }
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to delete scheduled task: ${err}` });
}
});
// 手動即時実行
app.post('/api/scheduled-tasks/:id/trigger', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
res.status(404).json({ error: 'Not found' });
return;
}
await scheduler.executeById(id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to trigger scheduled task: ${err}` });
}
});
}
+290
View File
@@ -0,0 +1,290 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import express, { Request, Response, NextFunction } from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { mountUsersApi } from './users-api.js';
describe('GET /api/jobs/:id visibility', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
it('non-viewer gets null from repo.getJob (drives 404 in handler)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-vis-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const job = await repo.createJob({
repo: 'local/task-1',
issueNumber: 1,
instruction: 'x',
pieceName: 'chat',
ownerId: alice.id,
visibility: 'private',
visibilityScopeOrgId: null,
});
const bobUser: Express.User = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private' as const,
defaultVisibilityOrgId: null,
};
// Verify at the data layer: bob (non-owner, no orgs) cannot see alice's private job.
expect(await repo.getJob(job.id, { viewer: bobUser })).toBeNull();
// Alice (owner) can.
expect(await repo.getJob(job.id, { viewer: aliceUser })).not.toBeNull();
// Internal callers (no viewer) still get the row (worker/scheduler pass-through).
expect(await repo.getJob(job.id)).not.toBeNull();
} finally {
repo.close();
}
});
it('admin sees any job regardless of visibility', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-vis-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'a', role: 'user', status: 'active' });
const job = await repo.createJob({
repo: 'local/task-1',
issueNumber: 1,
instruction: 'x',
pieceName: 'chat',
ownerId: alice.id,
visibility: 'private',
visibilityScopeOrgId: null,
});
const adminUser: Express.User = {
id: 'admin-id', email: '[email protected]', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
expect(await repo.getJob(job.id, { viewer: adminUser })).not.toBeNull();
} finally {
repo.close();
}
});
});
describe('GET /api/users/me/orgs', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
/**
* Build a test app that mounts the REAL /api/users/me/orgs route via
* mountUsersApi (the same entry point createCoreServer uses) and injects a
* mocked req.user ahead of it. Pass `injectUser = null` to skip injection
* and exercise requireAuth.
*/
function buildApp(
repo: Repository,
injectUser: (Partial<Express.User> & { id: string }) | null,
): express.Application {
const app = express();
if (injectUser) {
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { user: Express.User }).user = {
email: '[email protected]', name: 'u', avatarUrl: null, role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
...injectUser,
} as Express.User;
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
// authActive=false: skip requireAuth (we pre-populate req.user above).
mountUsersApi(app, repo, false);
} else {
// authActive=true: exercise the real requireAuth guard. isAuthenticated()
// is missing so requireAuth should return 401.
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => false;
next();
});
mountUsersApi(app, repo, true);
}
return app;
}
it('returns 401 when the request is unauthenticated (requireAuth gate)', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const app = buildApp(repo, null);
const res = await request(app).get('/api/users/me/orgs');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
} finally {
repo.close();
}
});
it('returns the cached gitea orgs for the authenticated user', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
repo.replaceUserGiteaOrgs(alice.id, [
{ orgId: 'org-1', orgName: 'alpha' },
{ orgId: 'org-2', orgName: 'beta' },
]);
const app = buildApp(repo, { id: alice.id });
const res = await request(app).get('/api/users/me/orgs');
expect(res.status).toBe(200);
expect(res.body.orgs).toHaveLength(2);
// listUserGiteaOrgs ORDERs by org_name ASC
expect(res.body.orgs[0].orgName).toBe('alpha');
expect(res.body.orgs[1].orgName).toBe('beta');
expect(res.body.orgs[0].orgId).toBe('org-1');
} finally {
repo.close();
}
});
it('returns empty array when user has no cached orgs', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const bob = repo.createUser({ email: '[email protected]', name: 'Bob', role: 'user', status: 'active' });
const app = buildApp(repo, { id: bob.id });
const res = await request(app).get('/api/users/me/orgs');
expect(res.status).toBe(200);
expect(res.body.orgs).toEqual([]);
} finally {
repo.close();
}
});
});
describe('PATCH /api/users/me/preferences', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
function buildApp(
repo: Repository,
injectUser: (Partial<Express.User> & { id: string }) | null,
): express.Application {
const app = express();
if (injectUser) {
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { user: Express.User }).user = {
email: '[email protected]', name: 'u', avatarUrl: null, role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
...injectUser,
} as Express.User;
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
mountUsersApi(app, repo, false);
} else {
app.use((req: Request, _res: Response, next: NextFunction) => {
(req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => false;
next();
});
mountUsersApi(app, repo, true);
}
return app;
}
it('returns 400 when defaultVisibility is invalid', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id });
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'bogus' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid defaultVisibility');
} finally {
repo.close();
}
});
it('returns 400 when defaultVisibilityOrgId is not one of the user orgs', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id, orgIds: ['10'] });
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'org', defaultVisibilityOrgId: '99' });
expect(res.status).toBe(400);
} finally {
repo.close();
}
});
it('returns 400 when defaultVisibility=org is sent without defaultVisibilityOrgId', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id, orgIds: ['10'] });
for (const payload of [
{ defaultVisibility: 'org' },
{ defaultVisibility: 'org', defaultVisibilityOrgId: null },
{ defaultVisibility: 'org', defaultVisibilityOrgId: '' },
]) {
const res = await request(app).patch('/api/users/me/preferences').send(payload);
expect(res.status).toBe(400);
}
expect(repo.getUserById(alice.id)!.defaultVisibility).toBe('private');
} finally {
repo.close();
}
});
it('writes preferences on valid input and persists them', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' });
const app = buildApp(repo, { id: alice.id, orgIds: ['10'] });
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'org', defaultVisibilityOrgId: '10' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const after = repo.getUserById(alice.id);
expect(after!.defaultVisibility).toBe('org');
expect(after!.defaultVisibilityOrgId).toBe('10');
} finally {
repo.close();
}
});
it('returns 401 when unauthenticated', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-'));
const repo = new Repository(join(tempDir, 'db.sqlite'));
try {
const app = buildApp(repo, null);
const res = await request(app)
.patch('/api/users/me/preferences')
.send({ defaultVisibility: 'public' });
expect(res.status).toBe(401);
} finally {
repo.close();
}
});
});
+1190
View File
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { mountShareApi } from './share-api.js';
function setup(user?: { id: string; role: 'admin' | 'user' }) {
const tempDir = mkdtempSync(join(tmpdir(), 'share-api-'));
const repo = new Repository(join(tempDir, 'test.db'));
const app = express();
app.use(express.json());
// Mock user middleware (simulate authenticated user)
const effectiveUser = user ?? { id: 'user-1', role: 'admin' as const };
app.use((req, _res, next) => {
(req as any).user = {
...effectiveUser,
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
(req as any).isAuthenticated = () => true;
next();
});
mountShareApi(app, repo);
return { app, repo, tempDir };
}
describe('Share API', () => {
let tempDir = '';
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
});
it('POST /api/local/tasks/:id/share generates token', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.shareToken).toBeTruthy();
expect(res.body.shareUrl).toContain(res.body.shareToken);
});
it('DELETE /api/local/tasks/:id/share removes token', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const res = await request(ctx.app).delete(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const updated = await ctx.repo.getLocalTask(task.id);
expect(updated?.shareToken).toBeNull();
});
it('GET /api/shared/:token returns task info', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'shared task', body: 'body' });
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('shared task');
// ownerId と workspacePath は非公開
expect(res.body.task.ownerId).toBeUndefined();
expect(res.body.task.workspacePath).toBeUndefined();
});
it('GET /api/shared/:token returns 404 for unknown token', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const res = await request(ctx.app).get('/api/shared/nonexistent');
expect(res.status).toBe(404);
});
it('GET /api/shared/:token/comments returns comments', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
await ctx.repo.addLocalTaskComment(task.id, 'user', 'hello', 'comment');
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}/comments`);
expect(res.status).toBe(200);
expect(res.body.comments.length).toBe(1);
});
it('GET /api/shared/:token/files lists output files', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
const wsPath = join(tempDir, 'ws');
mkdirSync(join(wsPath, 'output'), { recursive: true });
writeFileSync(join(wsPath, 'output', 'result.md'), '# Result');
await ctx.repo.updateLocalTask(task.id, { workspacePath: wsPath });
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}/files`);
expect(res.status).toBe(200);
expect(res.body.entries.length).toBe(1);
expect(res.body.entries[0].name).toBe('result.md');
});
it('GET /api/shared/:token/files/raw serves file content', async () => {
const ctx = setup();
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'test', body: 'body' });
const wsPath = join(tempDir, 'ws');
mkdirSync(join(wsPath, 'output'), { recursive: true });
writeFileSync(join(wsPath, 'output', 'result.md'), '# Hello');
await ctx.repo.updateLocalTask(task.id, { workspacePath: wsPath });
const shareRes = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
const token = shareRes.body.shareToken;
const res = await request(ctx.app).get(`/api/shared/${token}/files/raw?path=result.md`);
expect(res.status).toBe(200);
});
// --- Cross-user authorization ---
it('POST /share by non-owner non-admin returns 404', async () => {
const ctx = setup({ id: 'bob', role: 'user' });
tempDir = ctx.tempDir;
// Alice owns the task
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(404);
// Token was NOT generated
const after = await ctx.repo.getLocalTask(task.id);
expect(after?.shareToken).toBeFalsy();
});
it('DELETE /share by non-owner non-admin returns 404', async () => {
const ctx = setup({ id: 'bob', role: 'user' });
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
// Alice already shared the task (simulate by setting token directly via repo)
await ctx.repo.shareLocalTask(task.id);
const res = await request(ctx.app).delete(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(404);
// Token still exists (Bob's DELETE was rejected)
const after = await ctx.repo.getLocalTask(task.id);
expect(after?.shareToken).toBeTruthy();
});
it('POST /share by owner succeeds', async () => {
const ctx = setup({ id: 'alice', role: 'user' });
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.shareToken).toBeTruthy();
});
it('POST /share by admin succeeds for any task', async () => {
const ctx = setup({ id: 'admin-1', role: 'admin' });
tempDir = ctx.tempDir;
const task = await ctx.repo.createLocalTask({ title: 'alice task', body: 'b', ownerId: 'alice', visibility: 'private' });
const res = await request(ctx.app).post(`/api/local/tasks/${task.id}/share`);
expect(res.status).toBe(200);
expect(res.body.shareToken).toBeTruthy();
});
});
+174
View File
@@ -0,0 +1,174 @@
import express, { Request, Response } from 'express';
import { readdirSync, statSync, readFileSync, mkdirSync } from 'fs';
import { join, resolve, sep, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { checkTaskOwnership } from './local-api-helpers.js';
function ensurePathWithin(baseDir: string, requestedPath: string): string {
const resolvedBase = resolve(baseDir);
const resolvedPath = resolve(baseDir, requestedPath);
if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
throw new Error('Path escapes workspace');
}
return resolvedPath;
}
function sanitizeTaskForPublic(task: Record<string, unknown>): Record<string, unknown> {
const { ownerId, workspacePath, body, ...safe } = task;
return safe;
}
export function mountShareApi(app: express.Application, repo: Repository): void {
// ── 公開エンドポイント(認証不要) ──
app.get('/api/shared/:token', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
res.json({ task: sanitizeTaskForPublic(task as unknown as Record<string, unknown>) });
} catch (err) {
logger.error(`Shared task API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch shared task' });
}
});
app.get('/api/shared/:token/comments', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
const comments = await repo.listLocalTaskComments(task.id);
res.json({ comments });
} catch (err) {
logger.error(`Shared comments API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch comments' });
}
});
app.get('/api/shared/:token/files', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = join(task.workspacePath, 'output');
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return {
name: entry.name,
path: relativeDir ? `${relativeDir}/${entry.name}` : entry.name,
kind: entry.isDirectory() ? 'directory' : 'file',
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
});
res.json({ basePath: 'output', path: relativeDir, entries });
} catch (err) {
logger.error(`Shared files API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
app.get('/api/shared/:token/files/content', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const rootDir = join(task.workspacePath, 'output');
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
logger.error(`Shared file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/shared/:token/files/raw', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const rootDir = join(task.workspacePath, 'output');
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
logger.error(`Shared file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/shared/:token/subtasks/activities', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(task.id), task.id);
if (!latestJob) { res.json({ subtasks: [] }); return; }
const subJobs = await repo.getSubJobs(latestJob.id);
const subtasks = subJobs.map(job => ({
jobId: job.id,
issueNumber: job.issueNumber,
status: job.status,
currentMovement: job.currentMovement ?? null,
currentActivity: job.currentActivity ?? null,
activityLog: '',
}));
res.json({ subtasks });
} catch (err) {
logger.error(`Shared subtask activities API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activities' });
}
});
// ── 認証付きエンドポイント ──
app.post('/api/local/tasks/:taskId/share', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
const shareToken = await repo.shareLocalTask(taskId);
res.json({ shareToken, shareUrl: `/ui/shared/${shareToken}` });
} catch (err) {
logger.error(`Share task API error: ${err}`);
res.status(500).json({ error: 'Failed to share task' });
}
});
app.delete('/api/local/tasks/:taskId/share', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
await repo.unshareLocalTask(taskId);
res.json({ ok: true });
} catch (err) {
logger.error(`Unshare task API error: ${err}`);
res.status(500).json({ error: 'Failed to unshare task' });
}
});
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
registerShutdownHook,
runShutdown,
installSignalHandlers,
__resetShutdownForTests,
__getRegisteredHookCountForTests,
} from './shutdown.js';
describe('shutdown registry', () => {
let exitCalls: number[];
beforeEach(() => {
exitCalls = [];
__resetShutdownForTests({ exitFn: (code) => { exitCalls.push(code); } });
});
it('runs all registered hooks concurrently', async () => {
const order: string[] = [];
registerShutdownHook('slow', async () => {
await new Promise((r) => setTimeout(r, 20));
order.push('slow');
});
registerShutdownHook('fast', async () => {
order.push('fast');
});
await runShutdown('SIGTERM');
// Concurrent: fast resolves before slow even though it registered second.
expect(order).toEqual(['fast', 'slow']);
expect(exitCalls).toEqual([0]);
});
it('continues other hooks when one rejects', async () => {
const ran: string[] = [];
registerShutdownHook('broken', async () => { throw new Error('boom'); });
registerShutdownHook('ok', async () => { ran.push('ok'); });
await runShutdown('SIGTERM');
expect(ran).toEqual(['ok']);
// exit still called once with 0 — a hook failure must not block exit.
expect(exitCalls).toEqual([0]);
});
it('is idempotent across repeated invocations', async () => {
let hookCalls = 0;
registerShutdownHook('counter', async () => { hookCalls++; });
await runShutdown('SIGTERM');
await runShutdown('SIGINT');
expect(hookCalls).toBe(1);
expect(exitCalls).toEqual([0]);
});
it('treats sync hooks identically to async hooks', async () => {
const ran: string[] = [];
registerShutdownHook('sync', () => { ran.push('sync'); });
registerShutdownHook('async', async () => { ran.push('async'); });
await runShutdown('SIGTERM');
expect(ran.sort()).toEqual(['async', 'sync']);
expect(exitCalls).toEqual([0]);
});
it('exits even when no hooks are registered', async () => {
await runShutdown('SIGTERM');
expect(exitCalls).toEqual([0]);
});
it('counts each registerShutdownHook call', () => {
registerShutdownHook('a', () => {});
registerShutdownHook('b', () => {});
expect(__getRegisteredHookCountForTests()).toBe(2);
});
it('installSignalHandlers installs exactly one listener per signal even when called twice', () => {
// Snapshot existing listener counts so we don't false-positive on
// listeners installed by the test harness itself.
const sigtermBefore = process.listenerCount('SIGTERM');
const sigintBefore = process.listenerCount('SIGINT');
try {
installSignalHandlers();
installSignalHandlers();
installSignalHandlers();
expect(process.listenerCount('SIGTERM') - sigtermBefore).toBe(1);
expect(process.listenerCount('SIGINT') - sigintBefore).toBe(1);
} finally {
// Remove the listeners we just installed so they don't leak
// into subsequent tests (vitest shares the process). We can't
// grab a handle to the wrapper, so removeAllListeners (capped
// to the count we added) is the safe option.
const sigtermNow = process.listenerCount('SIGTERM');
const sigintNow = process.listenerCount('SIGINT');
const sigtermAdded = sigtermNow - sigtermBefore;
const sigintAdded = sigintNow - sigintBefore;
const sigtermListeners = process.listeners('SIGTERM');
const sigintListeners = process.listeners('SIGINT');
for (let i = sigtermListeners.length - sigtermAdded; i < sigtermListeners.length; i++) {
process.removeListener('SIGTERM', sigtermListeners[i]! as () => void);
}
for (let i = sigintListeners.length - sigintAdded; i < sigintListeners.length; i++) {
process.removeListener('SIGINT', sigintListeners[i]! as () => void);
}
}
});
it('logs and surfaces sync throws as rejections', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
registerShutdownHook('syncthrow', () => { throw new Error('sync-boom'); });
await runShutdown('SIGTERM');
expect(exitCalls).toEqual([0]);
warn.mockRestore();
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* shutdown.ts — central registry for process-exit cleanup hooks.
*
* Why this exists
* ───────────────
* Phase B + Phase C each installed their own `process.on('SIGTERM', …)`
* and `process.on('SIGINT', …)` handlers (SSH console teardown, then
* BackendStatusRegistry shutdown). Stacking N independent handlers
* per signal has three issues:
*
* 1. Node's default MaxListeners is 10. Phase D will add more
* subsystems; we'll start emitting MaxListenersExceededWarning.
* 2. No ordering guarantee. If two hooks both touch shared state
* (e.g. a logger flush + a worker drain), interleaving is
* non-deterministic.
* 3. No idempotence guard. Multiple signals (SIGTERM then SIGINT)
* would re-run every hook.
*
* The registry solves all three: subsystems register a single hook
* each, the registry installs exactly one listener per signal, hooks
* run concurrently (Promise.allSettled — one slow hook doesn't gate
* the others), and a `shutdownStarted` flag prevents re-entry.
*
* Test surface
* ────────────
* `runShutdown` and `__resetShutdownForTests` are exported so unit
* tests can drive the pure logic without raising real signals (which
* would terminate the test runner). `installSignalHandlers` is the
* production entry point and is called once from `startServer`.
*/
import { logger } from '../logger.js';
export type ShutdownHook = () => Promise<void> | void;
interface RegisteredHook {
name: string;
fn: ShutdownHook;
}
const hooks: RegisteredHook[] = [];
let shutdownStarted = false;
let signalsInstalled = false;
let exitFn: (code: number) => void = (code) => process.exit(code);
/**
* Register a hook to run during graceful shutdown.
*
* `name` is used in shutdown logs only — it should describe the
* subsystem (e.g. `"ssh-console"`, `"backend-status-registry"`) so
* stuck or slow shutdowns are diagnosable from the log line.
*
* Hooks may be sync or return a Promise. Errors are caught and
* logged; one hook's failure never blocks the others.
*/
export function registerShutdownHook(name: string, fn: ShutdownHook): void {
hooks.push({ name, fn });
}
/**
* Drain all registered hooks and exit the process.
*
* Concurrent (Promise.allSettled) rather than sequential because the
* hooks operate on independent subsystems — sequential would just
* sum their latencies (and BackendStatusRegistry.stop alone can take
* up to ~3s waiting for in-flight probes to abort).
*
* Idempotent: if shutdown is already in progress, second calls are
* silently dropped (no double-drain, no double-exit).
*/
export async function runShutdown(signal: string): Promise<void> {
if (shutdownStarted) return;
shutdownStarted = true;
logger.info(`[shutdown] received ${signal}, draining ${hooks.length} hook(s)`);
const results = await Promise.allSettled(
hooks.map(async (h) => {
try {
await h.fn();
} catch (e) {
// Re-throw so allSettled records `rejected` with the original
// reason; the catch is here only to ensure sync throws surface
// the same way as async rejections.
throw e instanceof Error ? e : new Error(String(e));
}
}),
);
for (let i = 0; i < results.length; i++) {
const r = results[i]!;
const h = hooks[i]!;
if (r.status === 'rejected') {
const reason = r.reason instanceof Error ? r.reason.message : String(r.reason);
logger.warn(`[shutdown] hook ${h.name} rejected: ${reason}`);
}
}
exitFn(0);
}
/**
* Install the SIGTERM / SIGINT listeners exactly once.
*
* Safe to call multiple times — subsequent calls are no-ops so unit
* tests and integration paths can both invoke it without doubling
* the listeners.
*/
export function installSignalHandlers(): void {
if (signalsInstalled) return;
signalsInstalled = true;
process.on('SIGTERM', () => { void runShutdown('SIGTERM'); });
process.on('SIGINT', () => { void runShutdown('SIGINT'); });
}
/**
* Test-only reset. Clears registered hooks, the started flag, the
* installed-signals flag, and the exit function. Real production
* code must never call this — the singleton state is the entire
* point of the registry.
*/
export function __resetShutdownForTests(opts?: { exitFn?: (code: number) => void }): void {
hooks.length = 0;
shutdownStarted = false;
signalsInstalled = false;
exitFn = opts?.exitFn ?? ((code) => process.exit(code));
}
/** Test-only accessor for the current registered-hook count. */
export function __getRegisteredHookCountForTests(): number {
return hooks.length;
}
+420
View File
@@ -0,0 +1,420 @@
import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, lstatSync, renameSync, rmSync } from 'fs';
import { join, relative } from 'path';
import { randomBytes } from 'crypto';
import type { SkillCatalog, SkillEntry } from '../engine/skills.js';
import { VALID_SKILL_NAME } from '../engine/skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js';
import { logger } from '../logger.js';
import { handleInstallFromUrl } from './skills-git-install.js';
const MAX_CONTENT_SIZE = 64 * 1024; // 64 KB
export interface MountSkillsApiOptions {
skillCatalog: SkillCatalog;
requireAuth?: RequestHandler;
requireAdmin?: RequestHandler;
authActive?: boolean;
auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>;
}
type AuthedUser = { id?: string; role?: string };
function getUser(req: Request): AuthedUser | undefined {
return (req as any).user as AuthedUser | undefined;
}
function getUserId(req: Request): string {
const user = getUser(req);
return user?.id ?? 'local';
}
function isAdmin(req: Request): boolean {
const user = getUser(req);
return user?.role === 'admin';
}
/**
* Recursively list files in a directory, skipping symlinks.
* Returns paths relative to `baseDir`.
*/
function listDirFiles(baseDir: string, maxDepth: number = 5): string[] {
const results: string[] = [];
function walk(dir: string, depth: number): void {
if (depth > maxDepth) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
walk(fullPath, depth + 1);
} else if (stat.isFile()) {
results.push(relative(baseDir, fullPath));
}
}
}
walk(baseDir, 0);
return results;
}
export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): void {
const { skillCatalog } = opts;
// JSON body parser for skills endpoints
app.use('/api/skills', express.json());
// Auth gating
if (opts.authActive && opts.requireAuth) {
app.use('/api/skills', opts.requireAuth);
}
// ── GET /api/skills ── list skills ──────────────────────────────
app.get('/api/skills', (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const scope = (req.query.scope as string) ?? 'all';
if (!['all', 'system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: all, system, user' });
return;
}
const entries = skillCatalog.getForUser(userId);
const filtered = scope === 'all'
? entries
: entries.filter(e => e.source === scope);
const skills = filtered.map(e => ({
name: e.name,
description: e.description,
triggers: e.triggers,
source: e.source,
hasDir: e.dirPath !== null,
}));
res.json({ skills });
} catch (e) {
res.status(500).json({ error: `Failed to list skills: ${e}` });
}
});
// ── POST /api/skills/install-from-url ── Git URL install ────────
// Must be before /:name routes to avoid Express matching 'install-from-url' as :name
app.post('/api/skills/install-from-url', handleInstallFromUrl({
skillCatalog: opts.skillCatalog,
auditLog: opts.auditLog,
}));
// ── GET /api/skills/:name ── skill detail ──────────────────────
app.get('/api/skills/:name', (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
try {
const userId = getUserId(req);
const scopeHint = req.query.scope as string | undefined;
// Find the entry matching name (and optional scope filter)
const entries = skillCatalog.getForUser(userId);
let entry: SkillEntry | undefined;
if (scopeHint && ['system', 'user'].includes(scopeHint)) {
entry = entries.find(e => e.name === name && e.source === scopeHint);
}
if (!entry) {
entry = entries.find(e => e.name === name);
}
if (!entry) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Read content via catalog
const contentResult = skillCatalog.getSkillContent(name, userId);
const content = contentResult?.content ?? '';
// Read raw file for frontmatter
let raw = '';
try {
raw = readFileSync(entry.filePath, 'utf-8');
} catch { /* skip */ }
// File listing for directory skills
let files: string[] | undefined;
if (entry.dirPath) {
files = listDirFiles(entry.dirPath);
}
// Security scan
let findings;
if (entry.dirPath) {
findings = scanSkillDirectory(entry.dirPath);
} else {
findings = scanSkillContent(raw);
}
res.json({
name: entry.name,
description: entry.description,
triggers: entry.triggers,
source: entry.source,
hasDir: entry.dirPath !== null,
content,
files,
findings,
maxSeverity: maxSeverity(findings),
});
} catch (e) {
res.status(500).json({ error: `Failed to read skill: ${e}` });
}
});
// ── POST /api/skills ── create single-file skill ──────────────
app.post('/api/skills', async (req: Request, res: Response) => {
try {
const { name, content, scope } = req.body ?? {};
// Validate name
if (!name || typeof name !== 'string' || !VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name (lowercase alphanumeric, hyphens, underscores)' });
return;
}
// Validate scope
if (!scope || !['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// Validate content
if (!content || typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can create system skills' });
return;
}
const userId = getUserId(req);
// Determine destination directory
const destDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Check for existing skill (directory or flat file)
const destDirPath = join(destDir, name);
const destFlatPath = join(destDir, `${name}.md`);
if (existsSync(destDirPath) || existsSync(destFlatPath)) {
res.status(409).json({ error: 'Skill already exists' });
return;
}
// Scan content before writing
const findings = scanSkillContent(content);
const severity = maxSeverity(findings);
// Always create directory format: {name}/SKILL.md
const tmpDir = join(destDir, `.tmp-${randomBytes(8).toString('hex')}`);
mkdirSync(tmpDir, { recursive: true });
writeFileSync(join(tmpDir, 'SKILL.md'), content, 'utf-8');
renameSync(tmpDir, destDirPath);
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.create', actor, { name, scope, severity });
}
logger.info(`[skills-api] created skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
res.status(201).json({ name, scope, severity, findings });
} catch (e) {
res.status(500).json({ error: `Failed to create skill: ${e}` });
}
});
// ── PUT /api/skills/:name ── edit skill content ───────────────
app.put('/api/skills/:name', async (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
const scope = req.query.scope as string | undefined;
if (!scope) {
res.status(400).json({ error: 'scope query parameter is required' });
return;
}
if (!['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can edit system skills' });
return;
}
try {
const { content } = req.body ?? {};
if (!content || typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
if (Buffer.byteLength(content, 'utf-8') > MAX_CONTENT_SIZE) {
res.status(400).json({ error: `Content exceeds maximum size of ${MAX_CONTENT_SIZE / 1024}KB` });
return;
}
const userId = getUserId(req);
const baseDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Find the skill file: either flat file or directory with SKILL.md
let targetPath: string | null = null;
const flatPath = join(baseDir, `${name}.md`);
const dirSkillPath = join(baseDir, name, 'SKILL.md');
if (existsSync(dirSkillPath)) {
targetPath = dirSkillPath;
} else if (existsSync(flatPath)) {
targetPath = flatPath;
}
if (!targetPath) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Scan new content
const findings = scanSkillContent(content);
const severity = maxSeverity(findings);
// Atomic write: tmpfile in same directory as target → rename
const targetDir = targetPath === dirSkillPath ? join(baseDir, name) : baseDir;
const tmpPath = join(targetDir, `.tmp-${randomBytes(8).toString('hex')}.md`);
writeFileSync(tmpPath, content, 'utf-8');
renameSync(tmpPath, targetPath);
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.update', actor, { name, scope, severity });
}
logger.info(`[skills-api] updated skill=${name} scope=${scope} actor=${actor} severity=${severity}`);
res.json({ ok: true, severity, findings });
} catch (e) {
res.status(500).json({ error: `Failed to update skill: ${e}` });
}
});
// ── DELETE /api/skills/:name ── delete skill ──────────────────
app.delete('/api/skills/:name', async (req: Request, res: Response) => {
const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' });
return;
}
const scope = req.query.scope as string | undefined;
if (!scope) {
res.status(400).json({ error: 'scope query parameter is required' });
return;
}
if (!['system', 'user'].includes(scope)) {
res.status(400).json({ error: 'scope must be one of: system, user' });
return;
}
// System scope requires admin
if (scope === 'system' && !isAdmin(req)) {
res.status(403).json({ error: 'Only admins can delete system skills' });
return;
}
try {
const userId = getUserId(req);
const baseDir = scope === 'system'
? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId);
// Find the skill: directory or flat file
const dirPath = join(baseDir, name);
const flatPath = join(baseDir, `${name}.md`);
let deleted = false;
if (existsSync(dirPath) && lstatSync(dirPath).isDirectory()) {
rmSync(dirPath, { recursive: true, force: true });
deleted = true;
} else if (existsSync(flatPath) && lstatSync(flatPath).isFile()) {
unlinkSync(flatPath);
deleted = true;
}
if (!deleted) {
res.status(404).json({ error: 'Skill not found' });
return;
}
// Invalidate cache
if (scope === 'system') {
skillCatalog.refreshSystem();
} else {
skillCatalog.invalidate(userId);
}
// Audit log
const actor = userId;
if (opts.auditLog) {
await opts.auditLog(null, 'skill.delete', actor, { name, scope });
}
logger.info(`[skills-api] deleted skill=${name} scope=${scope} actor=${actor}`);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: `Failed to delete skill: ${e}` });
}
});
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { validateUrl } from './skills-git-install.js';
describe('validateUrl', () => {
it('accepts a normal https git URL', () => {
expect(validateUrl('https://github.com/owner/repo')).toBeNull();
expect(validateUrl('https://gitea.example.com/team/skills.git')).toBeNull();
});
it('rejects non-https schemes', () => {
expect(validateUrl('http://github.com/o/r')).toMatch(/https/);
expect(validateUrl('ssh://[email protected]/o/r')).toMatch(/https/);
expect(validateUrl('file:///etc/passwd')).toMatch(/https/);
expect(validateUrl('git://github.com/o/r')).toMatch(/https/);
});
it('rejects empty / non-string input', () => {
expect(validateUrl('')).toMatch(/required/);
// @ts-expect-error intentional bad input
expect(validateUrl(undefined)).toMatch(/required/);
});
// Regression: shell command injection via the git URL.
// Previously only [;&|`$] were blocked, so a double-quote + newline could
// break out of `git clone "${url}"` and run an arbitrary command.
it('rejects newline + quote command-injection payloads', () => {
expect(validateUrl('https://x"\nid #')).toMatch(/disallowed|control/);
expect(validateUrl('https://x"\r\ntouch /tmp/pwned')).toMatch(/disallowed|control/);
expect(validateUrl('https://github.com/o/r"; rm -rf ~ #')).toMatch(/disallowed/);
});
it('rejects shell metacharacters and whitespace', () => {
for (const bad of ['https://x;id', 'https://x|id', 'https://x&&id', 'https://x`id`', 'https://x$(id)', "https://x'", 'https://a b', 'https://x<y']) {
expect(validateUrl(bad)).toMatch(/disallowed/);
}
});
it('rejects control characters', () => {
expect(validateUrl('https://x\x00y')).toMatch(/control|disallowed/);
});
it('rejects strings that pass the prefix but are not valid URLs', () => {
// No host after the scheme.
expect(validateUrl('https://')).not.toBeNull();
});
});
+363
View File
@@ -0,0 +1,363 @@
/**
* Git URL install handler for skills.
* POST /api/skills/install-from-url
*
* Two modes:
* - Preview: POST { url } → returns detected skills + scan findings
* - Install: POST { url, selectedSkills: [...] } → installs selected skills
*/
import type { Request, Response } from 'express';
import {
existsSync, mkdirSync, mkdtempSync, rmSync, cpSync,
readdirSync, lstatSync, readFileSync, writeFileSync,
} from 'fs';
import { join, relative } from 'path';
import { execFileSync } from 'child_process';
import { tmpdir } from 'os';
import matter from 'gray-matter';
import type { SkillCatalog } from '../engine/skills.js';
import { VALID_SKILL_NAME } from '../engine/skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity, type ScanFinding } from '../engine/skills-scanner.js';
import { logger } from '../logger.js';
// ── Types ───────────────────────────────────────────────────────────────────
export interface GitInstallDeps {
skillCatalog: SkillCatalog;
auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>;
}
export interface DetectedSkill {
name: string;
description: string;
relativePath: string;
fullPath: string;
isDir: boolean;
findings: ScanFinding[];
maxSeverity: 'high' | 'medium' | 'none';
}
// ── Constants ───────────────────────────────────────────────────────────────
const MAX_REPO_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
const GIT_CLONE_TIMEOUT_MS = 30_000;
const SKIP_DIRS = new Set(['.git', 'node_modules', '.github', '.vscode']);
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* Walk a cloned directory looking for skills:
* - Directories containing SKILL.md
* - Standalone .md files with frontmatter `name`
*/
export function detectSkillsInDir(rootDir: string): DetectedSkill[] {
const results: DetectedSkill[] = [];
function walk(dir: string): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
// Skip symlinks entirely (security)
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) {
if (SKIP_DIRS.has(entry)) continue;
// Check if this directory is a skill (has SKILL.md)
const skillMdPath = join(fullPath, 'SKILL.md');
if (existsSync(skillMdPath)) {
try {
const skillStat = lstatSync(skillMdPath);
if (skillStat.isSymbolicLink()) continue; // Skip symlinked SKILL.md
} catch {
continue;
}
try {
const raw = readFileSync(skillMdPath, 'utf-8');
const { data } = matter(raw);
if (data && typeof data.name === 'string' && data.name && VALID_SKILL_NAME.test(data.name)) {
const findings = scanSkillDirectory(fullPath);
results.push({
name: data.name,
description: typeof data.description === 'string' ? data.description : '',
relativePath: relative(rootDir, fullPath),
fullPath,
isDir: true,
findings,
maxSeverity: maxSeverity(findings),
});
}
} catch {
// Unreadable SKILL.md — skip
}
// Don't recurse into skill directories (they're self-contained)
continue;
}
// Not a skill dir — recurse
walk(fullPath);
continue;
}
// Standalone .md file
if (stat.isFile() && entry.endsWith('.md')) {
try {
const raw = readFileSync(fullPath, 'utf-8');
const { data } = matter(raw);
if (data && typeof data.name === 'string' && data.name && VALID_SKILL_NAME.test(data.name)) {
const findings = scanSkillContent(raw);
results.push({
name: data.name,
description: typeof data.description === 'string' ? data.description : '',
relativePath: relative(rootDir, fullPath),
fullPath,
isDir: false,
findings,
maxSeverity: maxSeverity(findings),
});
}
} catch {
// Unreadable .md — skip
}
}
}
}
walk(rootDir);
return results;
}
/**
* Validate that a URL is safe for git clone (SSRF defense).
* Only HTTPS URLs are allowed.
*/
export function validateUrl(url: string): string | null {
if (!url || typeof url !== 'string') return 'url is required';
const trimmed = url.trim();
if (!trimmed.startsWith('https://')) {
return 'Only https:// URLs are allowed (http://, file://, ssh://, git:// and local paths are rejected for security)';
}
// Reject control characters (newlines, NUL, etc.), shell metacharacters,
// quotes and whitespace. The clone now runs via execFile (no shell), so this
// is defense-in-depth, but it also prevents a newline+quote breakout if the
// URL is ever reused in a shell context.
if (/[\u0000-\u001f\u007f;&|`$"'\\<>(){}\s]/.test(trimmed)) {
return 'URL contains disallowed characters';
}
// Must parse as a real https URL.
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return 'URL is not a valid URL';
}
if (parsed.protocol !== 'https:') {
return 'Only https:// URLs are allowed';
}
return null;
}
// ── Handler ─────────────────────────────────────────────────────────────────
export function handleInstallFromUrl(deps: GitInstallDeps): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
const { url, scope: rawScope, selectedSkills } = req.body ?? {};
// 1. Validate URL
const urlError = validateUrl(url);
if (urlError) {
res.status(400).json({ error: urlError });
return;
}
// 2. Validate scope
const scope: 'system' | 'user' = rawScope === 'system' ? 'system' : 'user';
// 3. System scope requires admin
const user = req.user as Express.User | undefined;
if (scope === 'system' && (!user || user.role !== 'admin')) {
res.status(403).json({ error: 'System-scope install requires admin role' });
return;
}
const userId = user?.id ?? 'anonymous';
// 4. Clone to temp directory
const tmpBase = mkdtempSync(join(tmpdir(), 'skill-git-'));
const cloneDir = join(tmpBase, 'repo');
try {
try {
// execFile (no shell): url and cloneDir are passed as literal argv
// entries, so shell metacharacters in `url` cannot inject commands.
// `--` terminates option parsing so a `url` starting with `-` cannot
// be treated as a git flag (defense-in-depth; validateUrl already
// requires an https:// prefix).
execFileSync(
'git',
['clone', '--depth', '1', '--no-recurse-submodules', '--no-checkout', '--', url, cloneDir],
{ timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' },
);
execFileSync(
'git',
['-C', cloneDir, 'checkout', 'HEAD', '--', '.'],
{ timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' },
);
} catch (cloneErr: unknown) {
const isTimeout = cloneErr instanceof Error && 'killed' in cloneErr && (cloneErr as any).killed;
if (isTimeout) {
res.status(408).json({ error: 'Git clone timed out (30s limit)' });
return;
}
const msg = cloneErr instanceof Error ? cloneErr.message : String(cloneErr);
res.status(400).json({ error: `Git clone failed: ${msg.slice(0, 300)}` });
return;
}
// 5. Verify clone exists
if (!existsSync(cloneDir)) {
res.status(400).json({ error: 'Git clone produced no output directory' });
return;
}
// 6. Size check
try {
const duOutput = execFileSync('du', ['-sb', cloneDir], { encoding: 'utf-8', timeout: 10_000 });
const sizeBytes = parseInt(duOutput.split('\t')[0], 10);
if (sizeBytes > MAX_REPO_SIZE_BYTES) {
res.status(400).json({
error: `Repository too large: ${Math.round(sizeBytes / 1024 / 1024)}MB exceeds 50MB limit`,
});
return;
}
} catch {
// du failed — continue (non-critical)
logger.warn('[skills-git-install] du -sb failed, skipping size check');
}
// 7. Detect skills
const detected = detectSkillsInDir(cloneDir);
if (detected.length === 0) {
res.status(400).json({
error: 'No skills found in repository. Skills must be directories with SKILL.md or standalone .md files with frontmatter "name".',
});
return;
}
// 8. Preview mode — explicitly requested via preview flag
const previewMode = req.body?.preview === true;
if (previewMode) {
const preview = detected.map(s => ({
name: s.name,
description: s.description,
relativePath: s.relativePath,
isDir: s.isDir,
findings: s.findings,
maxSeverity: s.maxSeverity,
}));
res.json({ preview, totalDetected: detected.length });
return;
}
// 9. Install mode — selectedSkills or all detected
const selectedSet = Array.isArray(selectedSkills) && selectedSkills.length > 0
? new Set(selectedSkills.filter((s: unknown) => typeof s === 'string'))
: new Set(detected.map(s => s.name));
if (selectedSet.size === 0) {
res.status(400).json({ error: 'No skills detected in repository' });
return;
}
// Resolve target directory
const targetDir = scope === 'system'
? deps.skillCatalog.getSystemDir()
: deps.skillCatalog.getUserSkillDir(userId);
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true });
}
const installed: string[] = [];
const errors: string[] = [];
for (const skill of detected) {
if (!selectedSet.has(skill.name)) continue;
try {
if (skill.isDir) {
// Copy entire skill directory
const destDir = join(targetDir, skill.name);
cpSync(skill.fullPath, destDir, { recursive: true });
} else {
// Single .md file → create as directory format ({name}/SKILL.md)
const content = readFileSync(skill.fullPath, 'utf-8');
const destDir = join(targetDir, skill.name);
mkdirSync(destDir, { recursive: true });
writeFileSync(join(destDir, 'SKILL.md'), content, 'utf-8');
}
installed.push(skill.name);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`${skill.name}: ${msg.slice(0, 200)}`);
logger.warn(`[skills-git-install] failed to install skill=${skill.name} err=${msg}`);
}
}
// Check for requested skills that weren't found in the repo
for (const name of selectedSet) {
if (!installed.includes(name) && !errors.some(e => e.startsWith(`${name}:`))) {
errors.push(`${name}: not found in repository`);
}
}
// 10. Invalidate cache
if (scope === 'system') {
deps.skillCatalog.refreshSystem();
} else {
deps.skillCatalog.invalidate(userId);
}
// 11. Audit log
if (deps.auditLog && installed.length > 0) {
deps.auditLog(null, 'skill_install_from_url', userId, {
url,
scope,
installed,
errors: errors.length > 0 ? errors : undefined,
}).catch(err => {
logger.warn(`[skills-git-install] audit log failed err=${err}`);
});
}
logger.info(`[skills-git-install] installed=${installed.length} errors=${errors.length} scope=${scope} user=${userId}`);
// 12. Return result
res.json({ installed, errors: errors.length > 0 ? errors : undefined });
} finally {
// Always clean up temp directory
try {
rmSync(tmpBase, { recursive: true, force: true });
} catch (cleanupErr) {
logger.warn(`[skills-git-install] tmpdir cleanup failed: ${cleanupErr}`);
}
}
};
}
+787
View File
@@ -0,0 +1,787 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import {
createSshUserRouter,
createSshAdminRouter,
type SshApiDeps,
type SshTester,
} from './ssh-api.js';
import { createConnectionRepo } from '../ssh/connection-repo.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
import { createAuditRepo } from '../ssh/audit-repo.js';
import { createAbuseRepo } from '../ssh/abuse-repo.js';
import { createAccessResolver } from '../ssh/access.js';
import { createMaintenanceController } from '../ssh/maintenance.js';
import { createAdminRateLimiter } from '../ssh/admin-rate-limit.js';
const VALID_KEY = 'a'.repeat(64);
const VALID_UUID = '6f9619ff-8b86-d011-b42d-00c04fc964ff';
const SAMPLE_PEM = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakeKey\n-----END OPENSSH PRIVATE KEY-----';
const SAMPLE_FP = 'SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCD';
const openDbs: Database.Database[] = [];
function makeDb(): Database.Database {
process.env.MCP_ENCRYPTION_KEY = VALID_KEY;
const db = new Database(':memory:');
openDbs.push(db);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?), (?), (?)').run('alice', 'bob', 'admin1');
return db;
}
function makeFakeTester(verdict: 'first_observe' | 'mismatch' | 'pass' = 'first_observe'): SshTester {
return {
async test() {
return {
fingerprint: 'SHA256:hostkeyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
hostKeyB64: Buffer.from([0, 0, 0, 11, ...Buffer.from('ssh-ed25519'), 0xaa]).toString('base64'),
hostKeyType: 'ssh-ed25519',
verdict,
};
},
};
}
interface Harness {
db: Database.Database;
app: express.Application;
deps: SshApiDeps;
maintenance: ReturnType<typeof createMaintenanceController>;
rateLimiter: ReturnType<typeof createAdminRateLimiter>;
}
function makeHarness(opts: {
userId?: string;
isAdmin?: boolean;
isAnon?: boolean;
orgIds?: string[];
tester?: SshTester;
forceUnlockLimit?: { windowMs: number; maxRequests: number };
onAccessRevoked?: SshApiDeps['onAccessRevoked'];
} = {}): Harness {
const db = makeDb();
const connectionRepo = createConnectionRepo(db);
const grantsRepo = createGrantsRepo(db);
const auditRepo = createAuditRepo(db);
const abuseRepo = createAbuseRepo(db, { windowMinutes: 10, failureThreshold: 5, lockMinutes: 30 });
const accessResolver = createAccessResolver(grantsRepo, { adminBypassesGrants: true });
const maintenance = createMaintenanceController();
const rateLimiter = createAdminRateLimiter(opts.forceUnlockLimit ?? { windowMs: 60_000, maxRequests: 10 });
const userId = opts.userId ?? 'alice';
const isAdmin = !!opts.isAdmin;
const isAnon = !!opts.isAnon;
const requireAuth: express.RequestHandler = (_req, res, next) => {
if (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; }
next();
};
// Stub encryption: store the PEM bytes prefixed with a marker so decrypt
// can verify roundtrip. Real impl uses src/ssh/crypto.ts.
const SAMPLE_PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTPUBKEY';
const encryptKeyMaterial: SshApiDeps['encryptKeyMaterial'] = (_ownerId, pem, passphrase) => ({
blob: Buffer.concat([Buffer.from('ENC:'), pem]),
passphraseBlob: passphrase ? Buffer.concat([Buffer.from('PEN:'), passphrase]) : null,
keyVersion: 1,
fingerprint: SAMPLE_FP,
publicKey: SAMPLE_PUBKEY,
});
const decryptKeyMaterial: SshApiDeps['decryptKeyMaterial'] = (_ownerId, blob) => {
if (!blob.subarray(0, 4).equals(Buffer.from('ENC:'))) throw new Error('decrypt: bad blob');
return Buffer.from(blob.subarray(4));
};
const decryptPassphrase: SshApiDeps['decryptPassphrase'] = (_ownerId, blob) => {
if (!blob) return null;
if (!blob.subarray(0, 4).equals(Buffer.from('PEN:'))) throw new Error('decrypt: bad pass blob');
return Buffer.from(blob.subarray(4));
};
const generateKeypair: SshApiDeps['generateKeypair'] = (keyType) => ({
privateKeyPem: Buffer.from(`STUB-PEM-${keyType}`, 'utf8'),
publicKey: `ssh-${keyType === 'rsa-4096' ? 'rsa' : 'ed25519'} AAAAGENERATED${keyType}`,
});
const derivePublicKey: SshApiDeps['derivePublicKey'] = (_ownerId, _blob, _passBlob) => SAMPLE_PUBKEY;
const deps: SshApiDeps = {
db,
requireAuth,
requireAdmin,
getUserId: () => (isAnon ? null : userId),
isAdmin: () => isAdmin,
getOrgIds: () => opts.orgIds ?? [],
connectionRepo,
grantsRepo,
auditRepo,
abuseRepo,
accessResolver,
maintenance,
forceUnlockLimiter: rateLimiter,
encryptKeyMaterial,
decryptKeyMaterial,
decryptPassphrase,
generateKeypair,
derivePublicKey,
sshTester: opts.tester ?? makeFakeTester(),
onAccessRevoked: opts.onAccessRevoked,
};
const app = express();
app.use(express.json());
app.use('/api/ssh', createSshUserRouter(deps));
app.use('/api/ssh/admin', createSshAdminRouter(deps));
return { db, app, deps, maintenance, rateLimiter };
}
async function createOwnedConnection(h: Harness, overrides: Record<string, unknown> = {}): Promise<string> {
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',
...overrides,
});
if (res.status !== 201) throw new Error(`unexpected status ${res.status}: ${JSON.stringify(res.body)}`);
return res.body.connection.id;
}
afterEach(() => {
for (const db of openDbs) {
try { db.close(); } catch {}
}
openDbs.length = 0;
delete process.env.MCP_ENCRYPTION_KEY;
});
// ──────────────────────────────────────────────────────────────────────
// Auth + maintenance + reason gating
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: auth gating', () => {
it('GET /api/ssh/connections requires auth', async () => {
const h = makeHarness({ isAnon: true });
const res = await request(h.app).get('/api/ssh/connections');
expect(res.status).toBe(401);
});
it('POST /api/ssh/connections requires auth', async () => {
const h = makeHarness({ isAnon: true });
const res = await request(h.app).post('/api/ssh/connections').send({});
expect(res.status).toBe(401);
});
it('GET /api/ssh/admin/connections requires admin', async () => {
const h = makeHarness({ isAdmin: false });
const res = await request(h.app).get('/api/ssh/admin/connections');
expect(res.status).toBe(403);
});
it('POST /api/ssh/admin/globals requires admin', async () => {
const h = makeHarness({ isAdmin: false });
const res = await request(h.app).post('/api/ssh/admin/globals').send({ reason: 'admin test' });
expect(res.status).toBe(403);
});
});
describe('SSH API: maintenance mode', () => {
it('blocks user POST /connections with 503 + Retry-After', async () => {
const h = makeHarness();
h.maintenance.enter('rotating master key');
const res = await request(h.app).post('/api/ssh/connections').send({});
expect(res.status).toBe(503);
expect(res.headers['retry-after']).toBe('30');
expect(res.body.error).toBe('rotation_in_progress');
});
it('blocks admin PATCH disable with 503', async () => {
const h = makeHarness({ isAdmin: true });
h.maintenance.enter('rotating');
const res = await request(h.app)
.patch('/api/ssh/admin/connections/anyid/disable')
.send({ reason: 'maintenance test reason' });
expect(res.status).toBe(503);
});
it('does NOT block read endpoints during maintenance', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
h.maintenance.enter('rotating');
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();
});
});
describe('SSH API: reason gating', () => {
it('admin disable rejects missing reason with 400', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app).patch(`/api/ssh/admin/connections/${id}/disable`).send({});
expect(res.status).toBe(400);
});
it('admin disable rejects reason < 8 chars', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/disable`)
.send({ reason: 'short' });
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/at least 8/);
});
it('admin grant create requires reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
pieceName: 'general',
});
expect(res.status).toBe(400);
});
});
// ──────────────────────────────────────────────────────────────────────
// User connection CRUD
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: user CRUD', () => {
it('POST /connections creates a user-owned connection', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
expect(id).toBeTruthy();
const list = await request(h.app).get('/api/ssh/connections');
expect(list.body.connections.map((c: { id: string }) => c.id)).toContain(id);
});
it('POST /connections rejects missing required fields', async () => {
const h = makeHarness();
const res = await request(h.app).post('/api/ssh/connections').send({ label: 'incomplete' });
expect(res.status).toBe(400);
});
it('POST /connections rejects allowRemoteUnrestricted (admin-only)', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/safe', allowRemoteUnrestricted: true,
});
expect(res.status).toBe(403);
expect(res.body.error).toBe('allow_remote_unrestricted_admin_only');
});
it('POST /connections rejects allowPrivateAddresses (admin-only)', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/safe', allowPrivateAddresses: true,
});
expect(res.status).toBe(403);
});
it('POST /connections rejects bad remotePathPrefix (../)', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/safe/../etc',
});
expect(res.status).toBe(400);
});
it('GET /connections returns 404 for another user\'s connection', async () => {
const h = makeHarness({ userId: 'alice' });
const id = await createOwnedConnection(h);
// Switch to bob (different harness, same DB? no — we'd need a shared DB).
// Easier test: PATCH /connections/:id as bob — fail with 403/404.
// Build a "bob" harness that reuses the underlying schema layer.
const hBob = makeHarness({ userId: 'bob' });
// Pre-create the same connection in bob's DB (different DBs, so we must replicate).
// Different approach: insert directly via repo into a single shared DB.
const idShared = await createOwnedConnection(h);
expect(idShared).toBeTruthy();
// For real isolation we'd need a shared DB; here we just assert the API
// returns 404 for an arbitrary unknown id from a different "user" harness.
const res = await request(hBob.app).get(`/api/ssh/connections/${idShared}`);
expect(res.status).toBe(404);
// Suppress unused warnings.
void id;
});
it('GET /connections/:id returns own', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).get(`/api/ssh/connections/${id}`);
expect(res.status).toBe(200);
expect(res.body.connection.id).toBe(id);
// Encrypted blob fields are stripped.
expect(res.body.connection.privateKeyEnc).toBeUndefined();
expect(res.body.connection.passphraseEnc).toBeUndefined();
// Public key is derived and returned so the user can paste it into
// authorized_keys.
expect(res.body.publicKey).toMatch(/^ssh-/);
});
it('POST /connections with keypairSource=generate returns publicKey and ignores privateKeyPem', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'gen', host: 'h', port: 22, username: 'u',
remotePathPrefix: '/safe',
keypairSource: 'generate',
generateKeyType: 'ed25519',
// Stale user-supplied key should be ignored when generating.
privateKeyPem: 'this-should-be-ignored',
});
expect(res.status).toBe(201);
expect(res.body.connection.id).toBeTruthy();
// The mock `encryptKeyMaterial` returns a fixed SAMPLE_PUBKEY regardless of
// generateKeyType — the algorithm-correctness of the generated key is
// unit-tested in crypto.test.ts. Here we only verify the API endpoint
// surfaces a public key field at all when keypairSource=generate is used.
expect(res.body.publicKey).toMatch(/^ssh-/);
});
it('POST /connections with keypairSource=generate accepts rsa-4096 keyType', async () => {
const h = makeHarness();
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'gen-rsa', host: 'h', port: 22, username: 'u',
remotePathPrefix: '/safe',
keypairSource: 'generate',
generateKeyType: 'rsa-4096',
});
expect(res.status).toBe(201);
expect(res.body.publicKey).toMatch(/^ssh-/);
});
it('PATCH /connections/:id updates own', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/connections/${id}`)
.send({ label: 'renamed' });
expect(res.status).toBe(200);
expect(res.body.connection.label).toBe('renamed');
});
it('PATCH /connections/:id rejects admin-only flag toggle', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/connections/${id}`)
.send({ allowRemoteUnrestricted: true });
expect(res.status).toBe(403);
});
it('DELETE /connections/:id deletes own', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).delete(`/api/ssh/connections/${id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────
// Host key flow
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: host key TOFU', () => {
it('POST /:id/test returns fingerprint + pending token on first_observe', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).post(`/api/ssh/connections/${id}/test`);
expect(res.status).toBe(200);
expect(res.body.verdict).toBe('first_observe');
expect(res.body.fingerprint).toMatch(/^SHA256:/);
expect(res.body.pendingToken).toBeTruthy();
});
it('POST /:id/verify-host-key consumes a valid token', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const testRes = await request(h.app).post(`/api/ssh/connections/${id}/test`);
expect(testRes.status).toBe(200);
const verifyRes = await request(h.app)
.post(`/api/ssh/connections/${id}/verify-host-key`)
.send({ fingerprint: testRes.body.fingerprint, token: testRes.body.pendingToken });
expect(verifyRes.status).toBe(200);
expect(verifyRes.body.ok).toBe(true);
expect(verifyRes.body.connection.hostKeyVerifiedAt).toBeTruthy();
});
it('POST /:id/verify-host-key rejects bad token (409)', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
await request(h.app).post(`/api/ssh/connections/${id}/test`);
const res = await request(h.app)
.post(`/api/ssh/connections/${id}/verify-host-key`)
.send({ fingerprint: 'SHA256:wrongAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', token: VALID_UUID });
expect(res.status).toBe(409);
});
it('POST /:id/replace-host-key requires reason', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
// First verify so we have a verified key, then observe a new one.
const testRes = await request(h.app).post(`/api/ssh/connections/${id}/test`);
await request(h.app)
.post(`/api/ssh/connections/${id}/verify-host-key`)
.send({ fingerprint: testRes.body.fingerprint, token: testRes.body.pendingToken });
// Observe a mismatch.
const tester = makeFakeTester('mismatch');
const h2 = makeHarness({ tester });
// (h2 has its own DB; we'll just verify the reason-required behavior on the
// replace endpoint by sending a clearly-invalid request to h's endpoint.)
void h2;
const res = await request(h.app)
.post(`/api/ssh/connections/${id}/replace-host-key`)
.send({ fingerprint: 'SHA256:newAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', token: VALID_UUID });
expect(res.status).toBe(400);
expect(String(res.body.error)).toMatch(/reason/);
});
});
// ──────────────────────────────────────────────────────────────────────
// User audit + grants
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: user audit + grants', () => {
it('GET /connections/:id/audit returns entries for owner', async () => {
const h = makeHarness();
const id = await createOwnedConnection(h);
const res = await request(h.app).get(`/api/ssh/connections/${id}/audit`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.audit)).toBe(true);
// The create itself audits an upsert row.
expect(res.body.audit.length).toBeGreaterThan(0);
expect(res.body.audit[0].action).toBe('ssh.connection.upsert');
});
it('GET /grants/visible-to-me lists user-subject grants', async () => {
const hAdmin = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(hAdmin);
// Admin creates a grant for alice → general
await request(hAdmin.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'alice',
pieceName: 'general',
reason: 'access for alice on general',
});
// alice queries her visible grants (must reuse the same DB — different
// harnesses use different DBs, so we drive alice's request via the admin
// harness app but the requireAuth/getUserId always returns admin here.
// Substitute by querying directly via the SQL helper through the admin
// grants list as a proxy.
const adminList = await request(hAdmin.app).get('/api/ssh/admin/grants');
expect(adminList.status).toBe(200);
expect(adminList.body.grants.some((g: { subjectId: string }) => g.subjectId === 'alice')).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────────────
// Admin endpoints
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: admin connections', () => {
it('GET /api/ssh/admin/connections lists all', async () => {
const h = makeHarness({ isAdmin: true });
await createOwnedConnection(h);
const res = await request(h.app).get('/api/ssh/admin/connections');
expect(res.status).toBe(200);
expect(res.body.connections.length).toBeGreaterThan(0);
});
it('PATCH /admin/connections/:id/disable disables with reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/disable`)
.send({ reason: 'disabled for security review' });
expect(res.status).toBe(200);
expect(res.body.connection.disabledByAdmin).toBe(true);
expect(res.body.connection.disabledByAdminReason).toBe('disabled for security review');
});
it('PATCH /admin/connections/:id/enable re-enables with reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/disable`)
.send({ reason: 'temporary disable' });
const res = await request(h.app)
.patch(`/api/ssh/admin/connections/${id}/enable`)
.send({ reason: 'restored after review' });
expect(res.status).toBe(200);
expect(res.body.connection.disabledByAdmin).toBe(false);
});
it('DELETE /admin/connections/:id requires reason and deletes', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.delete(`/api/ssh/admin/connections/${id}`)
.send({ reason: 'admin removal after audit' });
expect(res.status).toBe(200);
});
it('POST /admin/connections/:id/force-unlock is rate-limited', async () => {
const h = makeHarness({ isAdmin: true, forceUnlockLimit: { windowMs: 60_000, maxRequests: 2 } });
const id = await createOwnedConnection(h);
// First two calls succeed (with valid reason).
const r1 = await request(h.app).post(`/api/ssh/admin/connections/${id}/force-unlock`).send({ reason: 'unlock after probing' });
expect(r1.status).toBe(200);
const r2 = await request(h.app).post(`/api/ssh/admin/connections/${id}/force-unlock`).send({ reason: 'unlock after probing' });
expect(r2.status).toBe(200);
// Third is rate-limited.
const r3 = await request(h.app).post(`/api/ssh/admin/connections/${id}/force-unlock`).send({ reason: 'unlock after probing' });
expect(r3.status).toBe(429);
expect(r3.headers['retry-after']).toBeTruthy();
});
});
describe('SSH API: admin globals', () => {
it('POST /admin/globals creates a global connection', async () => {
const h = makeHarness({ isAdmin: true });
const res = await request(h.app)
.post('/api/ssh/admin/globals')
.send({
label: 'prod-bastion',
host: 'bastion.example.com',
port: 22,
username: 'ops',
privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/srv/ops',
reason: 'global bastion provisioned',
});
expect(res.status).toBe(201);
expect(res.body.connection.ownerId).toBeNull();
});
it('POST /admin/globals accepts allowRemoteUnrestricted with reason', async () => {
const h = makeHarness({ isAdmin: true });
const res = await request(h.app)
.post('/api/ssh/admin/globals')
.send({
label: 'unrestricted',
host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
allowRemoteUnrestricted: true,
reason: 'unrestricted required for break-fix work',
});
expect(res.status).toBe(201);
expect(res.body.connection.allowRemoteUnrestricted).toBe(true);
expect(res.body.connection.remotePathPrefix).toBe('/');
});
it('PATCH /admin/globals/:id rejects user-owned connection', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.patch(`/api/ssh/admin/globals/${id}`)
.send({ reason: 'attempt to patch user-owned via global' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('not_global');
});
});
describe('SSH API: admin grants', () => {
it('POST /admin/grants creates a grant', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
pieceName: 'general',
reason: 'bob needs general access',
});
expect(res.status).toBe(201);
expect(res.body.grant.subjectId).toBe('bob');
});
it('POST /admin/grants rejects piece_name + applies_to_all conflict', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
pieceName: 'general',
appliesToAllPieces: true,
reason: 'conflict expected',
});
expect(res.status).toBe(400);
});
it('POST /admin/grants accepts applies_to_all without piece_name', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const res = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id,
subjectType: 'user',
subjectId: 'bob',
appliesToAllPieces: true,
reason: 'bob admin-style grant',
});
expect(res.status).toBe(201);
expect(res.body.grant.appliesToAllPieces).toBe(true);
expect(res.body.grant.pieceName).toBeNull();
});
it('DELETE /admin/grants/:id removes with reason', async () => {
const h = makeHarness({ isAdmin: true });
const id = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId: id, subjectType: 'user', subjectId: 'bob',
pieceName: 'general', reason: 'grant for removal test',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke after expiry' });
expect(res.status).toBe(200);
});
it('DELETE /admin/grants/:id calls onAccessRevoked for user-subject grants (kicks active WS viewers)', async () => {
const onAccessRevoked = vi.fn().mockReturnValue(2);
const h = makeHarness({ isAdmin: true, onAccessRevoked });
const connectionId = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId, subjectType: 'user', subjectId: 'bob',
pieceName: 'general', reason: 'grant for kick-on-revoke test',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke for security' });
expect(res.status).toBe(200);
expect(onAccessRevoked).toHaveBeenCalledTimes(1);
expect(onAccessRevoked).toHaveBeenCalledWith({ connectionId, userId: 'bob' });
});
it('DELETE /admin/grants/:id does NOT call onAccessRevoked for org-subject grants (MVP deferral)', async () => {
const onAccessRevoked = vi.fn();
const h = makeHarness({ isAdmin: true, onAccessRevoked });
const connectionId = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId, subjectType: 'org', subjectId: 'team1',
pieceName: 'general', reason: 'org grant for kick deferral test',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke for security' });
expect(res.status).toBe(200);
expect(onAccessRevoked).not.toHaveBeenCalled();
});
it('DELETE /admin/grants/:id works when onAccessRevoked is undefined (console disabled)', async () => {
const h = makeHarness({ isAdmin: true }); // no onAccessRevoked
const connectionId = await createOwnedConnection(h);
const create = await request(h.app)
.post('/api/ssh/admin/grants')
.send({
connectionId, subjectType: 'user', subjectId: 'bob',
pieceName: 'general', reason: 'grant without console hook',
});
const grantId = create.body.grant.id;
const res = await request(h.app)
.delete(`/api/ssh/admin/grants/${grantId}`)
.send({ reason: 'revoke for console-disabled case' });
expect(res.status).toBe(200);
});
});
describe('SSH API: rotate-master-key stub', () => {
it('POST /admin/rotate-master-key sets maintenance + 202', async () => {
const h = makeHarness({ isAdmin: true });
const res = await request(h.app)
.post('/api/ssh/admin/rotate-master-key')
.send({ reason: 'rotate quarterly' });
expect(res.status).toBe(202);
expect(res.body.jobId).toMatch(/^rotate-/);
expect(res.body.notImplemented).toBe(true);
expect(h.maintenance.isActive()).toBe(true);
});
it('POST /admin/rotate-master-key rejects when already active', async () => {
const h = makeHarness({ isAdmin: true });
h.maintenance.enter('already rotating');
const res = await request(h.app)
.post('/api/ssh/admin/rotate-master-key')
.send({ reason: 'second attempt' });
expect(res.status).toBe(409);
});
it('GET /admin/rotate-master-key/:jobId returns progress for active job', async () => {
const h = makeHarness({ isAdmin: true });
const start = await request(h.app)
.post('/api/ssh/admin/rotate-master-key')
.send({ reason: 'rotate stub' });
const res = await request(h.app).get(`/api/ssh/admin/rotate-master-key/${start.body.jobId}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('in_progress');
});
});
describe('SSH API: admin audit query', () => {
it('GET /admin/audit returns cross-user audit rows', async () => {
const h = makeHarness({ isAdmin: true });
await createOwnedConnection(h);
const res = await request(h.app).get('/api/ssh/admin/audit?limit=10');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.audit)).toBe(true);
expect(res.body.audit.length).toBeGreaterThan(0);
});
it('GET /admin/audit filters by action', async () => {
const h = makeHarness({ isAdmin: true });
await createOwnedConnection(h);
const res = await request(h.app).get('/api/ssh/admin/audit?action=ssh.connection.upsert');
expect(res.status).toBe(200);
expect(res.body.audit.every((r: { action: string }) => r.action === 'ssh.connection.upsert')).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import type { Repository } from '../db/repository.js';
function makeRepo(overrides: Partial<Repository> = {}): Repository {
return {
getLocalTask: vi.fn(),
getLatestJobForIssue: vi.fn(),
getSubJobs: vi.fn(),
getJob: vi.fn(),
...overrides,
} as unknown as Repository;
}
const DUMMY_TASK = { id: 1, title: 'test task', workspacePath: '/tmp/workspace' };
const DUMMY_LATEST_JOB = { id: 'job-parent-1', issueNumber: 1 };
describe('Subtask Activity API', () => {
let app: express.Application;
let repo: Repository;
let tmpDirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'subtask-activity-test-'));
tmpDirs.push(dir);
return dir;
}
beforeEach(() => {
tmpDirs = [];
repo = makeRepo();
app = express();
app.use(express.json());
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
});
afterEach(() => {
for (const dir of tmpDirs) {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
// -------------------------------------------------------------------
// GET /:id/subtasks/activities
// -------------------------------------------------------------------
describe('GET /:id/subtasks/activities', () => {
it('returns subtask list with currentMovement from DB', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: null },
{ id: 'sub-2', issueNumber: 3, status: 'succeeded', currentMovement: null, worktreePath: null },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks).toHaveLength(2);
expect(res.body.subtasks[0].jobId).toBe('sub-1');
expect(res.body.subtasks[0].currentMovement).toBe('execute');
expect(res.body.subtasks[1].currentMovement).toBeNull();
});
it('includes nested subtasks when parent is waiting_subtasks', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs)
.mockResolvedValueOnce([
{ id: 'sub-1', issueNumber: 1, status: 'waiting_subtasks', currentMovement: null, worktreePath: null },
] as never)
.mockResolvedValueOnce([
{ id: 'grand-1', issueNumber: 1, status: 'running', currentMovement: 'execute', worktreePath: null },
{ id: 'grand-2', issueNumber: 2, status: 'queued', currentMovement: null, worktreePath: null },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks).toHaveLength(3); // sub-1, grand-1, grand-2
expect(res.body.subtasks.map((s: { jobId: string }) => s.jobId)).toEqual(['sub-1', 'grand-1', 'grand-2']);
});
it('returns 404 when task not found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/99/subtasks/activities');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Task not found');
});
it('returns 404 when no job found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(404);
expect(res.body.error).toBe('No job found');
});
it('returns empty activityLog when worktreePath is null', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: null },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks[0].activityLog).toBe('');
});
it('returns activity log content when file exists', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
writeFileSync(join(logsDir, 'activity.log'), 'step 1\nstep 2\n');
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: worktree },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks[0].activityLog).toBe('step 1\nstep 2\n');
});
it('truncates activity log to 4000 chars in bulk API', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
const longContent = 'x'.repeat(5000);
writeFileSync(join(logsDir, 'activity.log'), longContent);
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(repo.getSubJobs).mockResolvedValue([
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: null, worktreePath: worktree },
] as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
expect(res.body.subtasks[0].activityLog).toHaveLength(4000);
// Should be the last 4000 chars
expect(res.body.subtasks[0].activityLog).toBe('x'.repeat(4000));
});
});
// -------------------------------------------------------------------
// GET /:id/subtasks/:jobId/activity
// -------------------------------------------------------------------
describe('GET /:id/subtasks/:jobId/activity', () => {
it('returns individual activity log via getJob', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
writeFileSync(join(logsDir, 'activity.log'), 'individual log content');
vi.mocked(repo.getLocalTask).mockResolvedValue({ ...DUMMY_TASK, workspacePath: worktree } as never);
vi.mocked(repo.getJob).mockResolvedValue(
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: worktree } as never,
);
const res = await request(app).get('/api/local/tasks/1/subtasks/sub-1/activity');
expect(res.status).toBe(200);
expect(res.body.activityLog).toBe('individual log content');
});
it('returns full (non-truncated) activity log for individual endpoint', async () => {
const worktree = makeTmpDir();
const logsDir = join(worktree, 'logs');
mkdirSync(logsDir);
const longContent = 'y'.repeat(5000);
writeFileSync(join(logsDir, 'activity.log'), longContent);
vi.mocked(repo.getLocalTask).mockResolvedValue({ ...DUMMY_TASK, workspacePath: worktree } as never);
vi.mocked(repo.getJob).mockResolvedValue(
{ id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: null, worktreePath: worktree } as never,
);
const res = await request(app).get('/api/local/tasks/1/subtasks/sub-1/activity');
expect(res.status).toBe(200);
// Individual endpoint does NOT truncate (maxChars = 0)
expect(res.body.activityLog).toHaveLength(5000);
});
it('returns 404 when task not found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/99/subtasks/sub-1/activity');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Task not found');
});
it('returns 404 when subtask not found', async () => {
vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
vi.mocked(repo.getJob).mockResolvedValue(null as never);
const res = await request(app).get('/api/local/tasks/1/subtasks/nonexistent/activity');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Subtask not found');
});
});
// -------------------------------------------------------------------
// Visibility gate regression (bulk activities)
// -------------------------------------------------------------------
describe('GET /:id/subtasks/activities visibility gate', () => {
// When getLocalTask returns null (viewer does not have access),
// canViewTask rejects with 404. This simulates B3: a non-owner user
// asking for another user's private task's bulk subtask activities.
it('returns 404 when viewer cannot see the parent task', async () => {
const privateRepo = makeRepo();
const privateApp = express();
privateApp.use(express.json());
privateApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
next();
});
privateApp.use('/api/local/tasks', createSubtaskActivityRouter(privateRepo));
// getLocalTask returns null because the viewer filter matches no rows
vi.mocked(privateRepo.getLocalTask).mockResolvedValue(null as never);
const res = await request(privateApp).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Task not found');
// Should never reach getLatestJobForIssue
expect(privateRepo.getLatestJobForIssue).not.toHaveBeenCalled();
});
it('allows access when visibility=public even for non-owner', async () => {
const pubRepo = makeRepo();
const pubApp = express();
pubApp.use(express.json());
pubApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = {
id: 'bob-id', email: '[email protected]', name: 'b', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
next();
});
pubApp.use('/api/local/tasks', createSubtaskActivityRouter(pubRepo));
vi.mocked(pubRepo.getLocalTask).mockResolvedValue({
id: 1, title: 'pub', workspacePath: '/tmp/w',
ownerId: 'alice-id', visibility: 'public', visibilityScopeOrgId: null,
} as never);
vi.mocked(pubRepo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
vi.mocked(pubRepo.getSubJobs).mockResolvedValue([] as never);
const res = await request(pubApp).get('/api/local/tasks/1/subtasks/activities');
expect(res.status).toBe(200);
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { Router, Request, Response } from 'express';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { type Repository, type Job, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { canViewTask } from './local-api-helpers.js';
const MAX_ACTIVITY_LOG_CHARS = 4000;
function readActivityLog(worktreePath: string | null, maxChars: number = 0): string {
if (!worktreePath) return '';
const logPath = join(worktreePath, 'logs', 'activity.log');
if (!existsSync(logPath)) return '';
try {
const content = readFileSync(logPath, 'utf-8');
return maxChars > 0 && content.length > maxChars
? content.slice(-maxChars)
: content;
} catch {
return '';
}
}
export function createSubtaskActivityRouter(repo: Repository): Router {
const router = Router();
// GET /:id/subtasks/activities — bulk fetch all subtask activities (includes nested subtasks)
router.get('/:id/subtasks/activities', async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id);
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob) { res.status(404).json({ error: 'No job found' }); return; }
// 再帰的に全サブジョブ(孫含む)を収集
const collectAllSubJobs = async (parentId: string): Promise<Job[]> => {
const jobs = await repo.getSubJobs(parentId);
const result = [...jobs];
for (const job of jobs) {
if (job.status === 'waiting_subtasks') {
result.push(...await collectAllSubJobs(job.id));
}
}
return result;
};
const allJobs = await collectAllSubJobs(latestJob.id);
const subtasks = allJobs.map(job => ({
jobId: job.id,
issueNumber: job.issueNumber,
status: job.status,
currentMovement: job.currentMovement ?? null,
currentActivity: job.currentActivity ?? null,
activityLog: readActivityLog(job.worktreePath, MAX_ACTIVITY_LOG_CHARS),
}));
res.json({ subtasks });
} catch (err) {
logger.error(`Subtask activities API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activities' });
}
});
// GET /:id/subtasks/:jobId/activity — individual subtask activity (supports nested subtasks)
router.get('/:id/subtasks/:jobId/activity', async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id);
const jobId = req.params.jobId;
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const job = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!job || !job.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; }
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !job.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
res.json({ activityLog: readActivityLog(job.worktreePath) });
} catch (err) {
logger.error(`Subtask activity API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activity' });
}
});
return router;
}
+102
View File
@@ -0,0 +1,102 @@
import { type Application, type Request, type Response } from 'express';
import { existsSync, readdirSync, statSync } from 'fs';
import { resolve, sep } from 'path';
import { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { canViewTask } from './local-api-helpers.js';
export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
// NOTE: listing MUST be registered before the wildcard route
app.get('/api/local/tasks/:id/subtasks/:jobId/files', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.id);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const jobId = req.params.jobId;
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!subJob || !subJob.worktreePath) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const basePath = resolve(subJob.worktreePath);
const categories: Record<string, string[]> = {};
for (const dir of ['output', 'logs', 'input']) {
const dirPath = resolve(basePath, dir);
if (!existsSync(dirPath)) continue;
const dirFiles = readdirSync(dirPath, { recursive: true })
.map(f => String(f))
.filter(f => !statSync(resolve(dirPath, f)).isDirectory());
if (dirFiles.length > 0) categories[dir] = dirFiles;
}
// 後方互換: files は output/ のファイル一覧
res.json({ files: categories['output'] ?? [], categories });
} catch (err) {
logger.error(`Subtask file list API error: ${err}`);
res.status(500).json({ error: 'Failed to list subtask files' });
}
});
app.get('/api/local/tasks/:id/subtasks/:jobId/files/*', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.id);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const jobId = req.params.jobId;
const filePath = req.params[0];
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!subJob || !subJob.worktreePath) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const base = resolve(subJob.worktreePath);
const resolved = resolve(base, filePath);
// Require the trailing separator so a sibling like `<base>-x` cannot pass
// the prefix check; allow the base dir itself.
if (resolved !== base && !resolved.startsWith(base + sep)) {
res.status(403).json({ error: 'Access denied' }); return;
}
if (!existsSync(resolved)) { res.status(404).json({ error: 'File not found' }); return; }
const stat = statSync(resolved);
if (stat.isDirectory()) {
const dirFiles = readdirSync(resolved);
res.json({ files: dirFiles }); return;
}
res.sendFile(resolved);
} catch (err) {
logger.error(`Subtask files API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask file' });
}
});
}
+229
View File
@@ -0,0 +1,229 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express, { type RequestHandler } from 'express';
import request from 'supertest';
import {
mountToolsApi,
_resetToolCatalogCacheForTests,
type McpCatalogDeps,
type ToolCatalogEntry,
} from './tools-api.js';
import { setSshSubsystem, type SshSubsystem } from '../engine/tools/ssh.js';
/**
* Build an express app exposing /api/tools. `user` controls what
* (req as any).user is set to before the catalog handler runs.
*/
function makeApp(opts?: {
user?: { id?: string; role?: string } | null;
authActive?: boolean;
mcp?: McpCatalogDeps | null;
}): express.Application {
const app = express();
app.use(express.json());
if (opts?.user !== null) {
const u = opts?.user ?? { id: 'u1', role: 'user' };
app.use((req, _res, next) => {
(req as unknown as { user: typeof u }).user = u;
// supertest doesn't have passport; fake isAuthenticated.
(req as unknown as { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
}
const requireAuth: RequestHandler = (req, res, next) => {
if ((req as unknown as { user?: { id?: string } }).user?.id) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
};
mountToolsApi(app, {
authActive: opts?.authActive ?? false,
requireAuth,
mcp: opts?.mcp ?? null,
});
return app;
}
describe('GET /api/tools (runtime catalog)', () => {
beforeEach(() => {
_resetToolCatalogCacheForTests();
setSshSubsystem(null);
});
afterEach(() => {
setSshSubsystem(null);
_resetToolCatalogCacheForTests();
});
it('returns ToolCatalogResponse with builtin tools', async () => {
const res = await request(makeApp()).get('/api/tools');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tools)).toBe(true);
const names = (res.body.tools as ToolCatalogEntry[]).map((t) => t.name);
// Core builtin tools must be present.
expect(names).toContain('Read');
expect(names).toContain('Write');
expect(names).toContain('Bash');
});
it('tags core tools with source=builtin and category=core', async () => {
const res = await request(makeApp()).get('/api/tools');
const read = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'Read');
expect(read).toBeDefined();
expect(read!.source).toBe('builtin');
expect(read!.category).toBe('core');
expect(read!.scope).toBe('piece');
expect(read!.available).toBe(true);
});
it('includes meta tools tagged source=meta scope=global', async () => {
const res = await request(makeApp()).get('/api/tools');
const tools = res.body.tools as ToolCatalogEntry[];
const readDoc = tools.find((t) => t.name === 'ReadToolDoc');
expect(readDoc).toBeDefined();
expect(readDoc!.source).toBe('meta');
expect(readDoc!.scope).toBe('global');
expect(readDoc!.available).toBe(true);
const brainstorm = tools.find((t) => t.name === 'Brainstorm');
expect(brainstorm?.source).toBe('meta');
expect(brainstorm?.scope).toBe('global');
});
it('marks ssh tools available=false with reason when SSH subsystem is not initialised', async () => {
setSshSubsystem(null);
const res = await request(makeApp()).get('/api/tools');
const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec');
// SSH module may not exist in some lean builds; only assert when present.
if (ssh) {
expect(ssh.category).toBe('ssh');
expect(ssh.available).toBe(false);
expect(ssh.reason).toMatch(/SSH subsystem not initialised/);
expect(ssh.scope).toBe('piece');
}
});
it('marks ssh tools available=true when subsystem is initialised', async () => {
setSshSubsystem({} as SshSubsystem);
const res = await request(makeApp()).get('/api/tools');
const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec');
if (ssh) {
expect(ssh.available).toBe(true);
expect(ssh.reason).toBeUndefined();
}
});
it('includes MCP tools for authenticated user', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [
{ id: 'canva', name: 'Canva', enabled: true },
],
},
tokenManager: {
hasToken: () => true,
},
toolCache: {
getAllForServers: () => [
{ serverId: 'canva', toolName: 'createDesign' },
{ serverId: 'canva', toolName: 'listDesigns' },
],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const tools = res.body.tools as ToolCatalogEntry[];
const create = tools.find((t) => t.name === 'mcp__canva__createDesign');
expect(create).toBeDefined();
expect(create!.source).toBe('mcp');
expect(create!.category).toBe('mcp:canva');
expect(create!.serverId).toBe('canva');
expect(create!.scope).toBe('user');
expect(create!.available).toBe(true);
});
it('marks MCP tools unavailable with reason when user is not connected (offline)', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'gh', name: 'GitHub', enabled: true }],
},
tokenManager: {
hasToken: () => false, // user not connected
},
toolCache: {
getAllForServers: () => [{ serverId: 'gh', toolName: 'listIssues' }],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const tool = (res.body.tools as ToolCatalogEntry[]).find(
(t) => t.name === 'mcp__gh__listIssues',
);
expect(tool).toBeDefined();
expect(tool!.available).toBe(false);
expect(tool!.reason).toMatch(/offline/);
});
it('omits MCP tools when caller has no user id (unauthenticated)', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }],
},
tokenManager: { hasToken: () => true },
toolCache: {
getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }],
},
};
const res = await request(makeApp({ user: null, mcp })).get('/api/tools');
expect(res.status).toBe(200);
const tools = res.body.tools as ToolCatalogEntry[];
expect(tools.some((t) => t.source === 'mcp')).toBe(false);
});
it('returns 401 when authActive=true and caller is not authenticated', async () => {
const res = await request(makeApp({ user: null, authActive: true })).get('/api/tools');
expect(res.status).toBe(401);
});
it('?legacy=1 returns flat array of tool names', async () => {
const res = await request(makeApp()).get('/api/tools?legacy=1');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tools)).toBe(true);
// Must be string[], NOT objects.
for (const t of res.body.tools as unknown[]) {
expect(typeof t).toBe('string');
}
expect(res.body.tools).toContain('Read');
expect(res.body.tools).toContain('ReadToolDoc');
});
it('?legacy=1 includes per-user MCP names when authenticated', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }],
},
tokenManager: { hasToken: () => true },
toolCache: {
getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools?legacy=1');
expect(res.body.tools).toContain('mcp__canva__createDesign');
});
it('surfaces a placeholder entry when MCP server has no cached tools', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'fresh', name: 'Fresh', enabled: true }],
},
tokenManager: { hasToken: () => false },
toolCache: { getAllForServers: () => [] },
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const placeholder = (res.body.tools as ToolCatalogEntry[]).find(
(t) => t.serverId === 'fresh',
);
expect(placeholder).toBeDefined();
expect(placeholder!.available).toBe(false);
expect(placeholder!.reason).toMatch(/offline|no cached tools/);
expect(placeholder!.scope).toBe('user');
});
});
+311
View File
@@ -0,0 +1,311 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { ToolDef } from '../llm/openai-compat.js';
import { getSshSubsystem } from '../engine/tools/ssh.js';
/**
* Tool catalog entry exposed by GET /api/tools.
*
* The catalog is built at request-time from the same module set the agent loop
* uses, plus per-caller MCP context. UI consumers (Piece allowed_tools editor)
* should rely on this rather than the previous hand-maintained static list.
*
* See docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md
* step 4 for the design rationale.
*/
export interface ToolCatalogEntry {
name: string;
source: 'builtin' | 'meta' | 'mcp';
/**
* Coarse grouping for UI. Values are stable strings derived from the source
* module file name (e.g. 'core', 'web', 'office'). MCP tools use
* `mcp:<serverId>` so the UI can group them by server.
*/
category: string;
/** MCP server id (source: 'mcp' only). */
serverId?: string;
/** Whether the tool can be invoked at this moment. */
available: boolean;
/** Human-readable explanation when available=false. */
reason?: string;
/**
* Where the tool is "switched on":
* - 'global' → always injected (meta tools like ReadToolDoc)
* - 'piece' → must appear in a piece's `allowed_tools`
* - 'user' → per-user resource (MCP)
*/
scope: 'global' | 'piece' | 'user';
}
export interface ToolCatalogResponse {
tools: ToolCatalogEntry[];
}
/**
* Meta tools are auto-injected by `agent-loop.buildSystemPrompt()` regardless of
* a piece's `allowed_tools`. Keep this list in sync with `META_TOOLS` in
* `src/engine/tools/index.ts`.
*/
const META_TOOLS = new Set<string>([
'ReadToolDoc',
'CreateChecklist',
'CheckItem',
'GetChecklist',
'MissionUpdate',
'ListUserAssets',
'RunUserScript',
'UpdateUserMemory',
'ReadUserMemory',
'ReadUserTemplate',
'RenderUserTemplate',
'WriteUserScript',
'WriteUserTemplate',
'Brainstorm',
'ReadAppDoc',
'ListAppDocs',
'GetMyOrchestratorState',
'ReadSkill',
'ListSkills',
'InstallSkill',
]);
/**
* Modules to load. The key becomes the `category` field for builtin tools.
* `core` is loaded separately because its export name is `ALL_TOOL_DEFS`.
*
* Categories that map to "user-scoped" assets (per-user MCP servers, SSH) get
* scope='user' below — see categoryScope().
*/
const MODULE_SPECS: Array<{ category: string; specifier: string }> = [
{ category: 'web', specifier: '../engine/tools/web.js' },
{ category: 'image', specifier: '../engine/tools/image.js' },
{ category: 'data', specifier: '../engine/tools/data.js' },
{ category: 'office', specifier: '../engine/tools/office.js' },
{ category: 'review', specifier: '../engine/tools/review.js' },
{ category: 'x', specifier: '../engine/tools/x.js' },
{ category: 'orchestration', specifier: '../engine/tools/orchestration.js' },
{ category: 'browser', specifier: '../engine/tools/browser.js' },
{ category: 'maps', specifier: '../engine/tools/maps.js' },
{ category: 'youtube', specifier: '../engine/tools/youtube.js' },
{ category: 'pieces', specifier: '../engine/tools/pieces.js' },
{ category: 'amazon', specifier: '../engine/tools/amazon.js' },
{ category: 'speech', specifier: '../engine/tools/speech.js' },
{ category: 'checklist', specifier: '../engine/tools/checklist.js' },
{ category: 'knowledge', specifier: '../engine/tools/knowledge.js' },
{ category: 'ms-learn', specifier: '../engine/tools/ms-learn.js' },
{ category: 'slide', specifier: '../engine/tools/slide.js' },
{ category: 'docs', specifier: '../engine/tools/docs.js' },
{ category: 'mission', specifier: '../engine/tools/mission.js' },
{ category: 'user-folder', specifier: '../engine/tools/user-folder.js' },
{ category: 'brainstorm', specifier: '../engine/tools/brainstorm.js' },
{ category: 'app-docs', specifier: '../engine/tools/app-docs.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh-console.js' },
{ category: 'notes', specifier: '../engine/tools/notes.js' },
{ category: 'dashboard', specifier: '../engine/tools/dashboard.js' },
{ category: 'skills', specifier: '../engine/tools/skills.js' },
];
interface ToolModule {
TOOL_DEFS?: Record<string, ToolDef>;
ALL_TOOL_DEFS?: Record<string, ToolDef>;
}
/**
* Deps the catalog needs to enumerate per-user MCP tools. Optional — if
* absent, MCP tools are omitted entirely (e.g. when MCP_ENCRYPTION_KEY is
* not configured and the aggregator was never set up).
*/
export interface McpCatalogDeps {
registry: {
listEnabledForUser(userId: string): Array<{ id: string; name: string; enabled: boolean }>;
};
tokenManager: {
hasToken(userId: string, serverId: string): boolean;
};
toolCache: {
getAllForServers(serverIds: string[]): Array<{ serverId: string; toolName: string }>;
};
}
export interface MountToolsApiOptions {
/** When true, /api/tools is gated behind requireAuth. */
authActive?: boolean;
/** requireAuth middleware (only consulted when authActive is true). */
requireAuth?: RequestHandler;
/** Subsystems used to enumerate per-user MCP tools. */
mcp?: McpCatalogDeps | null;
}
// ──────────────────────────────────────────────────────────────────────
// Module / category caches
// ──────────────────────────────────────────────────────────────────────
let _cachedBuiltinEntries: Array<Omit<ToolCatalogEntry, 'available' | 'reason'>> | null = null;
async function loadBuiltinEntries(): Promise<Array<Omit<ToolCatalogEntry, 'available' | 'reason'>>> {
if (_cachedBuiltinEntries) return _cachedBuiltinEntries;
// name → category info. First-write-wins so a later module specifying the
// same tool name keeps the original category — matches runtime tools/index.ts.
const seen = new Map<string, { category: string; source: 'builtin' | 'meta' }>();
// Core tools (Read/Write/Edit/Bash/Glob/Grep) — always categorised 'core'.
try {
const coreMod = (await import('../engine/tools/core.js')) as ToolModule;
const defs = coreMod.ALL_TOOL_DEFS ?? {};
for (const name of Object.keys(defs)) {
if (!seen.has(name)) {
seen.set(name, { category: 'core', source: META_TOOLS.has(name) ? 'meta' : 'builtin' });
}
}
} catch {
// core should always load; if not, we have bigger problems
}
for (const { category, specifier } of MODULE_SPECS) {
try {
const mod = (await import(specifier)) as ToolModule;
const defs = mod.TOOL_DEFS ?? {};
for (const name of Object.keys(defs)) {
if (!seen.has(name)) {
seen.set(name, {
category,
source: META_TOOLS.has(name) ? 'meta' : 'builtin',
});
}
}
} catch {
// module not available — skip
}
}
const entries: Array<Omit<ToolCatalogEntry, 'available' | 'reason'>> = [];
for (const [name, info] of seen) {
entries.push({
name,
source: info.source,
category: info.category,
scope: info.source === 'meta' ? 'global' : 'piece',
});
}
entries.sort((a, b) => a.name.localeCompare(b.name));
_cachedBuiltinEntries = entries;
return entries;
}
/** Test-only: reset the module-load cache. */
export function _resetToolCatalogCacheForTests(): void {
_cachedBuiltinEntries = null;
}
// ──────────────────────────────────────────────────────────────────────
// SSH availability
// ──────────────────────────────────────────────────────────────────────
function annotateSshAvailability(entry: Omit<ToolCatalogEntry, 'available' | 'reason'>): ToolCatalogEntry {
if (entry.category !== 'ssh') {
return { ...entry, available: true };
}
const sub = getSshSubsystem();
if (sub) {
return { ...entry, available: true };
}
return {
...entry,
available: false,
reason: 'SSH subsystem not initialised',
};
}
// ──────────────────────────────────────────────────────────────────────
// MCP catalog
// ──────────────────────────────────────────────────────────────────────
function buildMcpEntries(userId: string, mcp: McpCatalogDeps): ToolCatalogEntry[] {
const servers = mcp.registry.listEnabledForUser(userId);
if (servers.length === 0) return [];
const cache = mcp.toolCache.getAllForServers(servers.map((s) => s.id));
const entries: ToolCatalogEntry[] = [];
for (const server of servers) {
const connected = mcp.tokenManager.hasToken(userId, server.id);
const serverTools = cache.filter((t) => t.serverId === server.id);
if (serverTools.length === 0) {
// No cached tools yet — surface the server as a single placeholder so
// the UI can still show it (e.g. "<server> — not yet connected").
entries.push({
name: `mcp__${server.id}__`,
source: 'mcp',
category: `mcp:${server.id}`,
serverId: server.id,
available: false,
reason: connected
? `mcp server ${server.name} has no cached tools`
: `mcp server ${server.name} offline`,
scope: 'user',
});
continue;
}
for (const t of serverTools) {
entries.push({
name: `mcp__${server.id}__${t.toolName}`,
source: 'mcp',
category: `mcp:${server.id}`,
serverId: server.id,
available: connected,
reason: connected ? undefined : `mcp server ${server.name} offline`,
scope: 'user',
});
}
}
entries.sort((a, b) => a.name.localeCompare(b.name));
return entries;
}
// ──────────────────────────────────────────────────────────────────────
// Handler
// ──────────────────────────────────────────────────────────────────────
export function mountToolsApi(app: Application, options: MountToolsApiOptions = {}): void {
const handler = async (req: Request, res: Response): Promise<void> => {
const builtinBase = await loadBuiltinEntries();
const builtin = builtinBase.map(annotateSshAvailability);
// MCP entries require an authenticated caller. When auth is disabled the
// request still carries no user; we treat that case as "no MCP catalog"
// because MCP is inherently per-user.
const user = (req.user as { id?: string } | undefined) ?? null;
let mcpEntries: ToolCatalogEntry[] = [];
if (options.mcp && user?.id) {
try {
mcpEntries = buildMcpEntries(user.id, options.mcp);
} catch {
// Defensive: never let MCP enumeration crash the whole catalog.
mcpEntries = [];
}
}
const all: ToolCatalogEntry[] = [...builtin, ...mcpEntries];
// Legacy shape: flat array of names. Maintained so the existing
// ui/src/api.ts fetchTools() (and any external consumer treating
// response.tools as string[]) keeps working until step 5.
if (req.query.legacy === '1') {
res.json({ tools: all.map((t) => t.name) });
return;
}
const payload: ToolCatalogResponse = { tools: all };
res.json(payload);
};
const guards: RequestHandler[] = [];
if (options.authActive && options.requireAuth) {
guards.push(options.requireAuth);
}
app.get('/api/tools', ...guards, handler);
}
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createUserFolderApi } from './user-folder-api.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeApp(userId: string, userFolderRoot: string): express.Application {
const app = express();
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' };
next();
});
app.use('/api/users/me', createUserFolderApi({ userFolderRoot }));
return app;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('User Folder API — AGENTS.md routes', () => {
let tmpRoot: string;
let app: express.Application;
const USER_A = 'user-a';
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'agents-md-test-'));
app = makeApp(USER_A, tmpRoot);
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
// ── GET /agents-md ────────────────────────────────────────────────────────
it('GET /agents-md returns exists=false when file is missing', async () => {
const res = await request(app).get('/api/users/me/agents-md');
expect(res.status).toBe(200);
expect(res.body).toEqual({ exists: false, content: '' });
});
// ── PUT /agents-md ────────────────────────────────────────────────────────
it('PUT /agents-md writes content; subsequent GET reflects it', async () => {
const text = '# My Instructions\n\nAlways be concise.\n';
const putRes = await request(app)
.put('/api/users/me/agents-md')
.set('Content-Type', 'text/plain')
.send(text);
expect(putRes.status).toBe(200);
expect(putRes.body.ok).toBe(true);
expect(typeof putRes.body.bytes).toBe('number');
const getRes = await request(app).get('/api/users/me/agents-md');
expect(getRes.status).toBe(200);
expect(getRes.body.exists).toBe(true);
expect(getRes.body.content).toBe(text);
});
it('PUT /agents-md with oversized body returns 413', async () => {
// 64 KB + 1 byte — exceeds USER_AGENTS_MAX_BYTES
const oversized = 'x'.repeat(64 * 1024 + 1);
const res = await request(app)
.put('/api/users/me/agents-md')
.set('Content-Type', 'text/plain')
.send(oversized);
expect(res.status).toBe(413);
expect(res.body.error).toMatch(/exceeds/);
});
// ── DELETE /agents-md ─────────────────────────────────────────────────────
it('DELETE /agents-md removes the file', async () => {
// First write something
await request(app)
.put('/api/users/me/agents-md')
.set('Content-Type', 'text/plain')
.send('# Hello\n');
// Confirm it exists
const beforeGet = await request(app).get('/api/users/me/agents-md');
expect(beforeGet.body.exists).toBe(true);
// Delete it
const delRes = await request(app).delete('/api/users/me/agents-md');
expect(delRes.status).toBe(200);
expect(delRes.body.ok).toBe(true);
expect(delRes.body.existed).toBe(true);
// Confirm it's gone
const afterGet = await request(app).get('/api/users/me/agents-md');
expect(afterGet.body.exists).toBe(false);
});
it('DELETE /agents-md when file does not exist returns ok=true, existed=false', async () => {
const res = await request(app).delete('/api/users/me/agents-md');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.existed).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+992
View File
@@ -0,0 +1,992 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import express from 'express';
import { existsSync, readFileSync, writeFileSync, statSync, readdirSync, renameSync, mkdirSync, unlinkSync } from 'fs';
import { join, dirname, basename } from 'path';
import {
USER_SUBDIRS,
type UserSubdir,
ensureUserFolder,
resolveUserSubdir,
userRoot,
readUserAgentsMd,
writeUserAgentsMd,
deleteUserAgentsMd,
} from '../user-folder/paths.js';
import { logger } from '../logger.js';
import { compileScript } from '../user-folder/script-compiler.js';
import { parseScript, serializeScript } from '../user-folder/frontmatter.js';
import { runUserScript } from '../user-folder/script-runner.js';
import type { RecordedAction } from '../engine/browser-recorder.js';
import { recorder } from '../engine/browser-recorder.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { loadSessionStateForUser } from '../user-folder/session-loader.js';
import {
deletePet,
getPet,
importPetZip,
listPets,
PetConflictError,
PetValidationError,
readPetSettings,
resolvePetAsset,
slugifyPetId,
writePetSettings,
} from '../user-folder/pets.js';
import type { NotesService } from '../notes/notes-service.js';
interface Deps {
userFolderRoot: string;
sessRepo?: BrowserSessionRepo;
masterKeyPath?: string;
authActive?: boolean; // default true; when false, fall back to synthetic 'local' user
notesService?: NotesService;
}
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
const MAX_FILE_BYTES = 1024 * 1024; // 1 MB
function isUserSubdir(s: string): s is UserSubdir {
return (USER_SUBDIRS as readonly string[]).includes(s);
}
// Subdirs that users may write to / delete from. 'trash' is system-managed.
// 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers
// then delegate immediately to NotesService rather than the generic file writer.
const WRITABLE_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'notes'] as const;
type WritableSubdir = typeof WRITABLE_SUBDIRS[number];
function isWritableSubdir(s: string): s is WritableSubdir {
return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
}
/**
* Write file atomically via tmp + rename.
* The tmp file is created in the same directory as the target to ensure
* rename is an atomic single-filesystem move.
*/
function writeAtomic(path: string, content: string): void {
const dir = dirname(path);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
let renamed = false;
try {
writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
renameSync(tmp, path);
renamed = true;
} finally {
if (!renamed) {
try { unlinkSync(tmp); } catch { /* tmp may not exist if writeFileSync threw */ }
}
}
}
/**
* Format a Date as YYYYMMDD-HHMMSS in UTC (used for trash prefix).
*/
function utcTimestamp(d: Date): string {
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
export function createUserFolderApi(deps: Deps): Router {
const { userFolderRoot } = deps;
const r = Router();
// ── Auth gate ────────────────────────────────────────────────────────────
const authActive = deps.authActive ?? true;
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
// Local-dev / no-auth mode: inject a synthetic 'local' user so handlers
// can operate against data/users/local/. Real OAuth deployments are
// unaffected because authActive=true and Passport populates req.user.
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// ── Pets: Codex Pets-compatible user imports ────────────────────────────
r.get('/pets', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
res.json({
pets: listPets(userFolderRoot, u.id),
settings: readPetSettings(userFolderRoot, u.id),
});
} catch (err) {
logger.error(`[user-folder-api] pets list failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list pets' });
}
});
r.post('/pets/import', express.raw({ limit: '12mb', type: '*/*' }), (req: Request, res: Response) => {
const u = getUser(req)!;
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const rawPetId = typeof req.query['petId'] === 'string'
? req.query['petId']
: typeof req.query['filename'] === 'string'
? req.query['filename']
: null;
const overwrite = req.query['overwrite'] === 'true';
try {
const detail = importPetZip(userFolderRoot, u.id, body, {
preferredId: rawPetId ? slugifyPetId(rawPetId) : null,
overwrite,
});
res.json({ ok: true, pet: detail });
} catch (err) {
if (err instanceof PetConflictError) {
res.status(409).json({ error: err.message, petId: err.petId });
return;
}
if (err instanceof PetValidationError || err instanceof SyntaxError) {
res.status(400).json({ error: (err as Error).message });
return;
}
logger.error(`[user-folder-api] pet import failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to import pet' });
}
});
r.get('/pets/settings', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
res.json({ settings: readPetSettings(userFolderRoot, u.id) });
} catch (err) {
logger.error(`[user-folder-api] pet settings read failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to read pet settings' });
}
});
r.put('/pets/settings', express.json({ limit: '32kb' }), (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const settings = writePetSettings(userFolderRoot, u.id, req.body);
res.json({ ok: true, settings });
} catch (err) {
if (err instanceof PetValidationError) {
res.status(400).json({ error: err.message });
return;
}
logger.error(`[user-folder-api] pet settings write failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to write pet settings' });
}
});
r.get('/pets/:petId/assets/:file', (req: Request, res: Response) => {
const u = getUser(req)!;
const asset = resolvePetAsset(userFolderRoot, u.id, req.params.petId, req.params.file);
if (!asset) {
res.status(404).json({ error: 'Asset not found' });
return;
}
res.setHeader('Content-Type', asset.contentType);
res.sendFile(asset.path);
});
r.get('/pets/:petId', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const pet = getPet(userFolderRoot, u.id, req.params.petId);
if (!pet) {
res.status(404).json({ error: 'Pet not found' });
return;
}
res.json({ pet });
} catch (err) {
if (err instanceof PetValidationError) {
res.status(400).json({ error: err.message });
return;
}
logger.error(`[user-folder-api] pet read failed user=${u.id} pet=${req.params.petId} err=${err}`);
res.status(500).json({ error: 'Failed to read pet' });
}
});
r.delete('/pets/:petId', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const deleted = deletePet(userFolderRoot, u.id, req.params.petId);
if (!deleted) {
res.status(404).json({ error: 'Pet not found' });
return;
}
res.json({ ok: true });
} catch (err) {
logger.error(`[user-folder-api] pet delete failed user=${u.id} pet=${req.params.petId} err=${err}`);
res.status(500).json({ error: 'Failed to delete pet' });
}
});
// ── GET /folder/list?subdir=scripts ──────────────────────────────────────
r.get('/folder/list', (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
if (!subdir || !isUserSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
const dirPath = join(userRoot(userFolderRoot, u.id), subdir);
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
const files = entries
.filter(e => e.isFile() && !e.name.startsWith('.'))
.map(e => {
const stat = statSync(join(dirPath, e.name));
return {
name: e.name,
size: stat.size,
mtime: stat.mtime.toISOString(),
};
});
res.json({ files });
} catch (err) {
logger.error(`[user-folder-api] list failed user=${u.id} subdir=${subdir} err=${err}`);
res.status(500).json({ error: 'Failed to list folder' });
}
});
// ── GET /folder/file?subdir=scripts&path=foo.js ──────────────────────────
r.get('/folder/file', (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
const relPath = req.query['path'] as string | undefined;
if (!subdir || !isUserSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
return;
}
if (!relPath) {
res.status(400).json({ error: 'path query parameter is required' });
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let fullPath: string;
try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
} catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return;
}
if (!existsSync(fullPath)) {
res.status(404).json({ error: 'File not found' });
return;
}
let stat: ReturnType<typeof statSync>;
try {
stat = statSync(fullPath);
} catch {
res.status(404).json({ error: 'File not found' });
return;
}
if (!stat.isFile()) {
res.status(404).json({ error: 'Not a file' });
return;
}
if (stat.size > MAX_FILE_BYTES) {
res.status(413).json({ error: 'File exceeds 1 MB limit' });
return;
}
try {
const content = readFileSync(fullPath, 'utf-8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(content);
} catch (err) {
logger.error(`[user-folder-api] read failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
// ── PUT /folder/file?subdir=scripts&path=foo.js ──────────────────────────
r.put('/folder/file', express.text({ limit: '1mb', type: '*/*' }), async (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
const relPath = req.query['path'] as string | undefined;
if (!subdir || !isWritableSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
return;
}
if (!relPath) {
res.status(400).json({ error: 'path query parameter is required' });
return;
}
// ── notes subdir: delegate entirely to NotesService ──────────────────
if (subdir === 'notes') {
if (!deps.notesService) {
res.status(500).json({ error: 'notesService is not configured; cannot write notes' });
return;
}
// Validate path: must be exactly 2 segments (<folder>/<file.md>)
const segments = relPath.split('/').filter(s => s.length > 0);
if (segments.length !== 2) {
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
return;
}
const [folder, fileName] = segments as [string, string];
const content = typeof req.body === 'string' ? req.body : '';
try {
deps.notesService.writeNote({ ownerId: u.id, folder, fileName, content });
res.json({ ok: true, indexed: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.error(`[user-folder-api] notes write failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to write note' });
}
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let fullPath: string;
try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
} catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return;
}
const content = typeof req.body === 'string' ? req.body : '';
try {
writeAtomic(fullPath, content);
const stat = statSync(fullPath);
res.json({ ok: true, size: stat.size, mtime: stat.mtime.toISOString() });
} catch (err) {
logger.error(`[user-folder-api] write failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to write file' });
}
});
// ── DELETE /folder/file?subdir=scripts&path=foo.js ───────────────────────
r.delete('/folder/file', async (req: Request, res: Response) => {
const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined;
const relPath = req.query['path'] as string | undefined;
if (!subdir || !isWritableSubdir(subdir)) {
res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
return;
}
if (!relPath) {
res.status(400).json({ error: 'path query parameter is required' });
return;
}
// ── notes subdir: delegate entirely to NotesService ──────────────────
if (subdir === 'notes') {
if (!deps.notesService) {
res.status(500).json({ error: 'notesService is not configured; cannot delete notes' });
return;
}
const segments = relPath.split('/').filter(s => s.length > 0);
if (segments.length !== 2) {
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
return;
}
const [folder, fileName] = segments as [string, string];
try {
deps.notesService.deleteNote({ ownerId: u.id, folder, fileName });
res.json({ ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.error(`[user-folder-api] notes delete failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to delete note' });
}
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let fullPath: string;
try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
} catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return;
}
if (!existsSync(fullPath)) {
res.status(404).json({ error: 'File not found' });
return;
}
// Extract the base filename for the trash name
const originalName = relPath.split('/').pop()!;
const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6);
const trashedAs = `${ts}-${suffix}-${originalName}`;
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
const trashPath = join(trashDir, trashedAs);
try {
renameSync(fullPath, trashPath);
res.json({ ok: true, trashedAs });
} catch (err) {
logger.error(`[user-folder-api] delete/trash failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to move file to trash' });
}
});
// ── POST /browser-macros/compile ──────────────────────────────────────────────
r.post('/browser-macros/compile', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
const u = getUser(req)!;
const {
recordingName,
scriptName,
description,
sessionProfileId,
paramHints,
} = req.body as {
recordingName?: unknown;
scriptName?: unknown;
description?: unknown;
sessionProfileId?: unknown;
paramHints?: unknown;
};
if (typeof recordingName !== 'string' || !recordingName.trim()) {
res.status(400).json({ error: 'recordingName is required' });
return;
}
if (typeof scriptName !== 'string' || !scriptName.trim()) {
res.status(400).json({ error: 'scriptName is required' });
return;
}
if (typeof description !== 'string') {
res.status(400).json({ error: 'description is required' });
return;
}
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
// Resolve recording path
let recordingPath: string;
try {
recordingPath = resolveUserSubdir(userFolderRoot, u.id, 'recordings', `${recordingName}.json`);
} catch {
res.status(400).json({ error: 'Invalid recordingName' });
return;
}
if (!existsSync(recordingPath)) {
res.status(404).json({ error: `Recording not found: ${recordingName}.json` });
return;
}
// Parse recording JSON
let recording: { recordTo?: unknown; capturedAt?: unknown; actions?: unknown };
try {
recording = JSON.parse(readFileSync(recordingPath, 'utf-8')) as typeof recording;
} catch {
res.status(400).json({ error: 'Recording is not valid JSON' });
return;
}
// Validate shape
if (!recording || typeof recording !== 'object' || !Array.isArray(recording.actions)) {
res.status(400).json({ error: 'Recording is missing required fields (expected { recordTo, capturedAt, actions })' });
return;
}
// Conflict policy: check if script already exists
const scriptFileName = scriptName.endsWith('.js') ? scriptName : `${scriptName}.js`;
let scriptPath: string;
try {
scriptPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
} catch {
res.status(400).json({ error: 'Invalid scriptName' });
return;
}
if (existsSync(scriptPath) && req.query['overwrite'] !== 'true') {
res.status(409).json({ error: 'Script already exists; pass ?overwrite=true to replace' });
return;
}
// Validate paramHints shape
if (paramHints !== undefined) {
if (!Array.isArray(paramHints)) {
res.status(400).json({ error: 'paramHints must be an array' });
return;
}
for (let i = 0; i < paramHints.length; i++) {
const hint = paramHints[i];
if (!hint || typeof hint !== 'object' ||
typeof hint.name !== 'string' || !hint.name ||
typeof hint.valueToReplace !== 'string' ||
!['string', 'number', 'boolean'].includes((hint as any).type)) {
res.status(400).json({
error: `paramHints[${i}] must be { name: string, valueToReplace: string, type: 'string' | 'number' | 'boolean' }`,
});
return;
}
}
}
// Compile
let compiled: ReturnType<typeof compileScript>;
try {
compiled = compileScript({
recording: recording.actions as RecordedAction[],
description,
sessionProfileId: typeof sessionProfileId === 'number' ? sessionProfileId : undefined,
paramHints: Array.isArray(paramHints)
? (paramHints as { name: string; valueToReplace: string; type: 'string' | 'number' | 'boolean' }[])
: undefined,
recordingSource: `${recordingName}.json`,
});
} catch (err) {
logger.error(`[user-folder-api] compile failed user=${u.id} recording=${recordingName} err=${err}`);
res.status(500).json({ error: `Compile failed: ${(err as Error).message}` });
return;
}
// Stamp timestamps and re-serialize
const now = new Date().toISOString();
const meta = { ...compiled.meta, createdAt: now, updatedAt: now };
const { body } = parseScript(compiled.source);
const source = serializeScript({ frontmatter: meta, body });
// Write atomically
try {
writeAtomic(scriptPath, source);
} catch (err) {
logger.error(`[user-folder-api] write failed user=${u.id} script=${scriptName} err=${err}`);
res.status(500).json({ error: 'Failed to write script file' });
return;
}
const size = statSync(scriptPath).size;
res.json({ ok: true, scriptName: scriptFileName, source, size });
});
// ── POST /scripts/:name/run ───────────────────────────────────────────────────
// body.kind: 'script' | 'browser-macro' — determines which subdir to load from.
// If omitted, both are tried (scripts/ first, then browser-macros/).
r.post('/scripts/:name/run', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
const scriptFileName = rawName.endsWith('.js') ? rawName : `${rawName}.js`;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
const { params, timeoutMs, kind } = ((req.body as Record<string, unknown>) ?? {}) as {
params?: Record<string, unknown>;
timeoutMs?: number;
kind?: string;
};
// Resolve script path depending on kind
let scriptPath: string | null = null;
let resolvedRuntime: 'plain' | 'playwright' = 'plain';
if (!kind || kind === 'script') {
try {
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'scripts', scriptFileName);
if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'plain'; }
} catch { /* invalid path */ }
}
if (!scriptPath && (!kind || kind === 'browser-macro')) {
try {
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'playwright'; }
} catch { /* invalid path */ }
}
if (!scriptPath && kind === 'script') {
// explicit kind but no match — keep null to hit 404 below
}
if (scriptPath === null) {
res.status(404).json({ error: `Script not found: ${scriptFileName}` });
return;
}
// Clamp timeoutMs to 5 minutes max (prevent malicious long-running requests)
const requestedTimeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 60_000;
const cappedTimeout = Math.min(requestedTimeout, 300_000);
// Load session storageState if it's a playwright-runtime script with sessionProfileId
let storageState: object | undefined;
if (resolvedRuntime === 'playwright') {
try {
const source = readFileSync(scriptPath, 'utf-8');
const parsed = parseScript(source);
const sessionProfileId = parsed.frontmatter.sessionProfileId;
if (sessionProfileId !== undefined) {
if (!deps.sessRepo || !deps.masterKeyPath) {
res.status(500).json({
error: 'Session profile required but session repository is not configured',
});
return;
}
const sessionResult = await loadSessionStateForUser(
{ sessRepo: deps.sessRepo, masterKeyPath: deps.masterKeyPath },
u.id,
sessionProfileId,
);
if (!sessionResult.ok) {
res.status(500).json({ error: sessionResult.error.message });
return;
}
storageState = sessionResult.storageState;
}
} catch (err) {
res.status(500).json({ error: `Failed to parse script: ${(err as Error).message}` });
return;
}
}
// Run
const startMs = Date.now();
try {
const scriptResult = await runUserScript({
scriptPath,
params: params ?? {},
runtime: resolvedRuntime,
storageState,
timeoutMs: cappedTimeout,
});
const durationMs = Date.now() - startMs;
res.json({ result: scriptResult.result, logs: scriptResult.logs, durationMs });
} catch (err) {
const durationMs = Date.now() - startMs;
const message = (err as Error).message;
res.status(500).json({ error: message, durationMs });
}
});
// ── GET /browser-macros/:name/diff ───────────────────────────────────────────
// Returns { current: string|null, candidate: string, candidateMtime: string }
// or 404 if no .next.js exists.
r.get('/browser-macros/:name/diff', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
// Normalize: strip any trailing .js suffix to get the bare name
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let candidatePath: string;
let currentPath: string;
try {
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
} catch {
res.status(400).json({ error: 'Invalid script name' });
return;
}
if (!existsSync(candidatePath)) {
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
return;
}
let candidate: string;
let candidateMtime: string;
try {
candidate = readFileSync(candidatePath, 'utf-8');
candidateMtime = statSync(candidatePath).mtime.toISOString();
} catch (err) {
logger.error(`[user-folder-api] diff read candidate failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to read candidate file' });
return;
}
// current may not exist (orphaned .next.js)
let current: string | null = null;
if (existsSync(currentPath)) {
try {
current = readFileSync(currentPath, 'utf-8');
} catch (err) {
logger.error(`[user-folder-api] diff read current failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to read current file' });
return;
}
}
res.json({ current, candidate, candidateMtime });
});
// ── POST /browser-macros/:name/accept ────────────────────────────────────────
// Atomically archives browser-macros/{name}.js to trash, then renames .next.js into place.
// NOTE: Not fully atomic — a crash between step 1 and step 2 would leave no
// browser-macros/{name}.js. Acceptable given the complexity of a copy-then-rename
// alternative. The .next.js is always preserved or moved to trash.
r.post('/browser-macros/:name/accept', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let candidatePath: string;
let currentPath: string;
try {
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
} catch {
res.status(400).json({ error: 'Invalid script name' });
return;
}
if (!existsSync(candidatePath)) {
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
return;
}
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6);
let archivedAs: string | null = null;
try {
// Step 1: Archive the existing script to trash (if it exists)
if (existsSync(currentPath)) {
archivedAs = `${ts}-${suffix}-${baseName}.js`;
const trashPath = join(trashDir, archivedAs);
renameSync(currentPath, trashPath);
}
// Step 2: Rename .next.js into the canonical script location
renameSync(candidatePath, currentPath);
} catch (err) {
logger.error(`[user-folder-api] accept failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to accept patch' });
return;
}
logger.info(`[user-folder-api] accept user=${u.id} name=${baseName} archivedAs=${archivedAs ?? 'none'}`);
res.json({ ok: true, accepted: `${baseName}.js`, archivedAs });
});
// ── POST /browser-macros/:name/reject ────────────────────────────────────────
// Moves browser-macros/{name}.next.js to trash; the original .js is untouched.
r.post('/browser-macros/:name/reject', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawName = req.params['name'] ?? '';
const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;
try {
ensureUserFolder(userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' });
return;
}
let candidatePath: string;
try {
candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
} catch {
res.status(400).json({ error: 'Invalid script name' });
return;
}
if (!existsSync(candidatePath)) {
res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
return;
}
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6);
const trashedAs = `${ts}-${suffix}-${baseName}.next.js`;
const trashPath = join(trashDir, trashedAs);
try {
renameSync(candidatePath, trashPath);
} catch (err) {
logger.error(`[user-folder-api] reject failed user=${u.id} name=${baseName} err=${err}`);
res.status(500).json({ error: 'Failed to reject patch' });
return;
}
logger.info(`[user-folder-api] reject user=${u.id} name=${baseName} trashedAs=${trashedAs}`);
res.json({ ok: true, rejected: `${baseName}.next.js`, trashedAs });
});
// ── POST /recordings/flush?taskId=<id> ───────────────────────────────────
// Flush the in-memory recording buffer for a given taskId to disk.
// Returns { ok: true, recordingName, path } or 404 if no buffer exists.
r.post('/recordings/flush', (req: Request, res: Response) => {
const u = getUser(req)!;
const taskId = req.query['taskId'] as string | undefined;
if (!taskId) {
res.status(400).json({ error: 'taskId query parameter is required' });
return;
}
let absPath: string | null;
try {
absPath = recorder.flush(taskId, userFolderRoot, u.id);
} catch (err) {
logger.error(`[user-folder-api] recordings/flush failed user=${u.id} taskId=${taskId} err=${err}`);
res.status(500).json({ error: 'Failed to flush recording' });
return;
}
if (absPath === null) {
res.status(404).json({ error: 'no active recording for this task' });
return;
}
// e.g. absPath = "/data/users/user-a/recordings/my-rec.json"
// recordingName = "my-rec" (basename without .json)
const fileBasename = basename(absPath);
const recordingName = fileBasename.endsWith('.json')
? fileBasename.slice(0, -5)
: fileBasename;
const relPath = `recordings/${fileBasename}`;
logger.info(`[user-folder-api] recordings/flush user=${u.id} taskId=${taskId} recordingName=${recordingName}`);
res.json({ ok: true, recordingName, path: relPath });
});
// ── GET /agents-md ───────────────────────────────────────────────────────
r.get('/agents-md', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const content = readUserAgentsMd(userFolderRoot, u.id);
if (content === null) {
res.json({ exists: false, content: '' });
return;
}
res.json({ exists: true, content });
} catch (err) {
logger.error(`[user-folder-api] read AGENTS.md failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to read AGENTS.md' });
}
});
// ── PUT /agents-md ───────────────────────────────────────────────────────
r.put('/agents-md', express.text({ type: '*/*', limit: '256kb' }), (req: Request, res: Response) => {
const u = getUser(req)!;
const body = (req.body as unknown) as string;
if (typeof body !== 'string') {
res.status(400).json({ error: 'body must be text/plain' });
return;
}
try {
writeUserAgentsMd(userFolderRoot, u.id, body);
res.json({ ok: true, bytes: Buffer.byteLength(body, 'utf-8') });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('exceeds')) {
res.status(413).json({ error: msg });
return;
}
logger.error(`[user-folder-api] write AGENTS.md failed user=${u.id} err=${msg}`);
res.status(500).json({ error: 'Failed to write AGENTS.md' });
}
});
// ── DELETE /agents-md ────────────────────────────────────────────────────
r.delete('/agents-md', (req: Request, res: Response) => {
const u = getUser(req)!;
try {
const existed = deleteUserAgentsMd(userFolderRoot, u.id);
res.json({ ok: true, existed });
} catch (err) {
logger.error(`[user-folder-api] delete AGENTS.md failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete AGENTS.md' });
}
});
// ── Router-level error middleware ─────────────────────────────────────────
// Catches errors from route handlers (e.g. express.text body-too-large).
r.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
if (err && err.type === 'entity.too.large') {
res.status(413).json({ error: 'Request body exceeds 1 MB limit' });
return;
}
res.status(err?.status ?? 500).json({ error: err?.message ?? 'Internal error' });
});
return r;
}
+64
View File
@@ -0,0 +1,64 @@
import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { Repository } from '../db/repository.js';
import { requireAuth } from './auth.js';
const passthrough: RequestHandler = (_req, _res, next) => next();
/**
* /api/users/me endpoints.
*
* When `authActive` is true, routes are gated by `requireAuth` and rely on
* `req.user.id` being populated by passport. When auth is disabled (tests or
* standalone mode), the guard is skipped and callers are expected to inject
* `req.user` via their own middleware (e.g. the test harness).
*/
export function mountUsersApi(app: Application, repo: Repository, authActive = true): void {
const guard = authActive ? requireAuth : passthrough;
// Viewer's cached Gitea orgs (populated at OAuth callback by the gitea strategy).
app.get('/api/users/me/orgs', guard, (req: Request, res: Response) => {
const user = req.user as Express.User | undefined;
if (!user) {
// Defensive: with authActive=false and no injected user, return 401-shaped error.
res.status(401).json({ error: 'Unauthorized' });
return;
}
const orgs = repo.listUserGiteaOrgs(user.id);
res.json({ orgs });
});
// Update viewer's per-user preferences (currently just default visibility).
app.patch('/api/users/me/preferences', guard, express.json(), (req: Request, res: Response) => {
const user = req.user as Express.User | undefined;
if (!user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const body = (req.body ?? {}) as {
defaultVisibility?: unknown;
defaultVisibilityOrgId?: unknown;
};
const { defaultVisibility, defaultVisibilityOrgId } = body;
if (defaultVisibility !== undefined && defaultVisibility !== null &&
!['private', 'org', 'public'].includes(defaultVisibility as string)) {
res.status(400).json({ error: 'invalid defaultVisibility' });
return;
}
if (defaultVisibility === 'org') {
const scopeId = typeof defaultVisibilityOrgId === 'string' ? defaultVisibilityOrgId : '';
if (!scopeId) {
res.status(400).json({ error: 'default_visibility_org_id is required when defaultVisibility is "org"' });
return;
}
if (!user.orgIds.includes(scopeId)) {
res.status(400).json({ error: 'default_visibility_org_id must be one of your orgs' });
return;
}
}
repo.updateUser(user.id, {
defaultVisibility: (defaultVisibility as 'private' | 'org' | 'public' | undefined) ?? undefined,
defaultVisibilityOrgId: (defaultVisibilityOrgId as string | null | undefined) ?? null,
});
res.json({ ok: true });
});
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { parseTaskId, validateCreateTaskBody, validateCommentBody } from './validation.js';
describe('parseTaskId', () => {
it('正の整数を返す', () => {
expect(parseTaskId('1')).toBe(1);
expect(parseTaskId('999')).toBe(999);
});
it('0 は null', () => {
expect(parseTaskId('0')).toBeNull();
});
it('負数は null', () => {
expect(parseTaskId('-1')).toBeNull();
});
it('小数は null', () => {
expect(parseTaskId('1.5')).toBeNull();
});
it('NaN は null', () => {
expect(parseTaskId('abc')).toBeNull();
expect(parseTaskId('')).toBeNull();
});
});
describe('validateCreateTaskBody', () => {
it('正常な body を受け入れる', () => {
const result = validateCreateTaskBody({ body: 'hello' });
expect(result.valid).toBe(true);
});
it('body 空はエラー', () => {
const result = validateCreateTaskBody({ body: '' });
expect(result.valid).toBe(false);
});
it('body 未設定はエラー', () => {
const result = validateCreateTaskBody({});
expect(result.valid).toBe(false);
});
it('body が 100000 文字超はエラー', () => {
const result = validateCreateTaskBody({ body: 'x'.repeat(100001) });
expect(result.valid).toBe(false);
});
it('不正な profile はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', profile: 'invalid' });
expect(result.valid).toBe(false);
});
it('正常な profile は受け入れる', () => {
for (const p of ['auto', 'fast', 'quality']) {
const result = validateCreateTaskBody({ body: 'test', profile: p });
expect(result.valid).toBe(true);
}
});
it('不正な outputFormat はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', outputFormat: 'xml' });
expect(result.valid).toBe(false);
});
it('不正な askPolicy はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', askPolicy: 'medium' });
expect(result.valid).toBe(false);
});
it('不正な priority はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', priority: 'critical' });
expect(result.valid).toBe(false);
});
it('title が 200 文字超はエラー', () => {
const result = validateCreateTaskBody({ body: 'test', title: 'x'.repeat(201) });
expect(result.valid).toBe(false);
});
it('undefined のオプションフィールドは受け入れる', () => {
const result = validateCreateTaskBody({ body: 'test' });
expect(result.valid).toBe(true);
});
});
describe('validateCommentBody', () => {
it('正常な body を受け入れる', () => {
const result = validateCommentBody({ body: 'hello' });
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.body).toBe('hello');
expect(result.author).toBe('user');
}
});
it('body 空はエラー', () => {
const result = validateCommentBody({ body: '' });
expect(result.valid).toBe(false);
});
it('body が 100000 文字超はエラー', () => {
const result = validateCommentBody({ body: 'x'.repeat(100001) });
expect(result.valid).toBe(false);
});
it('author を指定できる', () => {
const result = validateCommentBody({ body: 'hi', author: 'bot' });
expect(result.valid).toBe(true);
if (result.valid) expect(result.author).toBe('bot');
});
it('author 未指定は user がデフォルト', () => {
const result = validateCommentBody({ body: 'hi' });
expect(result.valid).toBe(true);
if (result.valid) expect(result.author).toBe('user');
});
});
+142
View File
@@ -0,0 +1,142 @@
const VALID_PROFILES = ['auto', 'fast', 'quality'] as const;
const VALID_OUTPUT_FORMATS = ['text', 'markdown', 'json'] as const;
const VALID_ASK_POLICIES = ['low', 'high'] as const;
const VALID_PRIORITIES = ['low', 'medium', 'high'] as const;
const MAX_BODY_LENGTH = 100_000;
const MAX_TITLE_LENGTH = 200;
const MAX_COMMENT_LENGTH = 100_000;
export function parseTaskId(raw: string): number | null {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return n;
}
export interface ValidatedCreateTask {
body: string;
title?: string;
piece?: string;
profile?: typeof VALID_PROFILES[number];
outputFormat?: typeof VALID_OUTPUT_FORMATS[number];
askPolicy?: typeof VALID_ASK_POLICIES[number];
priority?: typeof VALID_PRIORITIES[number];
attachments?: Array<{ name: string; contentBase64: string }>;
}
type ValidationResult =
| { valid: true; data: ValidatedCreateTask }
| { valid: false; error: string };
export function validateCreateTaskBody(raw: unknown): ValidationResult {
if (!raw || typeof raw !== 'object') {
return { valid: false, error: 'Request body must be an object' };
}
const obj = raw as Record<string, unknown>;
if (typeof obj.body !== 'string' || obj.body.trim().length === 0) {
return { valid: false, error: 'body is required' };
}
if (obj.body.length > MAX_BODY_LENGTH) {
return { valid: false, error: `body must be ${MAX_BODY_LENGTH} characters or less` };
}
if (obj.title !== undefined && obj.title !== null) {
if (typeof obj.title !== 'string') {
return { valid: false, error: 'title must be a string' };
}
if (obj.title.length > MAX_TITLE_LENGTH) {
return { valid: false, error: `title must be ${MAX_TITLE_LENGTH} characters or less` };
}
}
if (obj.profile !== undefined && obj.profile !== null) {
if (!(VALID_PROFILES as readonly string[]).includes(String(obj.profile))) {
return { valid: false, error: `profile must be one of: ${VALID_PROFILES.join(', ')}` };
}
}
if (obj.outputFormat !== undefined && obj.outputFormat !== null) {
if (!(VALID_OUTPUT_FORMATS as readonly string[]).includes(String(obj.outputFormat))) {
return { valid: false, error: `outputFormat must be one of: ${VALID_OUTPUT_FORMATS.join(', ')}` };
}
}
if (obj.askPolicy !== undefined && obj.askPolicy !== null) {
if (!(VALID_ASK_POLICIES as readonly string[]).includes(String(obj.askPolicy))) {
return { valid: false, error: `askPolicy must be one of: ${VALID_ASK_POLICIES.join(', ')}` };
}
}
if (obj.priority !== undefined && obj.priority !== null) {
if (!(VALID_PRIORITIES as readonly string[]).includes(String(obj.priority))) {
return { valid: false, error: `priority must be one of: ${VALID_PRIORITIES.join(', ')}` };
}
}
return {
valid: true,
data: {
body: obj.body as string,
title: obj.title as string | undefined,
piece: obj.piece as string | undefined,
profile: obj.profile as ValidatedCreateTask['profile'],
outputFormat: obj.outputFormat as ValidatedCreateTask['outputFormat'],
askPolicy: obj.askPolicy as ValidatedCreateTask['askPolicy'],
priority: obj.priority as ValidatedCreateTask['priority'],
attachments: obj.attachments as ValidatedCreateTask['attachments'],
},
};
}
export function validateCommentBody(raw: unknown): { valid: true; body: string; author: string; attachments?: Array<{ name: string; contentBase64: string }> } | { valid: false; error: string } {
if (!raw || typeof raw !== 'object') {
return { valid: false, error: 'Request body must be an object' };
}
const obj = raw as Record<string, unknown>;
const body = String(obj.body ?? '').trim();
if (!body) {
return { valid: false, error: 'body is required' };
}
if (body.length > MAX_COMMENT_LENGTH) {
return { valid: false, error: `body must be ${MAX_COMMENT_LENGTH} characters or less` };
}
const author = String(obj.author ?? 'user').trim() || 'user';
const attachments = Array.isArray(obj.attachments) ? obj.attachments as Array<{ name: string; contentBase64: string }> : undefined;
return { valid: true, body, author, attachments };
}
export type ValidatedFeedback = {
rating: 'good' | 'bad';
tags: string[];
comment: string | null;
};
type FeedbackValidationResult =
| { valid: true; data: ValidatedFeedback }
| { valid: false; error: string };
export function validateFeedbackBody(raw: unknown): FeedbackValidationResult {
if (!raw || typeof raw !== 'object') {
return { valid: false, error: 'Request body must be an object' };
}
const obj = raw as Record<string, unknown>;
if (obj.rating !== 'good' && obj.rating !== 'bad') {
return { valid: false, error: "rating must be 'good' or 'bad'" };
}
if (!Array.isArray(obj.tags) || obj.tags.some((t: unknown) => typeof t !== 'string')) {
return { valid: false, error: 'tags must be an array of strings' };
}
if (obj.tags.length > 10) {
return { valid: false, error: 'tags must have at most 10 items' };
}
const comment = obj.comment != null ? String(obj.comment) : null;
if (comment && comment.length > 1000) {
return { valid: false, error: 'comment must be at most 1000 characters' };
}
return {
valid: true,
data: { rating: obj.rating, tags: obj.tags as string[], comment },
};
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { buildVisibilityWhere, canUserSeeTask } from './visibility.js';
function makeUser(overrides: Partial<Express.User> = {}): Express.User {
return {
id: 'user-1', email: '[email protected]', name: 'u', avatarUrl: null,
role: 'user', status: 'active',
orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
...overrides,
};
}
describe('buildVisibilityWhere', () => {
it('admin sees everything (1=1)', () => {
const w = buildVisibilityWhere(makeUser({ role: 'admin' }), 'lt');
expect(w.clause).toBe('1=1');
expect(w.params).toEqual([]);
});
it('user with no orgs: owner or public only', () => {
const w = buildVisibilityWhere(makeUser(), 'lt');
expect(w.clause).toContain('lt.owner_id = ?');
expect(w.clause).toContain("lt.visibility = 'public'");
expect(w.clause).toContain('IN (NULL)'); // empty orgs → never matches
expect(w.params).toEqual(['user-1']);
});
it('user with orgs: owner or public or same-org', () => {
const w = buildVisibilityWhere(makeUser({ orgIds: ['10', '20'] }), 'lt');
expect(w.clause).toMatch(/lt\.visibility_scope_org_id IN \(\?,\?\)/);
expect(w.params).toEqual(['user-1', '10', '20']);
});
it('respects custom table alias', () => {
const w = buildVisibilityWhere(makeUser(), 'j');
expect(w.clause).toContain('j.owner_id');
expect(w.clause).not.toContain('lt.');
});
});
describe('canUserSeeTask', () => {
const adminUser = makeUser({ role: 'admin' });
const aliceNoOrg = makeUser({ id: 'alice' });
const bobOrg10 = makeUser({ id: 'bob', orgIds: ['10'] });
it('admin sees private tasks of others', () => {
const t = { ownerId: 'someone-else', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(adminUser, t)).toBe(true);
});
it('owner sees own private task', () => {
const t = { ownerId: 'alice', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(true);
});
it('non-owner cannot see another user\'s private task', () => {
const t = { ownerId: 'someone-else', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(false);
});
it('public task is visible to anyone', () => {
const t = { ownerId: 'someone-else', visibility: 'public' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(true);
});
it('org task: same org member can see', () => {
const t = { ownerId: 'someone-else', visibility: 'org' as const, visibilityScopeOrgId: '10' };
expect(canUserSeeTask(bobOrg10, t)).toBe(true);
});
it('org task: different org member cannot see', () => {
const t = { ownerId: 'someone-else', visibility: 'org' as const, visibilityScopeOrgId: '99' };
expect(canUserSeeTask(bobOrg10, t)).toBe(false);
});
it('org task with null scope: only owner can see', () => {
const t = { ownerId: 'alice', visibility: 'org' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(true);
expect(canUserSeeTask(bobOrg10, t)).toBe(false);
});
it('owner with null ownerId: not matched (null !== null check skipped)', () => {
const t = { ownerId: null, visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(false);
});
});
+48
View File
@@ -0,0 +1,48 @@
export interface VisibilityWhere {
clause: string;
params: unknown[];
}
export function buildVisibilityWhere(user: Express.User, tableAlias: string): VisibilityWhere {
if (user.role === 'admin') {
return { clause: '1=1', params: [] };
}
const orgPlaceholders = user.orgIds.length > 0
? user.orgIds.map(() => '?').join(',')
: 'NULL';
return {
clause: `(
${tableAlias}.owner_id = ?
OR ${tableAlias}.visibility = 'public'
OR (${tableAlias}.visibility = 'org' AND ${tableAlias}.visibility_scope_org_id IN (${orgPlaceholders}))
)`.replace(/\s+/g, ' ').trim(),
params: [user.id, ...user.orgIds],
};
}
export function canEditEntity(user: Express.User, entity: { ownerId: string | null }): boolean {
return user.role === 'admin' || entity.ownerId === user.id;
}
/**
* 指定 user が指定タスクを閲覧できるか判定する。SQL ではなくロード済みオブジェクト
* に対して使う。`buildVisibilityWhere` と同じセマンティクスをコード上で再現する。
*
* - admin: 常に true
* - owner: 常に true
* - public: 常に true
* - org: user.orgIds に visibilityScopeOrgId が含まれているかで判定
* - private: owner 以外は false
*/
export function canUserSeeTask(
user: Express.User,
task: { ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null },
): boolean {
if (user.role === 'admin') return true;
if (task.ownerId !== null && task.ownerId === user.id) return true;
if (task.visibility === 'public') return true;
if (task.visibility === 'org' && task.visibilityScopeOrgId !== null) {
return user.orgIds.includes(task.visibilityScopeOrgId);
}
return false;
}
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import { parse } from 'yaml';
import { patchYaml, diff } from './yaml-patch.js';
// Representative piece with all the formatting we want to preserve.
const ORIGINAL = [
'name: sample',
'description: |',
' Multi-line',
' description.',
'max_movements: 10',
'initial_movement: start',
'',
'movements:',
' - name: start',
' edit: false',
' persona: analyst',
' instruction: |',
' Do the thing.',
' Keep newlines.',
' allowed_tools: [Read, Glob]',
' default_next: execute',
' rules:',
' - condition: ok',
' next: execute',
'',
' - name: execute',
' edit: true',
' persona: worker',
' instruction: |',
' Work work work.',
' allowed_tools: [Read, Write, Edit]',
' default_next: verify',
' rules:',
' - condition: done',
' next: verify',
'',
' - name: verify',
' edit: false',
' persona: reviewer',
' instruction: |',
' Review it.',
' allowed_tools: [Read]',
' default_next: COMPLETE',
' rules:',
' - condition: good',
' next: COMPLETE',
'',
].join('\n');
describe('diff', () => {
it('returns no ops when values are equal', () => {
expect(diff({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toEqual([]);
});
it('emits a single set for a changed leaf', () => {
const ops = diff({ a: 1, b: 2 }, { a: 1, b: 3 });
expect(ops).toEqual([{ kind: 'set', path: ['b'], value: 3 }]);
});
it('emits a delete for a removed key', () => {
const ops = diff({ a: 1, b: 2 }, { a: 1 });
expect(ops).toEqual([{ kind: 'delete', path: ['b'] }]);
});
it('emits a set for a new key', () => {
const ops = diff({ a: 1 }, { a: 1, b: 2 });
expect(ops).toEqual([{ kind: 'set', path: ['b'], value: 2 }]);
});
it('recurses element-wise when array lengths match', () => {
const ops = diff({ xs: [1, 2, 3] }, { xs: [1, 2, 4] });
expect(ops).toEqual([{ kind: 'set', path: ['xs', 2], value: 4 }]);
});
it('replaces whole array when lengths differ', () => {
const ops = diff({ xs: [1, 2, 3] }, { xs: [1, 2, 3, 4] });
expect(ops).toEqual([{ kind: 'set', path: ['xs'], value: [1, 2, 3, 4] }]);
});
});
describe('patchYaml', () => {
it('preserves `|` literal block style when editing instruction text', () => {
const body = parse(ORIGINAL);
body.movements[0].instruction = 'Do the updated thing.\nStill multiline.\n';
const patched = patchYaml(ORIGINAL, body);
expect(patched).toContain('instruction: |');
expect(patched).not.toMatch(/instruction:\s*>/);
// round-trips to the intended value
expect(parse(patched).movements[0].instruction).toBe(
'Do the updated thing.\nStill multiline.\n',
);
});
it('does not reformat unrelated inline allowed_tools array', () => {
const body = parse(ORIGINAL);
body.movements[0].instruction = 'tweaked';
const patched = patchYaml(ORIGINAL, body);
// yaml v2 may normalize bracket spacing (`[x]` -> `[ x ]`), but the key
// invariant is that flow-style stays flow (no multiline block expansion).
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Write,\s*Edit\s*\]/);
expect(patched).toMatch(/allowed_tools: \[\s*Read\s*\]/);
// And crucially it must NOT have been expanded to block style:
expect(patched).not.toMatch(/allowed_tools:\s*\n\s*-\s*Read\s*\n\s*-\s*Write/);
});
it('preserves blank lines between movements', () => {
const body = parse(ORIGINAL);
body.movements[1].persona = 'coder';
const patched = patchYaml(ORIGINAL, body);
// Each movement in the original is separated by a blank line. Check that
// the sequence `\n\n - name:` still appears between them.
const blankLineBeforeMovements = patched.match(/\n\n - name:/g);
expect(blankLineBeforeMovements).not.toBeNull();
expect(blankLineBeforeMovements!.length).toBeGreaterThanOrEqual(2);
});
it('supports adding a brand-new movement', () => {
const body = parse(ORIGINAL);
body.movements.push({
name: 'extra',
edit: false,
persona: 'helper',
instruction: 'added step',
allowed_tools: ['Read'],
default_next: 'COMPLETE',
rules: [{ condition: 'x', next: 'COMPLETE' }],
});
const patched = patchYaml(ORIGINAL, body);
const reparsed = parse(patched);
expect(reparsed.movements).toHaveLength(4);
expect(reparsed.movements[3].name).toBe('extra');
// other movements still have their literal-block instructions intact
expect(patched).toContain('instruction: |');
});
it('supports deleting a movement', () => {
const body = parse(ORIGINAL);
body.movements.splice(1, 1); // drop `execute`
const patched = patchYaml(ORIGINAL, body);
const reparsed = parse(patched);
expect(reparsed.movements).toHaveLength(2);
expect(reparsed.movements.map((m: any) => m.name)).toEqual(['start', 'verify']);
});
it('renaming default_next is a minimal targeted change', () => {
const body = parse(ORIGINAL);
body.movements[0].default_next = 'verify';
const patched = patchYaml(ORIGINAL, body);
expect(parse(patched).movements[0].default_next).toBe('verify');
// untouched: instruction block style, inline arrays, blank lines
expect(patched).toContain('instruction: |');
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Glob\s*\]/);
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Write,\s*Edit\s*\]/);
expect(patched).toMatch(/\n\n - name: execute/);
});
it('falls back to stringify when original yaml is malformed', () => {
const broken = 'name: x\n bad-indent: [\n';
const body = { name: 'x', description: 'ok' };
const out = patchYaml(broken, body);
// Fallback should still produce parseable output with the new body.
const parsed = parse(out);
expect(parsed.name).toBe('x');
expect(parsed.description).toBe('ok');
});
it('semantic content matches body after patching', () => {
const body = parse(ORIGINAL);
body.description = 'new description';
body.movements[2].instruction = 'reviewed differently';
body.movements[2].allowed_tools = ['Read', 'Glob'];
const patched = patchYaml(ORIGINAL, body);
expect(parse(patched)).toEqual(body);
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* YAML source-preserving patch helper.
*
* Problem: `yaml.stringify(obj)` completely re-serializes a document, destroying
* the source formatting (block literal vs folded, inline vs block arrays, blank
* lines, comments, key order). This changes `instruction: |` to `instruction: >`,
* which actually alters runtime behavior because folded style collapses newlines.
*
* Solution: parseDocument(originalText) -> deep-diff doc.toJS() vs newBody ->
* apply only differing paths via Document#setIn / Document#deleteIn. Untouched
* regions preserve their original source exactly.
*
* Newly-added subtrees go through Document#createNode with the existing
* lineWidth: 120 convention.
*/
import { parseDocument, stringify, type Document } from 'yaml';
import { logger } from '../logger.js';
const LINE_WIDTH = 120;
type Path = (string | number)[];
/**
* Recursively walk `prev` and `next` (both plain JS values), collecting paths
* where the two differ. For differing paths we emit either a set (with the new
* value) or a delete.
*
* Rules:
* - For objects (plain dicts) we compare by key. Keys removed in `next` become
* deletes; keys added in `next` become sets; common keys recurse.
* - For arrays of equal length we recurse element-by-element by index. This
* lets us do minimal edits inside one movement without re-serializing the
* entire `movements:` sequence (which would flatten its inline arrays and
* block-literal instructions).
* - For arrays of differing length we replace the whole array at that path.
* Element-wise alignment across inserts/deletes is ambiguous without a
* stable identity field, so we bail to wholesale replacement and accept
* the one-time formatting loss for the mutated sequence.
* - For primitives (string/number/bool/null) we compare via strict equality.
*/
export type DiffOp =
| { kind: 'set'; path: Path; value: unknown }
| { kind: 'delete'; path: Path };
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const k of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k])) return false;
}
return true;
}
return false;
}
export function diff(prev: unknown, next: unknown, path: Path = []): DiffOp[] {
if (deepEqual(prev, next)) return [];
// If the shapes differ (object<->array<->scalar) replace wholesale.
const prevIsObj = isPlainObject(prev);
const nextIsObj = isPlainObject(next);
const prevIsArr = Array.isArray(prev);
const nextIsArr = Array.isArray(next);
if (prevIsObj && nextIsObj) {
const ops: DiffOp[] = [];
const prevKeys = new Set(Object.keys(prev as Record<string, unknown>));
const nextKeys = new Set(Object.keys(next as Record<string, unknown>));
// deletions
for (const k of prevKeys) {
if (!nextKeys.has(k)) {
ops.push({ kind: 'delete', path: [...path, k] });
}
}
// additions + recursive diffs
for (const k of nextKeys) {
const nextVal = (next as Record<string, unknown>)[k];
if (!prevKeys.has(k)) {
ops.push({ kind: 'set', path: [...path, k], value: nextVal });
} else {
const prevVal = (prev as Record<string, unknown>)[k];
ops.push(...diff(prevVal, nextVal, [...path, k]));
}
}
return ops;
}
if (prevIsArr && nextIsArr) {
const prevArr = prev as unknown[];
const nextArr = next as unknown[];
if (prevArr.length !== nextArr.length) {
// Length change: bail to wholesale replacement. We can't reliably align
// items across insertions/deletions without a stable identity field.
return [{ kind: 'set', path, value: next }];
}
// Equal length: recurse element-wise so untouched items keep formatting.
const ops: DiffOp[] = [];
for (let i = 0; i < prevArr.length; i++) {
ops.push(...diff(prevArr[i], nextArr[i], [...path, i]));
}
return ops;
}
// Shape change or primitive mismatch: replace.
return [{ kind: 'set', path, value: next }];
}
/**
* Apply a list of diff ops to a Document in place. New subtrees are wrapped via
* doc.createNode so they follow our stringify options (lineWidth etc.).
*/
export function applyOps(doc: Document, ops: DiffOp[]): void {
for (const op of ops) {
if (op.kind === 'delete') {
doc.deleteIn(op.path);
} else {
// createNode respects schema + options; we don't pass extra options
// because per-field block style for untouched content comes from the
// original Document and we only call createNode for NEW subtrees.
const node = doc.createNode(op.value);
doc.setIn(op.path, node);
}
}
}
/**
* Re-serialize `body` onto the formatting of `originalText`, preserving block
* styles / inline arrays / blank lines / comments for untouched regions.
*
* If the original text fails to parse cleanly (errors array non-empty) we fall
* back to a plain stringify and log a warning.
*/
export function patchYaml(originalText: string, body: unknown): string {
let doc: Document;
try {
doc = parseDocument(originalText);
} catch (e) {
logger.warn(`[yaml-patch] parseDocument threw, falling back to stringify err=${e}`);
return stringify(body, { lineWidth: LINE_WIDTH });
}
if (doc.errors && doc.errors.length > 0) {
logger.warn(
`[yaml-patch] original document has parse errors, falling back to stringify count=${doc.errors.length}`,
);
return stringify(body, { lineWidth: LINE_WIDTH });
}
const prev = doc.toJS();
const ops = diff(prev, body);
applyOps(doc, ops);
return doc.toString({ lineWidth: LINE_WIDTH });
}