From c8c2365c4d04d2aeb409135261e120dc84caba61 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Mon, 27 Jul 2026 19:17:27 +0200 Subject: [PATCH] refactor(split): make the app self-hosting ready Remove the transitional website auth, admin hooks, SEO and hosted analytics together, then align the app routes, runtime configuration, tests and dependencies. These changes form one atomic cleanup because the filtered app must switch its identity and runtime surfaces as a unit. --- .env.example | 117 ++----- app/app.vue | 169 +--------- app/components/CookieConsentBanner.vue | 443 ------------------------- app/composables/useCookieConsent.ts | 98 ------ app/composables/useLiveAtcSession.ts | 82 +---- app/pages/dev-login.vue | 5 +- app/pages/{start.vue => index.vue} | 0 app/pages/live-atc.vue | 6 - app/pages/pilot-profile-setup.vue | 2 +- app/pages/pm.vue | 3 +- app/stores/auth.ts | 36 +- nuxt.config.ts | 45 +-- package.json | 11 +- server/api/auth/logout.post.ts | 20 -- server/api/auth/me.get.ts | 3 - server/api/auth/refresh.post.ts | 8 +- server/api/auth/sso/callback.post.ts | 2 +- server/api/bug-reports/index.post.ts | 6 +- server/api/dev/login.post.ts | 27 +- server/utils/auth.ts | 222 ++----------- server/utils/authMode.ts | 11 +- server/utils/bridge.ts | 28 +- server/utils/notifications.ts | 93 +----- server/utils/session.ts | 24 +- shared/utils/seo.ts | 199 ----------- tests/server/auth.test.ts | 257 ++------------ tests/smoke/apiHandlers.smoke.test.ts | 33 -- tests/stubs/nuxt-imports.ts | 2 - yarn.lock | 209 +----------- 29 files changed, 123 insertions(+), 2038 deletions(-) delete mode 100644 app/components/CookieConsentBanner.vue delete mode 100644 app/composables/useCookieConsent.ts rename app/pages/{start.vue => index.vue} (100%) delete mode 100644 shared/utils/seo.ts diff --git a/.env.example b/.env.example index 4a7e476..747b7ce 100644 --- a/.env.example +++ b/.env.example @@ -1,104 +1,49 @@ # Runtime NODE_ENV=development MONGODB_URI=mongodb://127.0.0.1:27017/opensquawk +NUXT_PUBLIC_RADIO_BACKEND_URL=http://127.0.0.1:8000 # Authentication -# Generate strong, unique random values — e.g. `openssl rand -hex 32`. -# The server refuses to start in production if these are unset, look like a -# placeholder, or are shorter than 32 characters. +# Open mode is the self-hosted default: one persistent local identity, no login. +AUTH_MODE=open +# Generate strong, unique values, for example with `openssl rand -hex 32`. JWT_SECRET=CHANGE_ME -JWT_REFRESH_SECRET=CHANGE_ME - -# How this deployment obtains an identity. -# open — self-hosted: no login at all, every request is one local user. -# sso — identity handed over from NUXT_PUBLIC_AUTH_ISSUER via a one-time code. -# Leave this at `sso` for as long as the website surface (/api/admin/**, -# /api/editor/**) lives in this repo: `open` would serve those to everyone. -AUTH_MODE=sso -# Issuer for AUTH_MODE=sso. Empty means website and app share one origin and -# the local /login page is used. +APP_JWT_SECRET=CHANGE_ME +# Only needed when AUTH_MODE=sso. NUXT_PUBLIC_AUTH_ISSUER= -# Secret for the app's own session cookie. Falls back to JWT_SECRET when unset; -# set it explicitly once the app runs on its own origin. -APP_JWT_SECRET= - -# SSO issuer side (website). Comma-separated allowlist of origins an SSO code -# may be issued for. No default and no wildcard — empty disables the handoff. -# Without this the issuer would be an open redirector handing out identities. -SSO_REDIRECT_ORIGINS= - -# OpenAI -OPENAI_API_KEY=sk-your-openai-key -OPENAI_PROJECT= -OPENAI_BASE_URL= -# Optional: specify a custom API endpoint, e.g. http://localhost:1234/v1 -LLM_MODEL=gpt-5-nano -TTS_MODEL=tts-1 -VOICE_ID=alloy -# Model used by the /api/decision/route LLM router (the Python backend calls -# this when regex routing misses a pilot transmission). -ROUTER_LLM_MODEL=gpt-5-mini - -# Internal service-to-service auth. The Python decision backend calls -# /api/decision/route with this value in the `x-service-secret` header. Must -# match SERVICE_SECRET in the Python backend's env. If unset, the LLM router is -# disabled (the backend falls back to deterministic bad_next routing). SERVICE_SECRET=CHANGE_ME -# Telemetry mirror (app → hosted service). Both this AND SERVICE_SECRET must be -# set for anything to be sent; leave empty and nothing ever leaves the instance. -# That is the self-host default, not a fallback. +# OpenAI-compatible services +OPENAI_API_KEY= +OPENAI_PROJECT= +OPENAI_BASE_URL= +OPENAIP_API_KEY= +LLM_MODEL=gpt-5-nano +ROUTER_LLM_MODEL=gpt-5-mini +TTS_MODEL=tts-1 +VOICE_ID=alloy + +# Optional telemetry mirror. Empty means nothing leaves this instance. TELEMETRY_URL= -# Account-deletion webhook (website → app instance). Base URL of the app -# deployment; the website POSTs /api/service/user-deleted there when an account -# is deleted. Empty means website and app share one database and the website's -# own deletes already cover everything. -APP_WEBHOOK_URL= - -# PM radio training -# Minimum word count for a voice (PTT) transmission to be used; shorter -# transcripts are treated as STT noise/hallucination and ignored. Set to 1 to -# disable. See the "STT MINIMUM-WORD GATE" in app/pages/pm.vue. -NUXT_PUBLIC_PTT_MIN_WORDS=2 - -# ATC audio generation -ATC_OUT_DIR=./storage/atc -FLIGHTLAB_TTS_CACHE_DIR=./.cache/flightlab-tts -USE_SPEACHES=false -USE_PIPER=false -PIPER_PORT=5001 -SPEACHES_BASE_URL= -SPEECH_MODEL_ID=speaches-ai/piper-en_US-ryan-low -# Optional: external webhook for bridge dome-light telemetry. Leave empty to -# disable — there is deliberately no default, so no instance forwards cockpit -# telemetry anywhere unless its operator asks for it. -DOME_LIGHT_WEBHOOK_URL= - -# Analytics (website). Without a Hotjar ID the module is not loaded at all. -HOTJAR_ID= - -# Notifications -NOTIFY_RESEND_API_KEY= -NOTIFY_EMAIL_TO= -NOTIFY_EMAIL_FROM="OpenSquawk " +# Bug reports stay in MongoDB unless both a recipient and SMTP are configured. +BUG_REPORT_NOTIFY_EMAIL= +NOTIFY_EMAIL_FROM="OpenSquawk " NOTIFY_SMTP_HOST= NOTIFY_SMTP_PORT=587 NOTIFY_SMTP_SECURE=false NOTIFY_SMTP_USER= NOTIFY_SMTP_PASS= -# Where bug reports are mailed. Unset means no mail is sent at all — reports -# then live only in this instance's own database. -BUG_REPORT_NOTIFY_EMAIL= -# Bootstrap invitations -BOOTSTRAP_INVITE_DEADLINE=2025-09-01T00:00:00Z +# Speech and audio +NUXT_PUBLIC_PTT_MIN_WORDS=2 +ATC_OUT_DIR=./storage/atc +FLIGHTLAB_TTS_CACHE_DIR=./.cache/flightlab-tts +USE_SPEACHES=false +SPEACHES_BASE_URL= +SPEECH_MODEL_ID=speaches-ai/piper-en_US-ryan-low +USE_PIPER=false +PIPER_PORT=5001 -# Manual invitation generator -MANUAL_INVITE_PASSWORD=CHANGE_ME - -# Cron / scheduled tasks -# Required for the /api/service/cron/* endpoints (they send emails and mint -# invite codes). Without it those endpoints return 503. Pass it via the -# `x-cron-secret` header (preferred) or `?secret=` query param. -CRON_SECRET=CHANGE_ME +# Optional cockpit dome-light webhook. Empty disables forwarding. +DOME_LIGHT_WEBHOOK_URL= diff --git a/app/app.vue b/app/app.vue index 9da2cea..33096d0 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,151 +1,17 @@ - - diff --git a/app/composables/useCookieConsent.ts b/app/composables/useCookieConsent.ts deleted file mode 100644 index a094313..0000000 --- a/app/composables/useCookieConsent.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { computed, watch } from 'vue'; - -type CookieConsentPreferences = { - necessary: true; - analytics: boolean; -}; - -type CookieConsentValue = { - version: number; - updatedAt: string; - preferences: CookieConsentPreferences; -}; - -const CONSENT_COOKIE_NAME = 'osq-cookie-consent'; -const CONSENT_VERSION = 1; -const SIX_MONTHS_IN_SECONDS = 60 * 60 * 24 * 180; -export const HOTJAR_LOCAL_STORAGE_KEY = 'osq-hotjar-consent'; - -export const useCookieConsent = () => { - const consentCookie = useCookie(CONSENT_COOKIE_NAME, { - sameSite: 'lax', - path: '/', - maxAge: SIX_MONTHS_IN_SECONDS, - secure: !process.dev, - }); - - if (consentCookie.value && consentCookie.value.version !== CONSENT_VERSION) { - consentCookie.value = null; - } - - const consentState = useState('cookie-consent', () => consentCookie.value ?? null); - - watch( - consentState, - (value) => { - consentCookie.value = value; - }, - { deep: true } - ); - - if (process.client) { - watch( - consentState, - (value) => { - if (!value) { - window.localStorage.removeItem(HOTJAR_LOCAL_STORAGE_KEY); - return; - } - - window.localStorage.setItem( - HOTJAR_LOCAL_STORAGE_KEY, - value.preferences.analytics ? 'granted' : 'denied' - ); - }, - { deep: true } - ); - } - - const hasConsent = computed(() => consentState.value !== null); - - const analyticsEnabled = computed(() => { - if (!consentState.value) { - return false; - } - - return consentState.value.preferences.analytics === true; - }); - - const savePreferences = (preferences: { analytics: boolean }) => { - consentState.value = { - version: CONSENT_VERSION, - updatedAt: new Date().toISOString(), - preferences: { - necessary: true, - analytics: preferences.analytics, - }, - }; - }; - - const acceptAll = () => savePreferences({ analytics: true }); - - const rejectAll = () => savePreferences({ analytics: false }); - - const resetConsent = () => { - consentState.value = null; - consentCookie.value = null; - }; - - return { - consent: consentState, - hasConsent, - analyticsEnabled, - acceptAll, - rejectAll, - savePreferences, - resetConsent, - }; -}; diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 81d35f0..17494c4 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -241,8 +241,8 @@ export function useLiveAtcSession( } // Guards the ATC reply (log entry + TTS) against being applied twice for the - // same decision — applyBackendDecision can be reached from four sources - // (transmit reply, telemetry tick, silence timeout, bug-report restore) and + // same decision — applyBackendDecision can be reached from three sources + // (transmit reply, telemetry tick and silence timeout), and // two of them can legitimately fire back-to-back for the same outcome (e.g. // a silence timeout racing an in-flight transmit reply), which otherwise // spoke/logged the same confirmation 2-3x (design doc WP1 Fix 2). @@ -842,83 +842,6 @@ export function useLiveAtcSession( await handlePilotTransmission(text, 'text') } - /** - * Restore a /live-atc session from a saved bug-report snapshot (admin link - * `/live-atc?restoreBugReport=`). The Python backend has no "resume mid-session" - * endpoint, so we recreate a real, working session for the SAME flight and - * scenario via startMonitoring(), then overlay the saved variables/flags and - * the captured conversation so the admin can reproduce and try out the bug. - */ - async function restoreBugReportState(restoreId: string) { - try { - const report = await api.get(`/api/admin/bug-reports/${restoreId}`) - const state = report?.pmState - if (!state) { - error.value = 'Bug-Report enthält keinen gespeicherten State.' - return - } - - // Locate the scenario the report was captured in. - const scenario = - SCENARIOS.find(s => s.id === state.scenarioId) || - SCENARIOS.find(s => s.startFlow === state.flowSlug) - if (!scenario) { - error.value = `Bug-Report-Restore: Szenario "${state.scenarioId || state.flowSlug || '?'}" nicht gefunden.` - return - } - - // Reconstruct a flight plan from the snapshot so startMonitoring resolves the - // correct airport/frequencies and creates a backend session for the same flight. - const v = state.variables || {} - const fc = state.flightContext || {} - const dep = v.dep || fc.dep - const dest = v.dest || fc.dest - const flightPlan: Record = { - callsign: v.callsign || fc.callsign || 'UNKNOWN', - aircraft: v.acf_type || fc.acf_type || 'A320', - dep, - departure: dep, - arr: dest, - arrival: dest, - route: fc.route || v.route || '', - assignedsquawk: v.squawk, - } - - // Spin up a real session (loads tree, fetches frequencies, creates backend session). - await startMonitoring(flightPlan, scenario) - // startMonitoring bails out on error without entering the monitor screen. - if (currentScreen.value !== 'monitor') return - - // Overlay the exact saved values over the freshly generated ones (stand, SID, …). - if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables) - if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags) - - // Restore the captured conversation for context. - clearCommunicationLog?.() - if (Array.isArray(state.communicationLog)) { - for (const e of state.communicationLog) { - if (!e?.message) continue - appendLogEntry(e.speaker || 'system', e.message, e.state || '', { - frequency: e.frequency, - flow: e.flow, - radioCheck: e.radioCheck, - offSchema: e.offSchema, - }) - } - } - - // A fresh backend session always starts at the flow's start state, so we - // can't fake the local cursor onto the captured mid-flow state without - // desyncing transmits. Tell the admin where the bug was captured instead. - error.value = - `Bug-Report wiederhergestellt: ${scenario.name} · ${flightPlan.callsign} (${dep || '?'}→${dest || '?'}). ` + - `Erfasster State: ${state.currentStateId || '?'} (Flow ${state.flowSlug || '?'}).` - } catch (err) { - console.warn('[PM] Bug report restore failed', err) - error.value = 'Bug-Report konnte nicht wiederhergestellt werden.' - } - } - onUnmounted(() => { clearSilenceTimer() clearAutoTune() @@ -938,6 +861,5 @@ export function useLiveAtcSession( flyAgain, backToSetup, sendPilotText, - restoreBugReportState, } } diff --git a/app/pages/dev-login.vue b/app/pages/dev-login.vue index e92402c..c22d838 100644 --- a/app/pages/dev-login.vue +++ b/app/pages/dev-login.vue @@ -1,7 +1,6 @@