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);
|
||||
Reference in New Issue
Block a user