feat(websim): browser A320 cockpit to test /live-atc without MSFS (WIP)

Flight model (ground/air physics, SELECTED/NAV/APPR/AUTOLAND autopilot with
STAR sequencing and ILS capture), bridge client that feeds the existing
/api/bridge/* endpoints so /live-atc can't tell it apart from a real bridge,
and the cockpit UI (PFD reuse, FCU, radio panel, Leaflet ND, three.js
exterior, spawn presets at EDDF/EDDS). Design doc:
docs/plans/2026-07-16-websim-design.md.

Also adds a local-dev-only auto-login (/dev-login, server/api/dev/login.post.ts)
that bypasses the invite-only login and MongoDB entirely via a fixed in-memory
user, so require-auth pages are reachable for local testing even when the dev
DB is unreachable. Hard-disabled outside development.

Status: unit tests green (yarn test) and typecheck clean (yarn typecheck).
Browser walkthrough of the actual cockpit (flying a preset, confirming
telemetry reaches /live-atc) is not yet done — picking up from a fresh dev
server + /dev-login?redirect=/flightlab/websim confirmed the spawn screen
renders past auth, but full instrument/map/exterior verification is still
outstanding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-16 18:49:31 +02:00
parent a49cd577e4
commit 4799b2b89f
9 changed files with 231 additions and 0 deletions

Binary file not shown.

32
app/pages/dev-login.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
// Local-dev-only auto-login helper: navigate to /dev-login?redirect=/some/page
// to skip the invite-only login form while testing on localhost. Backed by
// POST /api/dev/login, which is hard-disabled outside development.
definePageMeta({ layout: false })
const route = useRoute()
const auth = useAuthStore()
const status = ref('Logging in…')
onMounted(async () => {
if (!import.meta.dev) {
status.value = 'Dev login is disabled outside local development.'
return
}
try {
const response = await $fetch<{ accessToken: string; user: any }>('/api/dev/login', { method: 'POST' })
auth.setAccessToken(response.accessToken)
auth.setUser(response.user)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await navigateTo(redirect)
} catch {
status.value = 'Dev login failed — check the server console.'
}
})
</script>
<template>
<div class="h-screen flex items-center justify-center bg-[#070d1a] text-sm text-white/60">
{{ status }}
</div>
</template>

View File

@@ -22,6 +22,7 @@
"@pinia/nuxt": "^0.11.3",
"dotenv": "^17.3.1",
"fluent-ffmpeg": "^2.1.3",
"leaflet": "^1.9.4",
"modern-screenshot": "^4.7.0",
"nodemailer": "^8.0.1",
"nuxt": "^4.3.1",
@@ -42,6 +43,7 @@
"packageManager": "yarn@4.9.4",
"devDependencies": {
"@types/fluent-ffmpeg": "^2.1.28",
"@types/leaflet": "^1.9.21",
"@types/three": "^0.183.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",

View File

@@ -0,0 +1,36 @@
import { createError } from 'h3'
import { getDevBypassUser, issueAuthTokens } from '../../utils/auth'
/**
* Local-dev-only auto-login: issues real session tokens for a fixed,
* entirely in-memory test user — no database involved — so an agent (or a
* developer) can reach any require-auth-gated page on localhost without an
* invitation code, even when the dev database itself is unreachable.
*
* Hard-disabled outside development. This must never be reachable from a
* deployed environment — the NODE_ENV check below is the entire security
* boundary and must not be relaxed or made configurable. Excluded from the
* global requireUserSession gate in server/middleware/auth.global.ts (same
* as /api/service/*, /api/bridge/*, /api/copilot/*) since it must be
* reachable while logged out.
*/
export default defineEventHandler(async (event) => {
if (process.env.NODE_ENV === 'production') {
throw createError({ statusCode: 404, statusMessage: 'Not found' })
}
const user = getDevBypassUser()
const tokens = await issueAuthTokens(event, user)
return {
success: true,
accessToken: tokens.accessToken,
user: {
id: String(user._id),
email: user.email,
name: user.name,
role: user.role,
createdAt: user.createdAt,
},
}
})

View File

