Unify bridge auth header and add live telemetry panel

This commit is contained in:
itsrubberduck
2026-02-15 16:04:07 +01:00
parent 57fc0e3150
commit ae0f0b0f86
7 changed files with 161 additions and 35 deletions

View File

@@ -196,6 +196,31 @@
<p class="mt-1 font-medium text-white">{{ formattedLastStatusAt }}</p>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-[#0B132A]/75">
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left"
@click="liveTelemetryOpen = !liveTelemetryOpen"
>
<div>
<p class="text-sm font-semibold text-white">Live telemetry details</p>
<p class="text-xs text-white/50">Last telemetry: {{ formattedLastTelemetryAt }}</p>
</div>
<span class="text-xs uppercase tracking-[0.24em] text-white/55">
{{ liveTelemetryOpen ? 'Hide' : 'Show' }}
</span>
</button>
<div v-if="liveTelemetryOpen" class="border-t border-white/10 px-4 py-3">
<p v-if="liveTelemetryLoading" class="text-xs uppercase tracking-[0.24em] text-white/45">Loading telemetry </p>
<p v-else-if="liveTelemetryError" class="text-sm text-red-300">{{ liveTelemetryError }}</p>
<p v-else-if="!liveTelemetry?.telemetry" class="text-sm text-white/60">No telemetry received yet.</p>
<pre
v-else
class="max-h-72 overflow-auto rounded-xl border border-white/10 bg-black/40 p-3 text-xs leading-5 text-[#9be6f2]"
><code>{{ liveTelemetryJson }}</code></pre>
</div>
</div>
</div>
<p v-if="statusLoading" class="mt-6 text-xs uppercase tracking-[0.38em] text-white/45">Refreshing </p>
@@ -226,6 +251,12 @@ interface BridgeStatusPayload {
lastStatusAt: string | null
}
interface BridgeLivePayload {
connected: boolean
lastTelemetryAt: string | null
telemetry: Record<string, any> | null
}
useHead({ title: 'Link Bridge · OpenSquawk' })
const route = useRoute()
@@ -241,6 +272,11 @@ const statusInitialized = ref(false)
const statusError = ref('')
const statusLoading = ref(false)
const statusRequestActive = ref(false)
const liveTelemetry = ref<BridgeLivePayload | null>(null)
const liveTelemetryOpen = ref(false)
const liveTelemetryLoading = ref(false)
const liveTelemetryError = ref('')
const liveTelemetryRequestActive = ref(false)
const copiedToken = ref(false)
@@ -277,6 +313,13 @@ const connectionSubLabel = computed(() => {
const formattedConnectedAt = computed(() => formatTimestamp(connectionStatus.value?.connectedAt ?? null))
const formattedLastStatusAt = computed(() => formatTimestamp(connectionStatus.value?.lastStatusAt ?? null))
const formattedLastTelemetryAt = computed(() => formatTimestamp(liveTelemetry.value?.lastTelemetryAt ?? null))
const liveTelemetryJson = computed(() => {
if (!liveTelemetry.value?.telemetry) {
return null
}
return JSON.stringify(liveTelemetry.value.telemetry, null, 2)
})
const successBannerVisible = computed(() => connectSuccess.value || Boolean(connectionStatus.value?.connected))
@@ -304,9 +347,9 @@ async function connectBridge() {
try {
await $fetch('/api/bridge/connect', {
method: 'POST',
body: { token: token.value },
headers: {
Authorization: `Bearer ${accessToken.value}`,
'x-bridge-token': token.value,
},
})
connectSuccess.value = true
@@ -337,7 +380,9 @@ async function fetchStatus(force = false) {
try {
const response = await $fetch<BridgeStatusPayload>('/api/bridge/me', {
params: { token: token.value },
headers: {
'x-bridge-token': token.value,
},
})
connectionStatus.value = response
statusError.value = ''
@@ -345,6 +390,7 @@ async function fetchStatus(force = false) {
if (response.connected) {
connectSuccess.value = true
}
await fetchLiveTelemetry(force)
} catch (err: any) {
statusError.value =
err?.data?.statusMessage ||
@@ -357,6 +403,39 @@ async function fetchStatus(force = false) {
}
}
async function fetchLiveTelemetry(force = false) {
if (!hasToken.value) {
return
}
if (liveTelemetryRequestActive.value) {
return
}
liveTelemetryRequestActive.value = true
if (force || !liveTelemetry.value) {
liveTelemetryLoading.value = true
}
try {
const response = await $fetch<BridgeLivePayload>('/api/bridge/live', {
headers: {
'x-bridge-token': token.value,
},
})
liveTelemetry.value = response
liveTelemetryError.value = ''
} catch (err: any) {
liveTelemetryError.value =
err?.data?.statusMessage ||
err?.response?._data?.statusMessage ||
err?.message ||
'We could not fetch live telemetry.'
} finally {
liveTelemetryRequestActive.value = false
liveTelemetryLoading.value = false
}
}
async function copyToken() {
if (!hasToken.value) return
if (typeof navigator === 'undefined' || !navigator.clipboard) return
@@ -397,6 +476,9 @@ watch(token, () => {
connectionStatus.value = null
statusInitialized.value = false
statusError.value = ''
liveTelemetry.value = null
liveTelemetryError.value = ''
liveTelemetryOpen.value = false
startPolling()
})

View File

@@ -1,21 +1,20 @@
import { createError, readBody } from 'h3'
import { createError } from 'h3'
import { requireUserSession } from '../../utils/auth'
import { normalizeBridgeToken } from '../../utils/bridge'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { BridgeToken } from '../../models/BridgeToken'
interface ConnectBody {
token?: string
}
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const body = await readBody<ConnectBody>(event)
const token = normalizeBridgeToken(body.token)
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Token übergeben.' })
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
console.info(
`\x1b[32m[bridge:connect]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m user=\x1b[92m${String(user._id)}\x1b[0m`,
)
const now = new Date()
const document = await BridgeToken.findOneAndUpdate(
@@ -47,4 +46,3 @@ export default defineEventHandler(async (event) => {
},
}
})

View File

@@ -1,11 +1,12 @@
import { defineEventHandler, readBody, getHeader } from 'h3'
import { resolveUserFromToken } from '../../utils/auth'
import { createError, defineEventHandler, readBody } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
/**
* Receives MSFS SimConnect telemetry data from an external bridge application.
*
* The bridge should POST telemetry data with an Authorization header so we
* The bridge should POST telemetry data with an x-bridge-token header so we
* can route the data to the correct FlightLab WebSocket session.
*
* ──────────────────────────────────────────
@@ -13,7 +14,7 @@ import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
* ──────────────────────────────────────────
*
* POST /api/bridge/data
* Authorization: Bearer <user-jwt-token>
* x-bridge-token: <bridge-token>
* Content-Type: application/json
*
* {
@@ -37,17 +38,23 @@ import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
* ──────────────────────────────────────────
*/
export default defineEventHandler(async (event) => {
// Resolve user from Bearer token (optional — also works with ?userId query param)
const user = await resolveUserFromToken(event)
const userId = user?._id?.toString()
?? new URL(event.node.req.url ?? '', 'http://localhost').searchParams.get('userId')
const bridgeToken = getBridgeTokenFromHeader(event)
if (!bridgeToken) {
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
const bridgeDocument = await BridgeToken.findOne({ token: bridgeToken }).select('user')
const userId = bridgeDocument?.user?.toString() ?? null
if (!userId) {
event.node.res.statusCode = 401
return { error: 'Authorization required — send Bearer token or ?userId query param' }
throw createError({ statusCode: 401, statusMessage: 'Bridge-Token ist nicht mit einem Nutzer verknüpft.' })
}
const body = await readBody(event)
const telemetryKeys = body && typeof body === 'object' ? Object.keys(body as Record<string, unknown>) : []
console.info(
`\x1b[35m[bridge:data]\x1b[0m token=\x1b[96m${bridgeToken.slice(0, 6)}...\x1b[0m user=\x1b[92m${userId}\x1b[0m telemetryKeys=\x1b[92m${telemetryKeys.length}\x1b[0m payload=`,
body,
)
// Store telemetry and broadcast to WebSocket subscribers
flightlabTelemetryStore.update(userId, body)

View File

@@ -0,0 +1,33 @@
import { createError } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
export default defineEventHandler(async (event) => {
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
const bridgeDocument = await BridgeToken.findOne({ token }).select('user')
const userId = bridgeDocument?.user?.toString() ?? null
if (!userId) {
return {
connected: false,
lastTelemetryAt: null,
telemetry: null,
}
}
const telemetry = flightlabTelemetryStore.get(userId)
const timestamp = telemetry && typeof telemetry.timestamp === 'number'
? new Date(telemetry.timestamp).toISOString()
: null
return {
connected: true,
lastTelemetryAt: timestamp,
telemetry,
}
})

View File

@@ -1,16 +1,17 @@
import { createError, getQuery } from 'h3'
import { createError } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { normalizeBridgeToken } from '../../utils/bridge'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import type { UserDocument } from '../../models/User'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const token = normalizeBridgeToken(query.token as string | undefined)
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 400, statusMessage: 'Token muss übergeben werden.' })
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
console.info(`\x1b[36m[bridge:me]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m request received`)
const document = await BridgeToken.findOne({ token }).populate('user', 'name email')
if (!document || !document.user) {
@@ -41,4 +42,3 @@ export default defineEventHandler(async (event) => {
lastStatusAt: document.lastStatusAt ? document.lastStatusAt.toISOString() : null,
}
})

View File

@@ -1,22 +1,24 @@
import { createError, readBody } from 'h3'
import { BridgeToken } from '../../models/BridgeToken'
import { normalizeBridgeToken } from '../../utils/bridge'
import { getBridgeTokenFromHeader } from '../../utils/bridge'
import type { UserDocument } from '../../models/User'
interface StatusBody {
token?: string
simConnected?: boolean
flightActive?: boolean
}
export default defineEventHandler(async (event) => {
const body = await readBody<StatusBody>(event)
const token = normalizeBridgeToken(body.token)
const token = getBridgeTokenFromHeader(event)
if (!token) {
throw createError({ statusCode: 400, statusMessage: 'Token muss übergeben werden.' })
throw createError({ statusCode: 401, statusMessage: 'x-bridge-token header fehlt oder ist ungültig.' })
}
const body = await readBody<StatusBody>(event)
console.info(
`\x1b[33m[bridge:status]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m simConnected=\x1b[92m${String(body.simConnected)}\x1b[0m flightActive=\x1b[92m${String(body.flightActive)}\x1b[0m`,
)
const updatePayload: Record<string, unknown> = {
lastStatusAt: new Date(),
}
@@ -60,4 +62,3 @@ export default defineEventHandler(async (event) => {
lastStatusAt: document.lastStatusAt ? document.lastStatusAt.toISOString() : null,
}
})

View File

@@ -1,3 +1,5 @@
import { getHeader, type H3Event } from 'h3'
export function normalizeBridgeToken(input: unknown) {
if (typeof input !== 'string') {
return null
@@ -12,3 +14,6 @@ export function normalizeBridgeToken(input: unknown) {
return token
}
export function getBridgeTokenFromHeader(event: H3Event) {
return normalizeBridgeToken(getHeader(event, 'x-bridge-token'))
}