From 4daf9c4e6765c8299962819e5fe050588b7242e5 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Tue, 28 Jul 2026 07:58:44 +0200 Subject: [PATCH] feat(flightlab): serve bridge telemetry to the website process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlightLab lives on the website, /api/bridge/data lives here — since the split those are two processes, and the website's copy of the in-memory store never fills up again. The new service endpoint lets the website read this process's store over HTTP, guarded by the SERVICE_SECRET the delete webhook already uses. Pull, not push: the website asks only while somebody has a FlightLab screen open, so a running bridge costs nothing when nobody is watching. No user lookup is needed — the store keys on AppUser._id, which *is* the SSO subject. Also repairs `yarn test`, which could not start at all in this repo: the split carried tsconfig.tests.json across but not the tsconfig.scripts.json it extends. The options are inlined instead, since this repo has no scripts/ directory. Co-Authored-By: Claude Opus 5 --- server/api/service/flightlab-telemetry.get.ts | 37 +++++++++ .../server/flightlabTelemetryService.test.ts | 79 +++++++++++++++++++ tsconfig.tests.json | 12 ++- 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 server/api/service/flightlab-telemetry.get.ts create mode 100644 tests/server/flightlabTelemetryService.test.ts diff --git a/server/api/service/flightlab-telemetry.get.ts b/server/api/service/flightlab-telemetry.get.ts new file mode 100644 index 0000000..afac3c5 --- /dev/null +++ b/server/api/service/flightlab-telemetry.get.ts @@ -0,0 +1,37 @@ +import { createError, defineEventHandler, getQuery } from 'h3' +import { requireServiceSecret } from '../../utils/serviceAuth' +import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry' + +/** + * APP-SIDE. Latest bridge telemetry for one identity, for the website process. + * + * The bridge posts to /api/bridge/data, which lands in this process's + * in-memory store. FlightLab lives on the website and is therefore a different + * process since the repo split — it reads the store through this endpoint + * instead of importing it. + * + * Pull, not push: the website asks only while somebody is actually watching a + * FlightLab screen, so a running bridge costs nothing when nobody is. + * + * `subject` is the SSO subject, which is also the key the store uses: + * BridgeToken.user references AppUser._id, and AppUser._id *is* the subject + * (see server/models/AppUser.ts). + * + * GET /api/service/flightlab-telemetry?subject= + * x-service-secret: + */ +export default defineEventHandler((event) => { + requireServiceSecret(event) + + const subject = String(getQuery(event).subject || '').trim() + if (!subject) { + throw createError({ statusCode: 400, statusMessage: 'Missing subject' }) + } + + const telemetry = flightlabTelemetryStore.get(subject) + + return { + telemetry, + timestamp: telemetry?.timestamp ?? null, + } +}) diff --git a/tests/server/flightlabTelemetryService.test.ts b/tests/server/flightlabTelemetryService.test.ts new file mode 100644 index 0000000..0d93962 --- /dev/null +++ b/tests/server/flightlabTelemetryService.test.ts @@ -0,0 +1,79 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' + +import { flightlabTelemetryStore } from '~~/server/utils/flightlabTelemetry' +import handler from '~~/server/api/service/flightlab-telemetry.get' + +const originalSecret = process.env.SERVICE_SECRET + +function createEvent(subject: string | null, headers: Record = {}) { + const path = subject === null + ? '/api/service/flightlab-telemetry' + : `/api/service/flightlab-telemetry?subject=${encodeURIComponent(subject)}` + + return { + path, + node: { req: { headers, url: path } }, + context: {}, + } as any +} + +describe('/api/service/flightlab-telemetry handler', () => { + beforeEach(() => { + delete process.env.SERVICE_SECRET + }) + + afterEach(() => { + if (originalSecret === undefined) delete process.env.SERVICE_SECRET + else process.env.SERVICE_SECRET = originalSecret + }) + + it('refuses to run at all when no service secret is configured', async () => { + // Fail closed: bridge telemetry is user data and must never be readable + // just because an env var was forgotten. + await assert.rejects( + async () => handler(createEvent('507f1f77bcf86cd799439011')), + (error: any) => error?.statusCode === 503, + ) + }) + + it('rejects a wrong secret', async () => { + process.env.SERVICE_SECRET = 'the-real-secret' + + await assert.rejects( + async () => handler(createEvent('507f1f77bcf86cd799439011', { 'x-service-secret': 'nope' })), + (error: any) => error?.statusCode === 401, + ) + }) + + it('requires a subject', async () => { + process.env.SERVICE_SECRET = 'the-real-secret' + + await assert.rejects( + async () => handler(createEvent(null, { 'x-service-secret': 'the-real-secret' })), + (error: any) => error?.statusCode === 400, + ) + }) + + it('returns the latest telemetry the bridge posted for that subject', async () => { + process.env.SERVICE_SECRET = 'the-real-secret' + const subject = '507f1f77bcf86cd799439011' + flightlabTelemetryStore.update(subject, { AIRSPEED_INDICATED: 142 }) + + const result: any = await handler(createEvent(subject, { 'x-service-secret': 'the-real-secret' })) + + assert.equal(result.telemetry.AIRSPEED_INDICATED, 142) + assert.equal(typeof result.timestamp, 'number') + assert.equal(result.timestamp, result.telemetry.timestamp) + }) + + it('answers with null for a subject whose bridge never sent anything', async () => { + process.env.SERVICE_SECRET = 'the-real-secret' + + const result: any = await handler( + createEvent('507f1f77bcf86cd799439099', { 'x-service-secret': 'the-real-secret' }), + ) + + assert.deepEqual(result, { telemetry: null, timestamp: null }) + }) +}) diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 9743dcf..b11ae2f 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,13 +1,21 @@ { - "extends": "./tsconfig.scripts.json", "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "baseUrl": ".", "paths": { "#imports": ["./tests/stubs/nuxt-imports.ts"], "~~/*": ["./*"], "@@/*": ["./*"], "~/\*": ["./app/*"], "@/*": ["./app/*"] - } + }, + "types": ["node"] }, "include": ["server/**/*.ts", "shared/**/*.ts", "tests/**/*.ts"] }