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