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