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 @@ + + + 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"