feat: add remote device management and pairing functionality

- Implemented device pairing with a four-digit code and TTL for remote settings.
- Introduced automatic checks for device status and configuration validation.
- Developed UI components for device setup and management, including error handling and retry options.
- Created a device bridge for secure communication between the device and the server.
- Added tests for managed state validation and device pairing logic.
- Established a local end-to-end fixture for testing device service and API interactions.
This commit is contained in:
2026-10-10 00:52:36 +03:00
parent e3074555a7
commit d04b364047
21 changed files with 402 additions and 10 deletions

View File

@@ -4,37 +4,58 @@ import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { ApiError, createBoardClient } from './board.mjs';
import { createWeatherClient } from './weather.mjs';
import { createDeviceBridge } from './device.mjs';
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient()) {
export function createApp(client = createBoardClient(), weatherClient = createWeatherClient(), deviceBridge = createDeviceBridge(), clientFactory = createBoardClient) {
const app = express();
app.disable('x-powered-by');
app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); });
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));
app.use('/api/device', express.json({ limit: '8kb' }), (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'GET') {
let origin; try { origin = req.get('Origin') ? new URL(req.get('Origin')) : null; } catch { return res.status(403).json({ error: 'invalid_origin' }); }
if (req.get('Sec-Fetch-Site') === 'cross-site' || origin && origin.host !== req.get('Host') || !req.is('application/json')) return res.status(403).json({ error: 'invalid_origin' });
}
next();
});
app.get('/api/device/status', async (req, res) => res.json(await deviceBridge.status(req, client.config())));
app.post('/api/device/pair', async (req, res) => res.json(await deviceBridge.start(req, res)));
app.post('/api/device/heartbeat', async (req, res) => res.json(await deviceBridge.heartbeat(req)));
const clients = new Map();
app.use('/api', (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); res.status(405).json({ error: 'method_not_allowed' }); return; }
next();
});
app.use('/api', async (req, _res, next) => {
if (!deviceBridge.enabled || req.path === '/config') { req.boardClient = client; return next(); }
const device = await deviceBridge.context(req);
if (!clients.has(device.cityId)) clients.set(device.cityId, clientFactory({ cityId: device.cityId, cityName: device.cityName, stopName: device.stopName, defaultStopId: device.settings.stopId }));
req.boardClient = clients.get(device.cityId);
if (req.query.stopId && req.query.stopId !== String(device.settings.stopId)) throw new ApiError(409, 'settings_changed');
next();
});
app.get('/api/config', (_req, res) => res.json(client.config()));
app.get('/api/stops', async (_req, res) => { res.json(await client.stops()); });
app.get('/api/stops', async (req, res) => { res.json(await req.boardClient.stops()); });
app.get('/api/board', async (req, res) => {
const raw = req.query.stopId;
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) throw new ApiError(400, 'invalid_stop');
const data = await client.board(Number(raw));
const data = await req.boardClient.board(Number(raw));
res.json({ ...data, serverTime: new Date().toISOString() });
});
const stopId = req => {
if (typeof req.query.stopId !== 'string' || !/^[1-9]\d*$/.test(req.query.stopId) || !Number.isSafeInteger(Number(req.query.stopId))) throw new ApiError(400, 'invalid_stop');
return Number(req.query.stopId);
};
app.get('/api/timetable', async (req, res) => res.json(await client.timetable(stopId(req))));
app.get('/api/timetable', async (req, res) => res.json(await req.boardClient.timetable(stopId(req))));
app.get('/api/weather', async (req, res) => {
const id = stopId(req);
res.json(await weatherClient.weather(client.config().cityId, id, await client.coordinates(id)));
res.json(await weatherClient.weather(req.boardClient.config().cityId, id, await req.boardClient.coordinates(id)));
});
app.use('/api', (_req, res) => res.status(404).json({ error: 'not_found' }));
// Never return upstream bodies, stack traces or API credentials.
app.use((error, _req, res, _next) => { res.status(error instanceof ApiError ? error.status : 502).json({ error: error instanceof ApiError ? error.code : 'data_unavailable' }); });
app.use((error, _req, res, _next) => { res.status(error.type === 'entity.parse.failed' ? 400 : error.type === 'entity.too.large' ? 413 : error instanceof ApiError ? error.status : 502).json({ error: ['entity.parse.failed', 'entity.too.large'].includes(error.type) ? 'invalid_request' : error instanceof ApiError ? error.code : 'data_unavailable' }); });
return app;
}