mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
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:
Binary file not shown.
32
app/pages/dev-login.vue
Normal file
32
app/pages/dev-login.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
36
server/api/dev/login.post.ts
Normal file
36
server/api/dev/login.post.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
73
shared/utils/geo.ts
Normal 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
35
tests/shared/geo.test.ts
Normal 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)
|
||||
})
|
||||
25
yarn.lock
25
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user