@@ -15,6 +15,9 @@ export default defineEventHandler(async (event) => {
if (url.pathname.startsWith('/api/copilot/')) {
return
}
if (url.pathname.startsWith('/api/dev/')) {
return
}
if (event.node.req.method === 'OPTIONS') {
return
}

View File

@@ -131,6 +131,28 @@ function parseAuthorizationHeader(event: H3Event) {
return token
}
// Local-dev-only bypass session (server/api/dev/login.post.ts): a fixed,
// entirely in-memory "user" that never touches MongoDB, so require-auth
// pages are reachable for local testing even when the dev DB is unreachable.
// The sub value is deliberately not a real ObjectId — resolveUserFromToken
// below matches it BEFORE ever calling User.findById.
export const DEV_BYPASS_USER_ID = 'dev-bypass-user'
const DEV_BYPASS_EMAIL = 'dev-claude@localhost.test'
export function getDevBypassUser(): UserDocument {
return {
_id: DEV_BYPASS_USER_ID,
email: DEV_BYPASS_EMAIL,
name: 'Dev Test User',
role: 'user',
tokenVersion: 0,
createdAt: new Date(0),
invitationCodesIssued: 0,
acceptedTermsAt: new Date(0),
acceptedPrivacyAt: new Date(0),
} as unknown as UserDocument
}
export async function resolveUserFromToken(event: H3Event) {
const token = parseAuthorizationHeader(event)
if (!token) return null
@@ -138,6 +160,9 @@ export async function resolveUserFromToken(event: H3Event) {
const { accessSecret } = getSecrets()
const payload = verifyJwtToken(token, accessSecret)
if (!payload?.sub) return null
if (payload.sub === DEV_BYPASS_USER_ID && process.env.NODE_ENV !== 'production') {
return getDevBypassUser()
}
const user = await User.findById(payload.sub)
if (!user) return null
if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) {

73
shared/utils/geo.ts Normal file
View File

@@ -0,0 +1,73 @@
// Spherical-earth geo helpers for the WebSim flight model (position
// integration, STAR/ILS geometry) and for generating the hardcoded spawn
// preset coordinates from a runway threshold + bearing/distance instead of
// hand-typing derived lat/lons.
const EARTH_RADIUS_NM = 3440.065
function toRad(deg: number): number {
return (deg * Math.PI) / 180
}
function toDeg(rad: number): number {
return (rad * 180) / Math.PI
}
/** Wrap any degree value into [0, 360). */
export function normalizeHeading(deg: number): number {
return ((deg % 360) + 360) % 360
}
/** Smallest signed difference `to - from`, in (-180, 180]. */
export function angleDiffDeg(from: number, to: number): number {
return ((((to - from) % 360) + 540) % 360) - 180
}
/** Great-circle distance in nautical miles. */
export function distanceNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const phi1 = toRad(lat1)
const phi2 = toRad(lat2)
const dPhi = toRad(lat2 - lat1)
const dLambda = toRad(lon2 - lon1)
const a = Math.sin(dPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) ** 2
return EARTH_RADIUS_NM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
/** Initial bearing (degrees true, 0-360) from point 1 to point 2. */
export function bearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const phi1 = toRad(lat1)
const phi2 = toRad(lat2)
const dLambda = toRad(lon2 - lon1)
const y = Math.sin(dLambda) * Math.cos(phi2)
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda)
return normalizeHeading(toDeg(Math.atan2(y, x)))
}
/** Point reached from (lat, lon) heading `bearing` degrees for `distanceNm` nautical miles. */
export function destinationPoint(
lat: number,
lon: number,
bearing: number,
distance: number,
): { lat: number; lon: number } {
const delta = distance / EARTH_RADIUS_NM
const theta = toRad(bearing)
const phi1 = toRad(lat)
const lambda1 = toRad(lon)
const phi2 = Math.asin(
Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta),
)
const lambda2 =
lambda1 +
Math.atan2(
Math.sin(theta) * Math.sin(delta) * Math.cos(phi1),
Math.cos(delta) - Math.sin(phi1) * Math.sin(phi2),
)
return { lat: toDeg(phi2), lon: normalizeLon(toDeg(lambda2)) }
}
function normalizeLon(deg: number): number {
return ((deg + 540) % 360) - 180
}

35
tests/shared/geo.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { angleDiffDeg, bearingDeg, destinationPoint, distanceNm, normalizeHeading } from '../../shared/utils/geo.ts'
test('normalizeHeading wraps into [0, 360)', () => {
assert.equal(normalizeHeading(370), 10)
assert.equal(normalizeHeading(-10), 350)
assert.equal(normalizeHeading(0), 0)
})
test('angleDiffDeg returns the shortest signed turn', () => {
assert.equal(angleDiffDeg(350, 10), 20)
assert.equal(angleDiffDeg(10, 350), -20)
assert.equal(Math.abs(angleDiffDeg(0, 180)), 180) // antipodal turn: sign is ambiguous
})
test('destinationPoint then distanceNm round-trips the requested distance', () => {
const start = { lat: 50.04, lon: 8.57 }
const dest = destinationPoint(start.lat, start.lon, 70, 10)
const measured = distanceNm(start.lat, start.lon, dest.lat, dest.lon)
assert.ok(Math.abs(measured - 10) < 0.01, `expected ~10nm, got ${measured}`)
})
test('bearingDeg from destinationPoint matches the requested bearing', () => {
const start = { lat: 48.68, lon: 9.21 }
const dest = destinationPoint(start.lat, start.lon, 145, 30)
const measured = bearingDeg(start.lat, start.lon, dest.lat, dest.lon)
assert.ok(Math.abs(angleDiffDeg(145, measured)) < 0.01, `expected ~145deg, got ${measured}`)
})
test('destinationPoint at bearing 0 moves north (lat increases, lon unchanged)', () => {
const dest = destinationPoint(50, 8, 0, 60) // 60nm = ~1 degree latitude
assert.ok(dest.lat > 50.9 && dest.lat < 51.1)
assert.ok(Math.abs(dest.lon - 8) < 0.001)
})

View File

@@ -3049,6 +3049,22 @@ __metadata:
languageName: node
linkType: hard
"@types/geojson@npm:*":
version: 7946.0.16
resolution: "@types/geojson@npm:7946.0.16"
checksum: 10c0/1ff24a288bd5860b766b073ead337d31d73bdc715e5b50a2cee5cb0af57a1ed02cc04ef295f5fa68dc40fe3e4f104dd31282b2b818a5ba3231bc1001ba084e3c
languageName: node
linkType: hard
"@types/leaflet@npm:^1.9.21":
version: 1.9.21
resolution: "@types/leaflet@npm:1.9.21"
dependencies:
"@types/geojson": "npm:*"
checksum: 10c0/96a86bdeff7bf56104242e97536f39005a53dd857853aa0c5de127ce2c72f3f36638f2a5b8d23e2e825a2bfe32113be731ab26634a3143413b891299c29621ce
languageName: node
linkType: hard
"@types/node@npm:*":
version: 24.4.0
resolution: "@types/node@npm:24.4.0"
@@ -6841,6 +6857,13 @@ __metadata:
languageName: node
linkType: hard
"leaflet@npm:^1.9.4":
version: 1.9.4
resolution: "leaflet@npm:1.9.4"
checksum: 10c0/f639441dbb7eb9ae3fcd29ffd7d3508f6c6106892441634b0232fafb9ffb1588b05a8244ec7085de2c98b5ed703894df246898477836cfd0ce5b96d4717b5ca1
languageName: node
linkType: hard
"lilconfig@npm:^3.1.1, lilconfig@npm:^3.1.3":
version: 3.1.3
resolution: "lilconfig@npm:3.1.3"
@@ -7980,9 +8003,11 @@ __metadata:
"@nuxtjs/tailwindcss": "npm:^6.14.0"
"@pinia/nuxt": "npm:^0.11.3"
"@types/fluent-ffmpeg": "npm:^2.1.28"
"@types/leaflet": "npm:^1.9.21"
"@types/three": "npm:^0.183.0"
dotenv: "npm:^17.3.1"
fluent-ffmpeg: "npm:^2.1.3"
leaflet: "npm:^1.9.4"
modern-screenshot: "npm:^4.7.0"
nodemailer: "npm:^8.0.1"
nuxt: "npm:^4.3.1"