mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
feat(flightlab): serve bridge telemetry to the website process
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 <noreply@anthropic.com>
This commit is contained in:
37
server/api/service/flightlab-telemetry.get.ts
Normal file
37
server/api/service/flightlab-telemetry.get.ts
Normal file
@@ -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=<id>
|
||||
* x-service-secret: <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,
|
||||
}
|
||||
})
|
||||
79
tests/server/flightlabTelemetryService.test.ts
Normal file
79
tests/server/flightlabTelemetryService.test.ts
Normal file
@@ -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<string, string> = {}) {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user