diff --git a/lib/api/cap.js b/lib/api/cap.js index fd33c81..7e6a662 100644 --- a/lib/api/cap.js +++ b/lib/api/cap.js @@ -1,6 +1,6 @@ import { canAct } from '../auth/capability.js'; import * as pendingChanges from '../db/repos/pending_changes.js'; -import { ForbiddenError } from './errors.js'; +import { ForbiddenError, UnauthorizedError } from './errors.js'; const METHOD_TO_ACTION = { POST: 'create', PATCH: 'update', PUT: 'update', DELETE: 'delete' }; @@ -15,9 +15,8 @@ export function requireWrite(entity_type) { } export function requireOwner(req, _res, next) { - if (req.actor?.kind !== 'user') { - return next(new ForbiddenError('owner-only endpoint')); - } + if (!req.actor) return next(new UnauthorizedError('owner-only endpoint')); + if (req.actor.kind !== 'user') return next(new ForbiddenError('owner-only endpoint')); next(); } diff --git a/lib/api/errors.js b/lib/api/errors.js index a6bb297..5705934 100644 --- a/lib/api/errors.js +++ b/lib/api/errors.js @@ -33,6 +33,12 @@ export class ForbiddenError extends ApiError { } } +export class UnauthorizedError extends ApiError { + constructor(message = 'unauthorized', details) { + super('unauthorized', message, 401, details); + } +} + export function asyncWrap(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } diff --git a/lib/api/routes/devices.js b/lib/api/routes/devices.js new file mode 100644 index 0000000..beee87a --- /dev/null +++ b/lib/api/routes/devices.js @@ -0,0 +1,82 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { asyncWrap, errorMiddleware } from '../errors.js'; +import { requireOwner } from '../cap.js'; +import { validate } from '../validate.js'; +import * as devices from '../../db/repos/lan_devices.js'; +import * as agents from '../../db/repos/agents.js'; +import { timingSafeStrEqual } from '../../auth/safe_compare.js'; +import { accessOwnerEmail } from '../../auth/cf_access.js'; + +export const router = Router(); + +// Soft auth: identifies the actor if auth is present but never blocks the request. +// Owner-only sub-routes enforce 401/403 via requireOwner. +async function softAuth(req, _res, next) { + try { + const cfEmail = await accessOwnerEmail(req); + if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); } + const auth = req.headers.authorization || ''; + const [scheme, token] = auth.split(' '); + if (scheme === 'Bearer' && token) { + if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) { + req.actor = { kind: 'user', id: null }; return next(); + } + try { + const agent = await agents.verifyToken(token); + if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} }; + } catch { /* ignore */ } + } + } catch { /* ignore */ } + next(); +} +const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']; + +router.use(softAuth); + +// GET /devices — known devices grouped for the band (open within the app, like /services). +router.get('/', asyncWrap(async (_req, res) => { + const byGrp = new Map(); + for (const d of await devices.listKnown()) { + const g = d.grp || 'Flagged'; + if (!byGrp.has(g)) byGrp.set(g, []); + byGrp.get(g).push(d); + } + const order = [...GROUP_ORDER, ...[...byGrp.keys()].filter(g => !GROUP_ORDER.includes(g))]; + res.json({ groups: order.filter(g => byGrp.has(g)).map(name => ({ name, devices: byGrp.get(name) })) }); +})); + +// GET /devices/discovered — review queue (owner). +router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => { + res.json(await devices.listDiscovered()); +})); + +const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) }); +const patchBody = z.object({ + name: z.string().max(120).optional(), + grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(), + status: z.enum(['new', 'known', 'ignored']).optional(), + note: z.string().max(500).optional(), + flagged: z.boolean().optional() +}); + +// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered". +router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody }), asyncWrap(async (req, res) => { + const updated = await devices.update(req.params.mac.toLowerCase(), req.body); + if (!updated) return res.status(404).json({ error: { code: 'not_found' } }); + res.json(updated); +})); + +// DELETE /devices/:mac (owner). +router.delete('/:mac', requireOwner, validate({ params: macParam }), asyncWrap(async (req, res) => { + if (!(await devices.remove(req.params.mac.toLowerCase()))) return res.status(404).json({ error: { code: 'not_found' } }); + res.status(204).end(); +})); + +// POST /devices/scan — run a scan now (owner). +router.post('/scan', requireOwner, asyncWrap(async (_req, res) => { + const { runDeviceScanCycle } = await import('../../infra/scan_cycle.js'); + res.json(await runDeviceScanCycle()); +})); + +router.use(errorMiddleware); diff --git a/server.js b/server.js index 7e5ff7a..a8dc74f 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,7 @@ import * as queue from './lib/jobs/queue.js'; import { registerWorkers } from './lib/jobs/index.js'; import { router as ingestRouter } from './lib/api/routes/ingest.js'; import { router as iconsRouter } from './lib/api/routes/icons.js'; +import { router as devicesRouter } from './lib/api/routes/devices.js'; import { startCron } from './lib/cron/index.js'; import { seedFromConfig } from './lib/health/registry.js'; import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; @@ -52,6 +53,10 @@ export function createApp() { // slugs are sanitized to [a-z0-9-] to prevent path traversal. app.use('/api/icons', iconsRouter); + // /api/devices — band data is public (like the static devices.json it replaces); + // discovered/edit/scan sub-routes use requireOwner (401/403) internally. + app.use('/api/devices', devicesRouter); + app.get('/health', async (_req, res) => { let db_ok = false; try { diff --git a/tests/api/devices.test.js b/tests/api/devices.test.js new file mode 100644 index 0000000..0010826 --- /dev/null +++ b/tests/api/devices.test.js @@ -0,0 +1,41 @@ +// tests/api/devices.test.js +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../../server.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +let app; +const owner = r => r.set('Authorization', 'Bearer test-token'); +beforeAll(async () => { process.env.OWNER_TOKEN = 'test-token'; app = createApp(); }); +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('/api/devices', () => { + it('GET / returns known devices grouped', async () => { + const res = await request(app).get('/api/devices'); + expect(res.status).toBe(200); + const names = res.body.groups.map(g => g.name); + expect(names).toContain('Network'); + const net = res.body.groups.find(g => g.name === 'Network'); + expect(net.devices.some(d => d.name === 'Orbi Satellite')).toBe(true); + }); + + it('GET /discovered requires owner and lists new devices', async () => { + expect((await request(app).get('/api/devices/discovered')).status).toBe(401); + const res = await owner(request(app).get('/api/devices/discovered')); + expect(res.status).toBe(200); + expect(res.body.some(d => d.mac === '24:4b:fe:8e:09:a4')).toBe(true); + }); + + it('PATCH /:mac promotes + names (owner)', async () => { + const res = await owner(request(app).patch('/api/devices/24:4b:fe:8e:09:a4')) + .send({ name: 'ASUS Router', grp: 'Network', status: 'known' }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('ASUS Router'); + expect((await owner(request(app).get('/api/devices/discovered'))).body).toHaveLength(0); + }); + + it('PATCH rejects a bad MAC', async () => { + expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400); + }); +});