diff --git a/lib/api/index.js b/lib/api/index.js index fcfac69..11fb012 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -29,6 +29,7 @@ import { router as hostRouter } from './routes/host.js'; import { router as speedtestRouter } from './routes/speedtest.js'; import { router as healthRouter } from './routes/health.js'; import { router as securityRouter } from './routes/security.js'; +import { router as actionsRouter } from './routes/actions.js'; export function mountApi(app) { const api = Router(); @@ -41,6 +42,7 @@ export function mountApi(app) { api.use('/spaces/:space_id/resources', resourcesBySpaceRouter); api.use('/spaces/:space_id/companion', companionRouter); api.use('/security', securityRouter); + api.use('/actions', actionsRouter); api.use('/projects', projectsRouter); api.use('/projects/:project_id/tasks', tasksByProjectRouter); api.use('/tasks', tasksRouter); diff --git a/lib/api/routes/actions.js b/lib/api/routes/actions.js new file mode 100644 index 0000000..a3e0328 --- /dev/null +++ b/lib/api/routes/actions.js @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import { asyncWrap } from '../errors.js'; +import { makeActionService } from '../../actions/service.js'; +import { loadActions } from '../../actions/registry.js'; +import * as aa from '../../db/repos/agent_actions.js'; + +// Owner OR an agent with capabilities.act (Little Blue) may run/list. Approve/reject +// are owner-only. The service enforces tier-gating regardless of caller. +function svc() { + return makeActionService({ registry: loadActions(process.env.ACTIONS_CONFIG || undefined) }); +} +function canAct(req) { + const a = req.actor; + return a?.kind === 'user' || (a?.kind === 'agent' && a.capabilities?.act); +} +function ownerOnly(req, res, next) { + if (req.actor?.kind === 'user') return next(); + return res.status(403).json({ error: { code: 'owner_only', message: 'owner approval required' } }); +} + +export const router = Router(); + +router.get('/', asyncWrap(async (req, res) => { + if (!canAct(req)) return res.status(403).json({ error: { code: 'forbidden' } }); + res.json({ actions: svc().list() }); +})); + +router.post('/:id/run', asyncWrap(async (req, res) => { + if (!canAct(req)) return res.status(403).json({ error: { code: 'forbidden' } }); + const agent_id = req.actor?.kind === 'agent' ? req.actor.id : null; + res.json(await svc().run(req.params.id, req.actor, agent_id)); +})); + +router.get('/pending', ownerOnly, asyncWrap(async (_req, res) => { + res.json({ pending: await aa.listPending() }); +})); + +router.post('/pending/:rowId/approve', ownerOnly, asyncWrap(async (req, res) => { + res.json(await svc().approve(req.params.rowId, req.actor)); +})); +router.post('/pending/:rowId/reject', ownerOnly, asyncWrap(async (req, res) => { + res.json(await svc().reject(req.params.rowId, req.actor)); +})); + +router.get('/recent', ownerOnly, asyncWrap(async (_req, res) => { + res.json({ recent: await aa.recent() }); +})); diff --git a/tests/api/actions.test.js b/tests/api/actions.test.js new file mode 100644 index 0000000..db2e85d --- /dev/null +++ b/tests/api/actions.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { createApp } from '../../server.js'; + +let app; +beforeAll(async () => { + await resetDb(); await migrateUp(); + process.env.OWNER_TOKEN = 'test-token'; + process.env.ACTIONS_CONFIG = new URL('../fixtures/actions.test.json', import.meta.url).pathname; + app = createApp(); +}); +const auth = (r) => r.set('Authorization', 'Bearer test-token'); + +describe('actions API', () => { + it('GET / lists the whitelist (owner)', async () => { + const res = await auth(request(app).get('/api/actions')); + expect(res.status).toBe(200); + expect(res.body.actions.map(a => a.id)).toContain('stop-ct107'); + }); + it('no auth is rejected', async () => { + const res = await request(app).get('/api/actions'); + expect(res.status).toBe(401); + }); + it('risky run queues; appears in /pending; reject resolves it (owner)', async () => { + const run = await auth(request(app).post('/api/actions/stop-ct107/run')); + expect(run.status).toBe(200); + expect(run.body.queued).toBe(true); + const pend = await auth(request(app).get('/api/actions/pending')); + expect(pend.body.pending.some(p => p.id === run.body.action_row_id)).toBe(true); + const rej = await auth(request(app).post(`/api/actions/pending/${run.body.action_row_id}/reject`)); + expect(rej.body.status).toBe('rejected'); + }); +});