feat(devices): /api/devices band + discovered review/edit endpoints
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
82
lib/api/routes/devices.js
Normal 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);
|
||||
@@ -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
41
tests/api/devices.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user