83 lines
3.4 KiB
JavaScript
83 lines
3.4 KiB
JavaScript
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);
|