import { ApiError } from './board.mjs'; const COOKIE = 'ridebus_board_device'; const tokenFrom = req => { const value = req.headers.cookie?.split(';').map(item => item.trim()).find(item => item.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1); return /^[A-Za-z0-9_-]{43}$/.test(value || '') ? value : ''; }; export function createDeviceBridge({ enabled = process.env.BOARD_REMOTE_SETTINGS === 'true', baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', adminUrl = process.env.BOARD_ADMIN_URL || '', fetchData = fetch } = {}) { const cache = new Map(); async function upstream(path, token, body) { let response; try { response = await fetchData(new URL(`api/v1/board-devices/${path}`, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`), { method: body === undefined ? 'GET' : 'POST', headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(token ? { 'X-Board-Token': token } : {}) }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: AbortSignal.timeout(10000), }); } catch { throw new ApiError(502, 'devices_unavailable'); } if (!response.ok) { const codes = { 401: 'device_unpaired', 410: 'device_revoked', 429: 'device_rate_limit' }; throw new ApiError(codes[response.status] ? response.status : 502, codes[response.status] || 'devices_unavailable'); } return response.json(); } async function status(req, force = false) { const token = tokenFrom(req); if (!token) return { state: 'unpaired' }; const cached = cache.get(token); if (!force && cached?.until > Date.now()) return cached.value; try { const value = await upstream('self', token); if (cache.size >= 1000) cache.delete(cache.keys().next().value); cache.set(token, { value, until: Date.now() + 15000 }); return value; } catch (error) { cache.delete(token); if (error.code === 'device_unpaired') return { state: 'unpaired' }; throw error; } } return { enabled, async status(req, config) { if (!enabled) return { enabled: false }; const value = await status(req, true); return { ...value, enabled: true, adminUrl, ...(value.state === 'paired' ? { config: { ...config, cityId: value.device.cityId, cityName: value.device.cityName, stopName: value.device.stopName, defaultStopId: value.device.settings.stopId } } : {}) }; }, async start(req, res) { if (!enabled) throw new ApiError(404, 'not_found'); const previous = await status(req, true).catch(error => { if (error.code === 'device_revoked') return { state: 'revoked' }; throw error; }); if (['paired', 'pending'].includes(previous.state)) return { ...previous, enabled: true, adminUrl }; const value = await upstream('pairings', '', req.body || {}); if (!/^[A-Za-z0-9_-]{43}$/.test(value.token || '')) throw new ApiError(502, 'devices_unavailable'); const oldToken = tokenFrom(req); if (oldToken) cache.delete(oldToken); res.cookie(COOKIE, value.token, { httpOnly: true, sameSite: 'strict', secure: req.secure || req.get('X-Forwarded-Proto') === 'https' || process.env.BOARD_COOKIE_SECURE === 'true', path: '/api', maxAge: 365 * 86400000 }); const { token: _token, ...publicValue } = value; return { ...publicValue, enabled: true, adminUrl }; }, async heartbeat(req) { if (!enabled || !tokenFrom(req)) throw new ApiError(401, 'device_unpaired'); return upstream('self/heartbeat', tokenFrom(req), req.body || {}); }, async context(req) { if (!enabled) return null; const value = await status(req); if (value.state !== 'paired') throw new ApiError(401, 'device_unpaired'); return value.device; }, }; }