diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
index 7f34283..4549936 100644
Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ
diff --git a/app/pages/dev-login.vue b/app/pages/dev-login.vue
new file mode 100644
index 0000000..e92402c
--- /dev/null
+++ b/app/pages/dev-login.vue
@@ -0,0 +1,32 @@
+
+
+
+
+ {{ status }}
+
+
diff --git a/package.json b/package.json
index c946eb6..5e758f9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/api/dev/login.post.ts b/server/api/dev/login.post.ts
new file mode 100644
index 0000000..266473d
--- /dev/null
+++ b/server/api/dev/login.post.ts
@@ -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,
+ },
+ }
+})
diff --git a/server/middleware/auth.global.ts b/server/middleware/auth.global.ts
index 67a1386..7ee5707 100644
--- a/server/middleware/auth.global.ts
+++ b/server/middleware/auth.global.ts
@@ -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
}
diff --git a/server/utils/auth.ts b/server/utils/auth.ts
index 881580f..4feadf3 100644
--- a/server/utils/auth.ts
+++ b/server/utils/auth.ts
@@ -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) {
diff --git a/shared/utils/geo.ts b/shared/utils/geo.ts
new file mode 100644
index 0000000..7091f89
--- /dev/null
+++ b/shared/utils/geo.ts
@@ -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
+}
diff --git a/tests/shared/geo.test.ts b/tests/shared/geo.test.ts
new file mode 100644
index 0000000..6ec8ecc
--- /dev/null
+++ b/tests/shared/geo.test.ts
@@ -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)
+})
diff --git a/yarn.lock b/yarn.lock
index b1bfd78..a41319e 100644
--- a/yarn.lock
+++ b/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"