feat(devices): /api/devices band + discovered review/edit endpoints

This commit is contained in:
root
2026-06-08 21:04:41 +10:00
parent e9c1fb17ac
commit 0fe25d96ec
5 changed files with 137 additions and 4 deletions

View File

@@ -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();
}

View File

@@ -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);
}

82
lib/api/routes/devices.js Normal file
View File

@@ -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);

View File

@@ -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 {

41
tests/api/devices.test.js Normal file
View File

@@ -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);
});
});