diff --git a/backend/src/api/publicStatus.ts b/backend/src/api/publicStatus.ts new file mode 100644 index 0000000..99bcdd5 --- /dev/null +++ b/backend/src/api/publicStatus.ts @@ -0,0 +1,62 @@ +/** Approved fields for the unauthenticated node status response contracts. */ +export type PublicStatusCounters = { + battery_mv: number | null; + uptime_secs: number | null; + tx_air_secs: number | null; + rx_air_secs: number | null; + channel_utilization: number | null; + air_util_tx: number | null; +}; + +export type PublicLatestNodeStatusDTO = PublicStatusCounters & { + time: string; + node_id: string; + network: string | null; + name: string | null; + iata: string | null; +}; + +export type PublicMqttNodeStatusDTO = PublicStatusCounters & { + node_id: string; + name: string | null; + last_seen: string; + packets_24h: string; +}; + +type PublicStatusCounterSource = Partial; + +function publicStatusCounters(row: PublicStatusCounterSource): PublicStatusCounters { + return { + battery_mv: row.battery_mv ?? null, + uptime_secs: row.uptime_secs ?? null, + tx_air_secs: row.tx_air_secs ?? null, + rx_air_secs: row.rx_air_secs ?? null, + channel_utilization: row.channel_utilization ?? null, + air_util_tx: row.air_util_tx ?? null, + }; +} + +export function toPublicLatestNodeStatusDTO( + row: PublicLatestNodeStatusDTO, +): PublicLatestNodeStatusDTO { + return { + time: row.time, + node_id: row.node_id, + network: row.network, + name: row.name, + iata: row.iata, + ...publicStatusCounters(row), + }; +} + +export function toPublicMqttNodeStatusDTO( + row: PublicMqttNodeStatusDTO, +): PublicMqttNodeStatusDTO { + return { + node_id: row.node_id, + name: row.name, + last_seen: row.last_seen, + ...publicStatusCounters(row), + packets_24h: row.packets_24h, + }; +} diff --git a/backend/src/api/routes/misc.test.ts b/backend/src/api/routes/misc.test.ts index 5b167a1..c948e98 100644 --- a/backend/src/api/routes/misc.test.ts +++ b/backend/src/api/routes/misc.test.ts @@ -37,3 +37,64 @@ test('channel feed history route forwards its bounded channel and scope', async server.close(); } }); + +test('public MQTT node status omits owner-only and unknown telemetry fields', async () => { + const storedRow = { + node_id: 'b'.repeat(64), + name: 'Public MQTT node', + last_seen: '2026-09-23T12:00:00.000Z', + battery_mv: 3900, + uptime_secs: 4200, + channel_utilization: 8, + air_util_tx: 4, + rx_air_secs: 18, + tx_air_secs: 9, + packets_24h: '12', + hardware_model: 'private model', + firmware_version: 'private firmware', + stats: { + wifi_ssid: 'private wifi', + reset_reason: 'private reset detail', + fs_free_bytes: 1234, + mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' }, + future_owner_field: 'private future value', + }, + future_status_field: 'private future value', + }; + const router = Router(); + registerMiscRoutes(router, { + query: async (text) => { + assert.doesNotMatch(text, /nss\.stats/); + return { rows: [storedRow as unknown as T] }; + }, + getRecentPackets: async () => [], + getRecentPacketEvents: async () => [], + getPacketDetail: async () => null, + getChannelMessageHistory: async () => [], + getPublicVisibilityGeneration: async () => 1, + packetDetailLimiter: passThroughLimiter as Parameters[1]['packetDetailLimiter'], + }); + const app = express(); + app.use(router); + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/mqtt-nodes?network=ukmesh`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), [{ + node_id: storedRow.node_id, + name: storedRow.name, + last_seen: storedRow.last_seen, + battery_mv: storedRow.battery_mv, + uptime_secs: storedRow.uptime_secs, + channel_utilization: storedRow.channel_utilization, + air_util_tx: storedRow.air_util_tx, + rx_air_secs: storedRow.rx_air_secs, + tx_air_secs: storedRow.tx_air_secs, + packets_24h: storedRow.packets_24h, + }]); + } finally { + server.close(); + } +}); diff --git a/backend/src/api/routes/misc.ts b/backend/src/api/routes/misc.ts index 1d6aa66..ddce527 100644 --- a/backend/src/api/routes/misc.ts +++ b/backend/src/api/routes/misc.ts @@ -16,6 +16,7 @@ import { decodePlannedNodeCursor, listPublicPlannedNodes, } from '../../repositories/plannedNodes.js'; +import { toPublicMqttNodeStatusDTO } from '../publicStatus.js'; type QueryFn = ( text: string, @@ -218,7 +219,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void { air_util_tx: number | null; rx_air_secs: number | null; tx_air_secs: number | null; - stats: Record | null; packets_24h: string; }>( @@ -232,7 +232,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void { nss.air_util_tx, nss.rx_air_secs, nss.tx_air_secs, - nss.stats, COALESCE(pc.packet_count, 0) AS packets_24h FROM node_identity_status_samples nss LEFT JOIN node_identity_nodes n ON n.node_id = nss.node_id @@ -250,7 +249,9 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void { ORDER BY nss.node_id, COALESCE(nss.uptime_secs, 0) DESC, nss.time DESC`, params, ); - res.json(result.rows.filter((r) => Number(r.packets_24h) > 0)); + res.json(result.rows + .filter((r) => Number(r.packets_24h) > 0) + .map(toPublicMqttNodeStatusDTO)); } catch (err) { console.error('[api] GET /mqtt-nodes', (err as Error).message); res.status(500).json({ error: 'Internal server error' }); diff --git a/backend/src/api/routes/nodeStatus.test.ts b/backend/src/api/routes/nodeStatus.test.ts new file mode 100644 index 0000000..f5c29d1 --- /dev/null +++ b/backend/src/api/routes/nodeStatus.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { toPublicLatestNodeStatusDTO } from '../publicStatus.js'; + +test('latest node status DTO omits owner-only and unknown telemetry fields', () => { + const storedRow = { + time: '2026-09-23T12:00:00.000Z', + node_id: 'a'.repeat(64), + network: 'ukmesh', + name: 'Public node', + iata: 'NCL', + battery_mv: 3910, + uptime_secs: 7200, + tx_air_secs: 14, + rx_air_secs: 28, + channel_utilization: 7, + air_util_tx: 3, + hardware_model: 'private model', + firmware_version: 'private firmware', + stats: { + wifi_ssid: 'private wifi', + reset_reason: 'private reset detail', + fs_free_bytes: 1234, + mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' }, + future_owner_field: 'private future value', + }, + future_status_field: 'private future value', + }; + + assert.deepEqual(toPublicLatestNodeStatusDTO(storedRow), { + time: storedRow.time, + node_id: storedRow.node_id, + network: storedRow.network, + name: storedRow.name, + iata: storedRow.iata, + battery_mv: storedRow.battery_mv, + uptime_secs: storedRow.uptime_secs, + tx_air_secs: storedRow.tx_air_secs, + rx_air_secs: storedRow.rx_air_secs, + channel_utilization: storedRow.channel_utilization, + air_util_tx: storedRow.air_util_tx, + }); +}); diff --git a/backend/src/api/routes/nodeStatus.ts b/backend/src/api/routes/nodeStatus.ts index 095642c..df881dc 100644 --- a/backend/src/api/routes/nodeStatus.ts +++ b/backend/src/api/routes/nodeStatus.ts @@ -7,6 +7,10 @@ import { parseBoundedInteger, parseBoundedString, } from '../utils/input.js'; +import { + toPublicLatestNodeStatusDTO, + type PublicLatestNodeStatusDTO, +} from '../publicStatus.js'; const router = Router(); @@ -26,28 +30,11 @@ router.get('/node-status/latest', async (req, res) => { } const identityWhereClause = `WHERE ${identityConditions.join(' AND ')}`; - const result = await query<{ - time: string; - node_id: string; - network: string | null; - battery_mv: number | null; - uptime_secs: number | null; - tx_air_secs: number | null; - rx_air_secs: number | null; - channel_utilization: number | null; - air_util_tx: number | null; - stats: Record | null; - name: string | null; - iata: string | null; - hardware_model: string | null; - firmware_version: string | null; - }>( + const result = await query( `WITH identity_sources AS MATERIALIZED ( SELECT n.node_id AS canonical_id, n.name, n.iata, - n.hardware_model, - n.firmware_version, source.source_node_id FROM node_identity_nodes n CROSS JOIN LATERAL unnest(n.identity_source_ids) AS source(source_node_id) @@ -63,15 +50,12 @@ router.get('/node-status/latest', async (req, res) => { nss.rx_air_secs, nss.channel_utilization, nss.air_util_tx, - nss.stats, s.name, - s.iata, - s.hardware_model, - s.firmware_version + s.iata FROM identity_sources s JOIN LATERAL ( SELECT time, network, battery_mv, uptime_secs, tx_air_secs, - rx_air_secs, channel_utilization, air_util_tx, stats + rx_air_secs, channel_utilization, air_util_tx FROM node_status_samples WHERE node_id = s.source_node_id AND network = ANY($1::text[]) @@ -85,7 +69,7 @@ router.get('/node-status/latest', async (req, res) => { params, ); - res.json(result.rows); + res.json(result.rows.map(toPublicLatestNodeStatusDTO)); } catch (err) { console.error('[api] GET /node-status/latest', (err as Error).message); res.status(500).json({ error: 'Internal server error' }); diff --git a/docs/privacy.md b/docs/privacy.md index 14f3e30..ac70e40 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -64,7 +64,23 @@ The service stores radio traffic observed on the MeshCore network. deletion procedure in the ops playbook (rows by src/rx node id across packets, packet_paths, decryptions, path facts). -## 3. Cookies +## 3. Device status telemetry + +Status samples retain a JSON telemetry object for the authenticated owner +portal. The unauthenticated `/node-status/latest` and `/mqtt-nodes` APIs expose +only their explicit public fields: node identity, sample/availability data, +battery, uptime, airtime, channel utilization, and recent packet counts. They +do not return the telemetry object or forward unrecognized fields. Wi-Fi +identifiers, MQTT broker/account details, reset and configuration fields, +firmware metadata, and filesystem details are available only through +owner-authenticated endpoints. Unrecognized keys stay in storage until they +are deliberately added to a response contract. + +Existing status samples do not need a database rewrite: public responses are +projected through the same field allowlist regardless of when the sample was +stored. + +## 4. Cookies LocalStorage-only choices (cookie consent, theme). No third-party cookies. PECR does not apply to the mesh radio data (not a public electronic