diff --git a/.env.example b/.env.example index d602d26..4a7e476 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,24 @@ MONGODB_URI=mongodb://127.0.0.1:27017/opensquawk 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. +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= @@ -27,6 +45,17 @@ ROUTER_LLM_MODEL=gpt-5-mini # 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. +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 @@ -41,9 +70,14 @@ 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. +# 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= @@ -53,6 +87,9 @@ 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 diff --git a/.gitignore b/.gitignore index a70a034..27b35c1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ storage/** # Worktrees .worktrees +.claude/worktrees + +# Lokaler Zwei-DB-Teststack (scripts/dev-local-stack.sh) +.local-stack diff --git a/app/app.vue b/app/app.vue index d5f55df..9da2cea 100644 --- a/app/app.vue +++ b/app/app.vue @@ -6,7 +6,7 @@ + + diff --git a/app/stores/auth.ts b/app/stores/auth.ts index c375ff5..99f0b33 100644 --- a/app/stores/auth.ts +++ b/app/stores/auth.ts @@ -51,7 +51,10 @@ export const useAuthStore = defineStore('auth', { initialized: false, }), getters: { - isAuthenticated: (state) => Boolean(state.accessToken), + // A session can exist without a bearer token in hand: in AUTH_MODE=open + // there is never one, and in sso mode the durable session is an httpOnly + // cookie that JS cannot see. Once the user is loaded, we are authenticated. + isAuthenticated: (state) => Boolean(state.accessToken || state.user), }, actions: { setAccessToken(token: string) { @@ -79,9 +82,23 @@ export const useAuthStore = defineStore('auth', { this.setUser(response.user) return response.user }, + // PHASE 1 (app repo): login() and register() go away with the login page — + // the app never owns credentials. fetchUser/tryRefresh/logout stay. + async ssoCallback(code: string) { + const response = await $fetch<{ accessToken: string; user: AuthUser }>('/api/auth/sso/callback', { + method: 'POST', + body: { code }, + }) + this.setAccessToken(response.accessToken) + this.setUser(response.user) + this.initialized = true + return response.user + }, async tryRefresh() { try { - const response = await $fetch<{ accessToken: string }>('/api/service/auth/refresh', { + // One endpoint for every mode — the server decides what a session is + // (local identity, app cookie, or the website's refresh cookie). + const response = await $fetch<{ accessToken: string }>('/api/auth/refresh', { method: 'POST', }) this.setAccessToken(response.accessToken) @@ -92,20 +109,22 @@ export const useAuthStore = defineStore('auth', { } }, async fetchUser() { - if (!this.accessToken) { - this.setUser(null) - this.initialized = true - return null - } try { + // No bearer token is not the same as no session: the app's session + // cookie and AUTH_MODE=open both authenticate without one, so ask the + // server instead of deciding here. const user = await $fetch('/api/auth/me', { - headers: { Authorization: `Bearer ${this.accessToken}` }, + headers: this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : undefined, }) this.setUser(user) this.initialized = true return user } catch (err) { - console.warn('Failed to load user session', err) + // A 401 for a visitor who never had a session is the expected answer, + // not a fault worth logging. + if (this.accessToken) { + console.warn('Failed to load user session', err) + } this.setAccessToken('') this.setUser(null) this.initialized = true @@ -114,12 +133,11 @@ export const useAuthStore = defineStore('auth', { }, async logout() { try { - if (this.accessToken) { - await $fetch('/api/auth/logout', { - method: 'POST', - headers: { Authorization: `Bearer ${this.accessToken}` }, - }) - } + // Called unconditionally: the session may be a cookie we cannot see. + await $fetch('/api/auth/logout', { + method: 'POST', + headers: this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : undefined, + }) } catch (err) { console.warn('Logout failed', err) } finally { diff --git a/nuxt.config.ts b/nuxt.config.ts index b55711f..361bbc7 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,4 +1,12 @@ // nuxt.config.ts + +// Analytics is opt-in and belongs to whoever runs the instance. The module +// itself is inert — it only registers a composable — so it stays loaded for the +// auto-import; what matters is that without HOTJAR_ID there is no ID to +// initialize with, and app.vue never starts it. A self-hosted deployment can +// therefore not silently ship its users' sessions to somebody else's account. +const hotjarId = Number(process.env.HOTJAR_ID || 0) + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: {enabled: false}, @@ -28,7 +36,8 @@ export default defineNuxtConfig({ 'nuxt-module-hotjar', ], hotjar: { - hotjarId: 6522897, + // No default: an unset HOTJAR_ID leaves analytics off entirely. + hotjarId, scriptVersion: 6, debug: process.env.NODE_ENV !== 'production', }, @@ -37,6 +46,7 @@ export default defineNuxtConfig({ routeRules: { '/app/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } }, '/admin/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } }, + '/auth/callback': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } }, '/bridge/connect': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } }, '/classroom/**': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } }, '/classroom-introduction': { headers: { 'X-Robots-Tag': 'noindex, nofollow' } }, @@ -68,7 +78,10 @@ export default defineNuxtConfig({ useSpeaches: process.env.USE_SPEACHES, speachesBaseUrl: process.env.SPEACHES_BASE_URL, speechModelId: process.env.SPEECH_MODEL_ID, - domeLightWebhookUrl: process.env.DOME_LIGHT_WEBHOOK_URL || 'https://home.io.faktorxmensch.com/api/webhook/lidl_stab_3modi_8492', + // No default: this forwards cockpit telemetry to a third-party webhook. + // Empty means the forwarding is off, which is what a foreign instance + // must get. + domeLightWebhookUrl: process.env.DOME_LIGHT_WEBHOOK_URL || '', jwtSecret: process.env.JWT_SECRET, jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET, manualInvitePassword: process.env.MANUAL_INVITE_PASSWORD, @@ -77,6 +90,12 @@ export default defineNuxtConfig({ options: {}, }, public: { + // 'open' = self-hosted, no login at all. 'sso' = identity handed + // over by authIssuer. Server-side source of truth is + // server/utils/authMode.ts; this mirror is what the client + // middleware and auth store branch on. + authMode: process.env.AUTH_MODE || 'sso', + authIssuer: process.env.NUXT_PUBLIC_AUTH_ISSUER || '', apiDocumentationUrl: '/api-docs', radioBackendUrl: process.env.NUXT_PUBLIC_RADIO_BACKEND_URL || 'http://127.0.0.1:8000', // Minimum word count for a voice (PTT) transmission to be used. Below diff --git a/server/api/analytics/product-session.post.ts b/server/api/analytics/product-session.post.ts new file mode 100644 index 0000000..498fd8e --- /dev/null +++ b/server/api/analytics/product-session.post.ts @@ -0,0 +1,77 @@ +import { createError, defineEventHandler, readBody } from 'h3' +import { getUserFromEvent } from '../../utils/auth' +import { ProductUsageSession } from '../../models/ProductUsageSession' +import { emit as emitTelemetry } from '../../utils/telemetry' + +/** + * How long the user spent in Classroom / Live ATC. + * + * Moved off the website's /api/service/analytics/* path: that one was reachable + * unauthenticated from any browser, and post-split it lives in a different + * repo. The app now writes to its own database and mirrors through the ordinary + * telemetry channel — so a self-hosted instance keeps this data entirely to + * itself. + */ + +interface ProductSessionBody { + product?: string + path?: string + durationSeconds?: number + startedAt?: string + endedAt?: string +} + +function cleanPath(value: unknown) { + return typeof value === 'string' ? value.trim().slice(0, 180) : undefined +} + +function parseDate(value: unknown, fallback: Date) { + if (typeof value !== 'string') { + return fallback + } + + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? fallback : parsed +} + +export default defineEventHandler(async (event) => { + const user = await getUserFromEvent(event) + const body = await readBody(event).catch(() => ({} as ProductSessionBody)) + const product = body.product === 'liveatc' ? 'liveatc' : body.product === 'classroom' ? 'classroom' : null + + if (!product) { + throw createError({ statusCode: 400, statusMessage: 'Valid product is required.' }) + } + + const durationSeconds = + typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds) + ? Math.max(1, Math.min(60 * 60 * 6, Math.round(body.durationSeconds))) + : 0 + + if (durationSeconds < 5) { + return { success: true, ignored: true } + } + + const endedAt = parseDate(body.endedAt, new Date()) + const startedAt = parseDate(body.startedAt, new Date(endedAt.getTime() - durationSeconds * 1000)) + + const record = { + user: user?._id, + product, + path: cleanPath(body.path), + durationSeconds, + startedAt, + endedAt, + } + + await ProductUsageSession.create(record) + + emitTelemetry('product-usage-session', { + ...record, + user: user?._id ? String(user._id) : undefined, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + }) + + return { success: true } +}) diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index 410a692..5b2888a 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -8,6 +8,7 @@ import { execFile } from "node:child_process"; import { getOpenAIClient } from "../../utils/openai"; import { createReadStream } from "node:fs"; import { TransmissionLog } from "../../models/TransmissionLog"; +import { emit as emitTelemetry } from "../../utils/telemetry"; import { getUserFromEvent } from "../../utils/auth"; import { enforceRateLimit, getClientIp } from "../../utils/rateLimit"; import { recordUsage } from "../../utils/usage"; @@ -215,23 +216,33 @@ export default defineEventHandler(async (event) => { await rm(tmpAudioWav).catch(() => {}); } + const transmission = { + user: user?._id, + role: "pilot", + channel: "ptt", + direction: "incoming", + text: transcribedText, + sessionId, + metadata: { + moduleId: body.moduleId, + lessonId: body.lessonId, + }, + }; + try { - await TransmissionLog.create({ - user: user?._id, - role: "pilot", - channel: "ptt", - direction: "incoming", - text: transcribedText, - sessionId, - metadata: { - moduleId: body.moduleId, - lessonId: body.lessonId, - }, - }); + // Local DB first — this instance's own data, always written. + await TransmissionLog.create(transmission); } catch (logError) { console.warn("Transmission logging failed", logError); } + // Mirrored to the hosted service only if this instance is configured + // for it. Fire-and-forget: never awaited, never able to fail the request. + emitTelemetry('transmission-log', { + ...transmission, + user: user?._id ? String(user._id) : undefined, + }); + return { success: true, transcription: transcribedText } satisfies PTTResponse; } catch (error: any) { diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index 04c7134..8d0835c 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -7,6 +7,7 @@ import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize"; import { getServerRuntimeConfig } from "../../utils/runtimeConfig"; import {request} from "node:http"; import { TransmissionLog } from "../../models/TransmissionLog"; +import { emit as emitTelemetry } from "../../utils/telemetry"; import { requireUserSession } from "../../utils/auth"; import { enforceRateLimit } from "../../utils/rateLimit"; import { recordUsage } from "../../utils/usage"; @@ -376,35 +377,42 @@ export default defineEventHandler(async (event) => { characters: normalized.length, }); - try { - await TransmissionLog.create({ - user: user._id, - role: "atc", - channel: "say", - direction: "outgoing", - text: raw, - normalized, - sessionId, - metadata: { - level, - voice, - speed, - moduleId: body?.moduleId || null, - lessonId: body?.lessonId || null, - tag: body?.tag || null, - radioQuality: radioQuality.description, - tts: { - provider: ttsProvider, - model: modelUsed, - format: actualMime, - extension: outputExt - } + const transmission = { + user: user._id, + role: "atc", + channel: "say", + direction: "outgoing", + text: raw, + normalized, + sessionId, + metadata: { + level, + voice, + speed, + moduleId: body?.moduleId || null, + lessonId: body?.lessonId || null, + tag: body?.tag || null, + radioQuality: radioQuality.description, + tts: { + provider: ttsProvider, + model: modelUsed, + format: actualMime, + extension: outputExt } - }) + } + } + + try { + // Local DB first — this instance's own data, always written. + await TransmissionLog.create(transmission) } catch (logError) { console.warn("Transmission logging failed", logError) } + // Mirrored to the hosted service only if this instance is configured + // for it. Fire-and-forget: never awaited, never able to fail the request. + emitTelemetry('transmission-log', { ...transmission, user: String(user._id) }) + return { success: true, id, diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts index e0ae6ec..57e5f28 100644 --- a/server/api/auth/logout.post.ts +++ b/server/api/auth/logout.post.ts @@ -1,10 +1,27 @@ -import { clearRefreshTokenCookie, requireUserSession } from '../../utils/auth' +import { defineEventHandler } from 'h3' +import { clearRefreshTokenCookie, getUserFromEvent } from '../../utils/auth' +import { getAuthMode } from '../../utils/authMode' +import { clearAppSession } from '../../utils/session' export default defineEventHandler(async (event) => { - const user = await requireUserSession(event) - user.tokenVersion += 1 - await user.save() + // Always drop the app's own session, whatever else happens below. + clearAppSession(event) + + // AUTH_MODE=open has no session to end — the local identity is the instance. + if (getAuthMode() === 'open') { + return { success: true } + } + + const user = await getUserFromEvent(event) + + // PHASE 1 (app repo): drop this branch. Bumping tokenVersion invalidates the + // website's outstanding access tokens; an app session is ended by clearing + // the cookie above. `tokenVersion` only exists on website User documents. + if (user && typeof (user as any).tokenVersion === 'number') { + ;(user as any).tokenVersion += 1 + await user.save().catch(() => null) + } + clearRefreshTokenCookie(event) return { success: true } }) - diff --git a/server/api/auth/refresh.post.ts b/server/api/auth/refresh.post.ts new file mode 100644 index 0000000..a9438e7 --- /dev/null +++ b/server/api/auth/refresh.post.ts @@ -0,0 +1,36 @@ +import { defineEventHandler } from 'h3' +import { AppUser } from '../../models/AppUser' +import { getAuthMode, getLocalAppUser } from '../../utils/authMode' +import { createAppAccessToken, issueAppSession, readAppSession } from '../../utils/session' +import { rotateRefreshToken } from '../../utils/auth' + +/** + * Hands the client a fresh bearer token for whatever session it already has. + * + * The client calls exactly this one endpoint in every mode; deciding what a + * "session" means is the server's job: + * open → the single local identity, no credentials involved + * sso → the app's own session cookie, minted at the SSO exchange + * (transitional) → the website's refresh cookie, for users logged in before + * the split. That last branch disappears with the website half of auth.ts. + */ +export default defineEventHandler(async (event) => { + if (getAuthMode() === 'open') { + const localUser = await getLocalAppUser() + issueAppSession(event, localUser) + return { accessToken: createAppAccessToken(localUser) } + } + + const session = readAppSession(event) + if (session) { + const appUser = await AppUser.findById(session.sub) + if (appUser) { + // Slide the cookie forward so an active user is never logged out mid-use. + issueAppSession(event, appUser) + return { accessToken: createAppAccessToken(appUser) } + } + } + + // PHASE 1 (app repo): delete — there is no website refresh cookie there. + return await rotateRefreshToken(event) +}) diff --git a/server/api/auth/sso/callback.post.ts b/server/api/auth/sso/callback.post.ts new file mode 100644 index 0000000..3e6b733 --- /dev/null +++ b/server/api/auth/sso/callback.post.ts @@ -0,0 +1,90 @@ +import { createError, defineEventHandler, readBody } from 'h3' +import { getAuthIssuer, getAuthMode, mirrorAppUser } from '../../../utils/authMode' +import { createAppAccessToken, issueAppSession } from '../../../utils/session' + +interface ExchangeResponse { + subject: string + email: string + name?: string + role?: 'user' | 'admin' | 'dev' +} + +/** + * Consumer half of the SSO handoff (see Phase 0.2 of the split plan). + * + * The browser only ever carries a one-time code. It is redeemed here, + * server-to-server against the issuer and authenticated with SERVICE_SECRET, so + * the code is worthless to anyone who intercepts the redirect: it is single-use, + * short-lived, and cannot be exchanged without the shared secret. + * + * What comes back is an identity, not a session. The app mirrors it locally and + * mints its *own* session — from here on the issuer is irrelevant. + */ +export default defineEventHandler(async (event) => { + if (getAuthMode() !== 'sso') { + throw createError({ statusCode: 404, statusMessage: 'Not found' }) + } + + const issuer = getAuthIssuer() + if (!issuer) { + throw createError({ + statusCode: 503, + statusMessage: 'AUTH_MODE=sso requires NUXT_PUBLIC_AUTH_ISSUER to be set.', + }) + } + + const serviceSecret = (process.env.SERVICE_SECRET || '').trim() + if (!serviceSecret) { + throw createError({ + statusCode: 503, + statusMessage: 'AUTH_MODE=sso requires SERVICE_SECRET to be set.', + }) + } + + const body = await readBody(event) + const code = String(body?.code || '').trim() + if (!code) { + throw createError({ statusCode: 400, statusMessage: 'Missing code' }) + } + + let identity: ExchangeResponse + try { + identity = await $fetch(`${issuer}/api/service/auth/sso/exchange`, { + method: 'POST', + headers: { 'x-service-secret': serviceSecret }, + body: { code }, + }) + } catch { + // Deliberately opaque: expired, already-used and forged codes are + // indistinguishable to the caller. + throw createError({ statusCode: 401, statusMessage: 'SSO code could not be redeemed.' }) + } + + if (!identity?.subject || !identity?.email) { + throw createError({ statusCode: 502, statusMessage: 'Issuer returned an incomplete identity.' }) + } + + const appUser = await mirrorAppUser({ + subject: identity.subject, + email: identity.email, + name: identity.name, + role: identity.role || 'user', + }, { force: true }) + + if (!appUser) { + throw createError({ statusCode: 500, statusMessage: 'Could not persist the identity.' }) + } + + issueAppSession(event, appUser) + + return { + accessToken: createAppAccessToken(appUser), + user: { + id: String(appUser._id), + email: appUser.email, + name: appUser.name, + role: appUser.role, + createdAt: appUser.createdAt, + }, + } +}) diff --git a/server/api/bridge/data.post.ts b/server/api/bridge/data.post.ts index 4165824..99dcaa0 100644 --- a/server/api/bridge/data.post.ts +++ b/server/api/bridge/data.post.ts @@ -8,8 +8,6 @@ import type {FlightLabTelemetryState} from '../../../shared/data/flightlab/types type DomeLightMode = 'off' | 'white' | 'amber' -const DOME_LIGHT_WEBHOOK_FALLBACK_URL = 'https://home.io.faktorxmensch.com/api/webhook/lidl_stab_3modi_8492' - const lastDomeLightModeByToken = new Map() /** @@ -155,8 +153,13 @@ export default defineEventHandler(async (event) => { console.table( telemetryKeys.reduce((acc, key) => { acc[key] = telemetry[key]; return acc }, {} as Record) ) const runtimeConfig = useRuntimeConfig() - const domeLightWebhookUrl = String(runtimeConfig.domeLightWebhookUrl || '').trim() || DOME_LIGHT_WEBHOOK_FALLBACK_URL - await forwardDomeLightToWebhook(telemetry, domeLightWebhookUrl, bridgeToken) + // Opt-in only: no DOME_LIGHT_WEBHOOK_URL, no outbound request. There is + // deliberately no fallback URL — a foreign instance must never post cockpit + // telemetry to somebody else's home automation. + const domeLightWebhookUrl = String(runtimeConfig.domeLightWebhookUrl || '').trim() + if (domeLightWebhookUrl) { + await forwardDomeLightToWebhook(telemetry, domeLightWebhookUrl, bridgeToken) + } // Map raw bridge fields to FlightLab format and store const mapped = mapBridgeTelemetry(telemetry) diff --git a/server/api/bridge/me.get.ts b/server/api/bridge/me.get.ts index a1642c2..ad32b9b 100644 --- a/server/api/bridge/me.get.ts +++ b/server/api/bridge/me.get.ts @@ -1,8 +1,7 @@ import { createError } from 'h3' import { BridgeToken } from '../../models/BridgeToken' -import { getBridgeTokenFromHeader } from '../../utils/bridge' +import { getBridgeTokenFromHeader, resolveBridgeUser } from '../../utils/bridge' import { logBridgeEvent } from '../../utils/bridgeLog' -import type { UserDocument } from '../../models/User' export default defineEventHandler(async (event) => { const token = getBridgeTokenFromHeader(event) @@ -17,9 +16,10 @@ export default defineEventHandler(async (event) => { console.info(`\x1b[36m[bridge:me]\x1b[0m token=\x1b[96m${token.slice(0, 6)}...\x1b[0m request received`) - const document = await BridgeToken.findOne({ token }).populate('user', 'name email') + const document = await BridgeToken.findOne({ token }) + const bridgeUser = await resolveBridgeUser(document?.user) - if (!document || !document.user) { + if (!document || !bridgeUser) { const result = { token, connected: false, @@ -41,8 +41,6 @@ export default defineEventHandler(async (event) => { return result } - const bridgeUser = document.user as UserDocument - const result = { token: document.token, connected: true, diff --git a/server/api/bridge/status.post.ts b/server/api/bridge/status.post.ts index b87b7ba..47ae9fb 100644 --- a/server/api/bridge/status.post.ts +++ b/server/api/bridge/status.post.ts @@ -1,8 +1,7 @@ import { createError, readBody } from 'h3' import { BridgeToken } from '../../models/BridgeToken' -import { getBridgeTokenFromHeader } from '../../utils/bridge' +import { getBridgeTokenFromHeader, resolveBridgeUser } from '../../utils/bridge' import { logBridgeEvent } from '../../utils/bridgeLog' -import type { UserDocument } from '../../models/User' interface StatusBody { simConnected?: boolean @@ -39,13 +38,13 @@ export default defineEventHandler(async (event) => { $setOnInsert: { token }, }, { new: true, upsert: true, runValidators: true }, - ).populate('user', 'name email') + ) if (!document) { throw createError({ statusCode: 500, statusMessage: 'Status konnte nicht aktualisiert werden.' }) } - const user = document.user as UserDocument | undefined + const user = await resolveBridgeUser(document.user) const result = { token: document.token, diff --git a/server/api/bug-reports/index.post.ts b/server/api/bug-reports/index.post.ts index 63313a4..f1dbfdc 100644 --- a/server/api/bug-reports/index.post.ts +++ b/server/api/bug-reports/index.post.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { requireUserSession } from '../../utils/auth' import { BugReport, type BugReportSource } from '../../models/BugReport' import { sendMail } from '../../utils/notifications' +import { emit as emitTelemetry } from '../../utils/telemetry' const SOURCE_LABELS: Record = { 'live-atc': 'Live ATC', @@ -27,6 +28,9 @@ export default defineEventHandler(async (event) => { // the mail so it can be pasted straight into the commit message. const code = randomUUID() + // Local DB first — this instance's own data. The mirror is what makes the + // report show up in the hosted admin view; the write path does not depend on + // it, and a self-hosted instance keeps its reports to itself. const report = await BugReport.create({ code, source, @@ -37,6 +41,25 @@ export default defineEventHandler(async (event) => { pmState: body?.pmState || undefined, }) + emitTelemetry('bug-report', { + code, + source, + comment: comment.slice(0, 4000), + contact, + userId: String(user._id), + screenshot: body?.screenshot || undefined, + pmState: body?.pmState || undefined, + createdAt: report.createdAt, + }) + + // Opt-in and unset by default: a foreign instance must not mail its users' + // bug reports to us. The hosted service sets BUG_REPORT_NOTIFY_EMAIL; a + // self-hosted one keeps its reports in its own database and nowhere else. + const notifyEmail = (process.env.BUG_REPORT_NOTIFY_EMAIL || '').trim() + if (!notifyEmail) { + return { success: true, id: String(report._id), code } + } + const adminUrl = `${process.env.APP_URL || 'https://app.opensquawk.de'}/admin` const sourceLabel = SOURCE_LABELS[source] const stateInfo = body?.pmState?.currentStateId @@ -44,7 +67,7 @@ export default defineEventHandler(async (event) => { : '' await sendMail({ - to: 'emanuel@faktorxmensch.com', + to: notifyEmail, subject: `[OpenSquawk Bug · ${sourceLabel}] ${contact}`, html: `

Neuer Bug Report

Bereich: ${sourceLabel}

diff --git a/server/api/decision/route.post.ts b/server/api/decision/route.post.ts index a19d451..09eb289 100644 --- a/server/api/decision/route.post.ts +++ b/server/api/decision/route.post.ts @@ -15,6 +15,7 @@ import { type LlmRoutingCandidate, type LlmRoutingStatus, } from '../../models/LlmRoutingDecision' +import { emit as emitTelemetry } from '../../utils/telemetry' interface RouteRequestBody { sessionId: string @@ -148,28 +149,32 @@ export default defineEventHandler(async (event) => { }) } - // Always persist the routing-review record, including timeouts/errors. + const decision = { + sessionId: body.sessionId, + flowSlug: body.flowSlug, + stateId: body.stateId, + transcript: body.transcript, + expectedPhrase: body.expectedPhrase, + candidates: body.candidates, + chosen, + reason, + status, + model: ROUTER_MODEL, + timeoutMs, + latencyMs, + inputTokens, + outputTokens, + costUsd, + } + + // Always persist the routing-review record locally, including timeouts/errors. try { - await LlmRoutingDecision.create({ - sessionId: body.sessionId, - flowSlug: body.flowSlug, - stateId: body.stateId, - transcript: body.transcript, - expectedPhrase: body.expectedPhrase, - candidates: body.candidates, - chosen, - reason, - status, - model: ROUTER_MODEL, - timeoutMs, - latencyMs, - inputTokens, - outputTokens, - costUsd, - }) + await LlmRoutingDecision.create(decision) } catch (e) { console.warn('[decision/route] persisting routing decision failed', e) } + emitTelemetry('llm-routing-decision', decision) + return { chosen, reason, status, latencyMs, timeoutMs, model: ROUTER_MODEL } }) diff --git a/server/api/service/user-deleted.post.ts b/server/api/service/user-deleted.post.ts new file mode 100644 index 0000000..5ddeaba --- /dev/null +++ b/server/api/service/user-deleted.post.ts @@ -0,0 +1,69 @@ +import { createError, defineEventHandler, readBody } from 'h3' +import mongoose from 'mongoose' +import { requireServiceSecret } from '../../utils/serviceAuth' +import { AppUser } from '../../models/AppUser' +import { LearnProfile } from '../../models/LearnProfile' +import { PilotProfile } from '../../models/PilotProfile' +import { BridgeToken } from '../../models/BridgeToken' +import { TransmissionLog } from '../../models/TransmissionLog' + +/** + * APP-SIDE. Receiving end of the account-deletion webhook (Phase 0.6c). + * + * With two independent databases, deleting a user on the website cannot reach + * the app's data — so the website calls this. It is DSGVO-relevant: the privacy + * policy promises deletion, and this is what makes that promise true for the + * app's half. + * + * Deliberately loud. It reports what it deleted and fails with a real error if + * it could not, so the caller can surface the failure instead of telling the + * user their data is gone when it is not. + */ +export default defineEventHandler(async (event) => { + requireServiceSecret(event) + + const body = await readBody(event) + const subject = String(body?.subject || body?.userId || '').trim() + + if (!subject) { + throw createError({ statusCode: 400, statusMessage: 'Missing subject' }) + } + + // The mirror keys on _id where the subject is an ObjectId (which it is for + // every identity opensquawk.de issues), otherwise on ssoSubject. + const appUser = mongoose.isValidObjectId(subject) + ? await AppUser.findById(subject) + : await AppUser.findOne({ ssoSubject: subject }) + + // Profiles reference the identity by _id, which equals the subject. Resolve + // it even when no mirror row exists — data can outlive the mirror, and + // "no mirror" must not mean "nothing to delete". + const userId = appUser?._id ?? (mongoose.isValidObjectId(subject) ? subject : null) + + if (!userId) { + return { success: true, deleted: {}, note: 'No app-side data for this subject.' } + } + + const [learnProfiles, pilotProfiles, bridgeTokens, transmissionLogs] = await Promise.all([ + LearnProfile.deleteMany({ user: userId }), + PilotProfile.deleteMany({ user: userId }), + BridgeToken.deleteMany({ user: userId }), + TransmissionLog.deleteMany({ user: userId }), + ]) + + if (appUser) { + await appUser.deleteOne() + } + + const deleted = { + appUser: appUser ? 1 : 0, + learnProfiles: learnProfiles.deletedCount ?? 0, + pilotProfiles: pilotProfiles.deletedCount ?? 0, + bridgeTokens: bridgeTokens.deletedCount ?? 0, + transmissionLogs: transmissionLogs.deletedCount ?? 0, + } + + console.info(`[user-deleted] subject=${subject} deleted=${JSON.stringify(deleted)}`) + + return { success: true, deleted } +}) diff --git a/server/middleware/auth.global.ts b/server/middleware/auth.global.ts index 7ee5707..d7a2988 100644 --- a/server/middleware/auth.global.ts +++ b/server/middleware/auth.global.ts @@ -18,6 +18,15 @@ export default defineEventHandler(async (event) => { if (url.pathname.startsWith('/api/dev/')) { return } + // Establishing a session cannot itself require one: /api/auth/refresh trades + // an existing cookie for a bearer token, /api/auth/sso/* redeems a one-time + // code, and /api/auth/logout must work even once the session is gone. + if (url.pathname === '/api/auth/refresh' || url.pathname === '/api/auth/logout') { + return + } + if (url.pathname.startsWith('/api/auth/sso/')) { + return + } if (event.node.req.method === 'OPTIONS') { return } diff --git a/server/models/AppUser.ts b/server/models/AppUser.ts new file mode 100644 index 0000000..0cf9493 --- /dev/null +++ b/server/models/AppUser.ts @@ -0,0 +1,42 @@ +import mongoose from 'mongoose' + +export type AppUserRole = 'user' | 'admin' | 'dev' + +/** + * The app's own view of an identity. + * + * The app never reads the website's `User` collection — that collection lives + * in the website's database and is unreachable once the repos are split. What + * the app keeps instead is this mirror, written when an identity arrives + * (SSO exchange) or, in AUTH_MODE=open, as a single fixed local row. + * + * `_id` is deliberately the ObjectId of the SSO subject (i.e. the website's + * `User._id`). Every existing `LearnProfile.user`, `PilotProfile.user` and + * `BridgeToken.user` reference therefore stays valid without a data migration, + * and app-side queries keep working unchanged. `ssoSubject` is kept as its own + * field because the delete webhook addresses users by subject, not by _id. + */ +export interface AppUserDocument extends mongoose.Document { + ssoSubject: string + email: string + name?: string + role: AppUserRole + createdAt: Date + updatedAt: Date +} + +const appUserSchema = new mongoose.Schema( + { + ssoSubject: { type: String, required: true, unique: true, index: true, trim: true }, + email: { type: String, required: true, lowercase: true, trim: true }, + name: { type: String, trim: true }, + role: { type: String, enum: ['user', 'admin', 'dev'], default: 'user' }, + }, + { + timestamps: true, + }, +) + +export const AppUser = + (mongoose.models.AppUser as mongoose.Model | undefined) || + mongoose.model('AppUser', appUserSchema) diff --git a/server/models/BridgeToken.ts b/server/models/BridgeToken.ts index b13d18e..6770047 100644 --- a/server/models/BridgeToken.ts +++ b/server/models/BridgeToken.ts @@ -1,9 +1,9 @@ import mongoose from 'mongoose' -import type { UserDocument } from './User' +import type { AppUserDocument } from './AppUser' export interface BridgeTokenDocument extends mongoose.Document { token: string - user?: mongoose.Types.ObjectId | UserDocument + user?: mongoose.Types.ObjectId | AppUserDocument connectedAt?: Date lastStatusAt?: Date simConnected: boolean @@ -15,7 +15,9 @@ export interface BridgeTokenDocument extends mongoose.Document { const bridgeTokenSchema = new mongoose.Schema( { token: { type: String, required: true, unique: true, index: true }, - user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + // The app's own mirror, not the website's User collection — AppUser._id is + // the same ObjectId, so existing tokens keep resolving. + user: { type: mongoose.Schema.Types.ObjectId, ref: 'AppUser' }, connectedAt: { type: Date }, lastStatusAt: { type: Date }, simConnected: { type: Boolean, default: false }, diff --git a/server/utils/auth.ts b/server/utils/auth.ts index bfb1999..027b335 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,9 +1,13 @@ import { createError, getHeader, H3Event, setCookie, deleteCookie } from 'h3' import { useRuntimeConfig } from '#imports' -import { createHmac, randomBytes, timingSafeEqual, scrypt as _scrypt } from 'node:crypto' +import { randomBytes, timingSafeEqual, scrypt as _scrypt } from 'node:crypto' import { promisify } from 'node:util' import type { UserDocument } from '../models/User' import { User } from '../models/User' +import { AppUser } from '../models/AppUser' +import { createJwtToken, verifyJwtToken } from './jwt' +import { getAuthMode, getLocalAppUser, mirrorAppUser } from './authMode' +import { readAppSession, verifyAppAccessToken } from './session' const scrypt = promisify(_scrypt) as (password: string | Buffer, salt: string | Buffer, keylen: number) => Promise @@ -24,49 +28,6 @@ function getSecrets() { } } -function base64url(buffer: Buffer) { - return buffer.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') -} - -function fromBase64url(input: string) { - let sanitized = input.replace(/-/g, '+').replace(/_/g, '/') - const pad = sanitized.length % 4 - if (pad === 2) sanitized += '==' - else if (pad === 3) sanitized += '=' - else if (pad !== 0) sanitized += '===' - return Buffer.from(sanitized, 'base64') -} - -function createJwtToken(payload: Record, secret: string, ttlSeconds: number) { - const header = { alg: 'HS256', typ: 'JWT' } - const now = Math.floor(Date.now() / 1000) - const body = { ...payload, iat: now, exp: now + ttlSeconds } - const encodedHeader = base64url(Buffer.from(JSON.stringify(header))) - const encodedPayload = base64url(Buffer.from(JSON.stringify(body))) - const data = `${encodedHeader}.${encodedPayload}` - const signature = createHmac('sha256', secret).update(data).digest() - return `${data}.${base64url(signature)}` -} - -function verifyJwtToken(token: string, secret: string) { - const parts = token.split('.') - if (parts.length !== 3) throw new Error('Malformed token') - const [encodedHeader, encodedPayload, signature] = parts - const data = `${encodedHeader}.${encodedPayload}` - const expectedSignature = createHmac('sha256', secret).update(data).digest() - const receivedSignature = fromBase64url(signature) - if (receivedSignature.length !== expectedSignature.length || !timingSafeEqual(receivedSignature, expectedSignature)) { - throw new Error('Invalid signature') - } - const header = JSON.parse(fromBase64url(encodedHeader).toString('utf8')) - if (header.alg !== 'HS256') throw new Error('Unsupported algorithm') - const payload = JSON.parse(fromBase64url(encodedPayload).toString('utf8')) - if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) { - throw new Error('Token expired') - } - return payload as Record -} - export async function hashPassword(password: string) { const salt = randomBytes(PASSWORD_SALT_BYTES) const derived = await scrypt(password, salt, PASSWORD_KEYLEN) @@ -155,21 +116,61 @@ export function getDevBypassUser(): UserDocument { } as unknown as UserDocument } +/** Resolve an identity from the app's own mirror — no `User` lookup involved. */ +async function resolveAppUser(sub: string) { + const appUser = await AppUser.findById(sub) + if (!appUser) return null + return appUser as unknown as UserDocument +} + export async function resolveUserFromToken(event: H3Event) { + // 1. The app's own session cookie — the only path that survives the split. + const session = readAppSession(event) + if (session) { + const appUser = await resolveAppUser(session.sub) + if (appUser) return appUser + } + const token = parseAuthorizationHeader(event) if (!token) return null + + // 2. An app-minted bearer token (same session, carried in the header). + const appToken = verifyAppAccessToken(token) + if (appToken) { + return await resolveAppUser(appToken.sub) + } + + // 3. PHASE 1 (app repo): everything below goes away together with the `User` + // collection. While website and app share one deployment, a website access + // token is still a valid way in — it is how every existing user is logged in + // today. Each such request also refreshes the AppUser mirror (rate-limited + // internally), so app-side data is already keyed correctly when the split + // happens. try { 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 devUser = getDevBypassUser() + await mirrorAppUser({ + subject: DEV_BYPASS_USER_ID, + email: devUser.email, + name: devUser.name, + role: 'user', + }).catch(() => null) + return devUser } const user = await User.findById(payload.sub) if (!user) return null if (typeof payload.version === 'number' && payload.version !== user.tokenVersion) { return null } + await mirrorAppUser({ + subject: String(user._id), + email: user.email, + name: user.name, + role: user.role, + }).catch(() => null) return user } catch { return null @@ -180,6 +181,15 @@ export async function requireUserSession(event: H3Event) { if (event.context?.user) { return event.context.user as UserDocument } + + // AUTH_MODE=open: a self-hosted instance has no login. Every request is the + // one local identity, resolved without ever touching the `User` collection. + if (getAuthMode() === 'open') { + const localUser = await getLocalAppUser() as unknown as UserDocument + event.context.user = localUser + return localUser + } + const user = await resolveUserFromToken(event) if (!user) { throw createError({ statusCode: 401, statusMessage: 'Authentication required' }) @@ -190,6 +200,11 @@ export async function requireUserSession(event: H3Event) { export async function getUserFromEvent(event: H3Event) { if (event.context?.user) return event.context.user as UserDocument + if (getAuthMode() === 'open') { + const localUser = await getLocalAppUser() as unknown as UserDocument + event.context.user = localUser + return localUser + } const user = await resolveUserFromToken(event) if (user) { event.context.user = user @@ -202,6 +217,13 @@ export function hasAdminRole(user: UserDocument | null | undefined) { } export async function requireAdmin(event: H3Event) { + // PHASE 1 (app repo): delete this guard together with the admin surface. + // The local AUTH_MODE=open identity is an admin *of its own instance*, which + // is correct once /api/admin/** no longer lives here. For as long as it does, + // an unauthenticated open-mode request must never reach it. + if (getAuthMode() === 'open') { + throw createError({ statusCode: 404, statusMessage: 'Not found' }) + } const user = await requireUserSession(event) if (!hasAdminRole(user)) { throw createError({ statusCode: 403, statusMessage: 'Administratorrechte erforderlich' }) diff --git a/server/utils/authMode.ts b/server/utils/authMode.ts new file mode 100644 index 0000000..d79839a --- /dev/null +++ b/server/utils/authMode.ts @@ -0,0 +1,135 @@ +import mongoose from 'mongoose' +import { AppUser, type AppUserDocument, type AppUserRole } from '../models/AppUser' + +export type AuthMode = 'open' | 'sso' + +/** + * How this deployment obtains an identity. + * + * - `open` — self-hosted, no login at all. Every request is the one local user. + * - `sso` — identity is handed over from the issuer (opensquawk.de) via a + * one-time code; the app then mints and verifies its own session. + * + * There is deliberately no third mode: the app never owns passwords, invites or + * password resets. Entitlement/feature-gating must NOT be mixed in here — + * AUTH_MODE is an *identity* concept (see Phase 5.2 of the split plan). + */ + +// The single local identity used in AUTH_MODE=open. Fixed so that profiles, +// bridge tokens and progress survive restarts. Distinct from DEV_BYPASS_USER_ID +// in server/utils/auth.ts, which is a separate local-dev-only bypass. +export const LOCAL_USER_ID = '000000000000000000000010' +export const LOCAL_USER_SUBJECT = 'local' +const LOCAL_USER_EMAIL = 'local@opensquawk.invalid' +const LOCAL_USER_NAME = 'Local User' + +export function getAuthMode(): AuthMode { + // PHASE 1 (app repo): flip this default to 'open' and delete the note below. + // + // The default is 'sso' for as long as the website surface (/api/admin/**, + // /api/editor/**, invitations, waitlist) still lives in this repo. Defaulting + // to 'open' here would mean that any deployment which simply forgets to set + // AUTH_MODE serves those endpoints to everyone as the local admin user. + // In the app repo that surface is gone and 'open' is the correct default. + const raw = (process.env.AUTH_MODE || 'sso').trim().toLowerCase() + return raw === 'open' ? 'open' : 'sso' +} + +export function isOpenAuthMode() { + return getAuthMode() === 'open' +} + +/** + * Where an unauthenticated user is sent in AUTH_MODE=sso. Empty in `open`. + */ +export function getAuthIssuer(): string { + return (process.env.NUXT_PUBLIC_AUTH_ISSUER || '').trim().replace(/\/+$/, '') +} + +let localUserPromise: Promise | null = null + +/** + * The one identity of an AUTH_MODE=open instance. Persisted (rather than kept + * in memory) because BridgeToken/LearnProfile/PilotProfile reference it. + */ +export function getLocalAppUser(): Promise { + if (!localUserPromise) { + localUserPromise = AppUser.findOneAndUpdate( + { _id: LOCAL_USER_ID }, + { + // _id is seeded from the filter's equality condition on insert. + $setOnInsert: { + ssoSubject: LOCAL_USER_SUBJECT, + email: LOCAL_USER_EMAIL, + name: LOCAL_USER_NAME, + role: 'admin', + }, + }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ).exec().catch((err) => { + // Never cache a failed lookup — a transient DB outage must not disable + // the instance for the rest of the process lifetime. + localUserPromise = null + throw err + }) as Promise + } + return localUserPromise +} + +// Re-upserting the mirror on every single request would add a write to every +// authenticated call. Subjects seen recently are skipped. +const MIRROR_TTL_MS = 5 * 60 * 1000 +const mirroredAt = new Map() + +export interface MirrorInput { + subject: string + email: string + name?: string + role?: AppUserRole +} + +/** + * Write/refresh the local mirror of an SSO identity. + * + * When the subject is a valid ObjectId — which it is for every identity issued + * by opensquawk.de, where the subject *is* `User._id` — it is reused verbatim + * as the mirror's `_id`, so pre-existing `LearnProfile.user` / `PilotProfile.user` + * / `BridgeToken.user` references keep resolving without a migration + * (see server/models/AppUser.ts). A non-ObjectId subject from some future + * issuer simply gets a generated `_id` and is matched on `ssoSubject`. + */ +export async function mirrorAppUser(input: MirrorInput, options: { force?: boolean } = {}) { + const subject = String(input.subject || '').trim() + if (!subject) return null + + const last = mirroredAt.get(subject) + if (!options.force && last && Date.now() - last < MIRROR_TTL_MS) { + return null + } + + const update: Record = { + ssoSubject: subject, + email: input.email, + role: input.role || 'user', + } + if (input.name !== undefined) update.name = input.name + + // On insert, MongoDB seeds the document from the filter's equality + // conditions — so matching on _id is what carries the subject into _id. + const filter = mongoose.isValidObjectId(subject) ? { _id: subject } : { ssoSubject: subject } + + const doc = await AppUser.findOneAndUpdate( + filter, + { $set: update }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ).exec() + + mirroredAt.set(subject, Date.now()) + return doc +} + +/** Test seam — the mirror cache is process-local and otherwise unreachable. */ +export function resetAuthModeCaches() { + localUserPromise = null + mirroredAt.clear() +} diff --git a/server/utils/bridge.ts b/server/utils/bridge.ts index f661dd7..545d4a7 100644 --- a/server/utils/bridge.ts +++ b/server/utils/bridge.ts @@ -1,4 +1,7 @@ import { getHeader, type H3Event } from 'h3' +import mongoose from 'mongoose' +import { AppUser, type AppUserDocument } from '../models/AppUser' +import { mirrorAppUser } from './authMode' export function normalizeBridgeToken(input: unknown) { if (typeof input !== 'string') { @@ -17,3 +20,43 @@ export function normalizeBridgeToken(input: unknown) { export function getBridgeTokenFromHeader(event: H3Event) { return normalizeBridgeToken(getHeader(event, 'x-bridge-token')) } + +/** + * The identity behind a bridge token, from the app's own mirror. + * + * Bridge endpoints are called by the desktop app with only a token, so there is + * no session to resolve and no chance to refresh the mirror first. A token + * paired before the mirror existed would otherwise report "not connected" and + * push the user through pairing again, so a missing mirror row is backfilled + * from the website's User here rather than treated as "unknown user". + * + * PHASE 1 (app repo): drop the backfill — by then every token predates nothing + * and there is no User collection to read. + */ +export async function resolveBridgeUser( + user: unknown, +): Promise { + if (!user) return null + + // Already populated by the caller's .populate('user', ...). + if (typeof user === 'object' && 'email' in (user as Record)) { + return user as AppUserDocument + } + + const userId = String((user as { _id?: unknown })?._id ?? user) + if (!mongoose.isValidObjectId(userId)) return null + + const mirrored = await AppUser.findById(userId) + if (mirrored) return mirrored + + const { User } = await import('../models/User') + const websiteUser = await User.findById(userId) + if (!websiteUser) return null + + return await mirrorAppUser({ + subject: String(websiteUser._id), + email: websiteUser.email, + name: websiteUser.name, + role: websiteUser.role, + }, { force: true }) +} diff --git a/server/utils/jwt.ts b/server/utils/jwt.ts new file mode 100644 index 0000000..fa5d6fd --- /dev/null +++ b/server/utils/jwt.ts @@ -0,0 +1,53 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +/** + * Minimal HS256 JWT helpers. + * + * Extracted from server/utils/auth.ts so that the app's own session + * (server/utils/session.ts) can mint and verify tokens without dragging in the + * website-only parts of auth.ts (scrypt passwords, invite codes, password + * reset). Both files ship with the app repo; auth.ts's website half does not. + */ + +export function base64url(buffer: Buffer) { + return buffer.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') +} + +export function fromBase64url(input: string) { + let sanitized = input.replace(/-/g, '+').replace(/_/g, '/') + const pad = sanitized.length % 4 + if (pad === 2) sanitized += '==' + else if (pad === 3) sanitized += '=' + else if (pad !== 0) sanitized += '===' + return Buffer.from(sanitized, 'base64') +} + +export function createJwtToken(payload: Record, secret: string, ttlSeconds: number) { + const header = { alg: 'HS256', typ: 'JWT' } + const now = Math.floor(Date.now() / 1000) + const body = { ...payload, iat: now, exp: now + ttlSeconds } + const encodedHeader = base64url(Buffer.from(JSON.stringify(header))) + const encodedPayload = base64url(Buffer.from(JSON.stringify(body))) + const data = `${encodedHeader}.${encodedPayload}` + const signature = createHmac('sha256', secret).update(data).digest() + return `${data}.${base64url(signature)}` +} + +export function verifyJwtToken(token: string, secret: string) { + const parts = token.split('.') + if (parts.length !== 3) throw new Error('Malformed token') + const [encodedHeader, encodedPayload, signature] = parts + const data = `${encodedHeader}.${encodedPayload}` + const expectedSignature = createHmac('sha256', secret).update(data).digest() + const receivedSignature = fromBase64url(signature) + if (receivedSignature.length !== expectedSignature.length || !timingSafeEqual(receivedSignature, expectedSignature)) { + throw new Error('Invalid signature') + } + const header = JSON.parse(fromBase64url(encodedHeader).toString('utf8')) + if (header.alg !== 'HS256') throw new Error('Unsupported algorithm') + const payload = JSON.parse(fromBase64url(encodedPayload).toString('utf8')) + if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) { + throw new Error('Token expired') + } + return payload as Record +} diff --git a/server/utils/session.ts b/server/utils/session.ts new file mode 100644 index 0000000..6baea67 --- /dev/null +++ b/server/utils/session.ts @@ -0,0 +1,108 @@ +import { deleteCookie, getCookie, setCookie, type H3Event } from 'h3' +import { createJwtToken, verifyJwtToken } from './jwt' +import type { AppUserDocument } from '../models/AppUser' + +/** + * The app's *own* session — deliberately independent of the issuer. + * + * Once an identity has arrived (via the SSO exchange, or trivially in + * AUTH_MODE=open), the app mints a session signed with its own secret and + * stored in a host-only cookie on its own origin. No cross-domain cookie, no + * CORS, no shared secret with the website beyond the one-time exchange. After + * this point the app can serve every request without the issuer being reachable + * at all — which is the whole point: a self-hosted instance must not depend on + * opensquawk.de. + */ + +const APP_SESSION_COOKIE = 'os_app_session' +const APP_SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 +const APP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24 + +// Marks a bearer token as minted by the app rather than by the website's +// login. Both are HS256 and — in the monorepo, where APP_JWT_SECRET is usually +// unset — signed with the same secret, so the payload is what tells them apart: +// an app token resolves against AppUser, a website token against User. +const APP_TOKEN_TYPE = 'app' + +export interface AppSessionPayload { + sub: string + sso: string + email: string + role: string +} + +function getAppSessionSecret() { + // APP_JWT_SECRET lets the app run on a secret of its own; JWT_SECRET is the + // fallback so the monorepo keeps working with a single configured secret. + const secret = (process.env.APP_JWT_SECRET || process.env.JWT_SECRET || '').trim() + if (!secret) { + throw new Error('App session secret missing – bitte APP_JWT_SECRET (oder JWT_SECRET) in .env setzen') + } + return secret +} + +function sessionClaims(user: AppUserDocument) { + return { + sub: String(user._id), + sso: user.ssoSubject, + email: user.email, + role: user.role, + typ: APP_TOKEN_TYPE, + } +} + +/** + * Short-lived bearer token for the browser. The durable session lives in the + * httpOnly cookie; this is what the client puts in the Authorization header, + * exactly as the website's access token does today, so no call site has to + * change shape. + */ +export function createAppAccessToken(user: AppUserDocument) { + return createJwtToken(sessionClaims(user), getAppSessionSecret(), APP_ACCESS_TOKEN_TTL_SECONDS) +} + +/** + * Returns the payload if `token` is an app-minted bearer token, else null — + * including for a well-formed website token, which must fall through to the + * website resolution path. + */ +export function verifyAppAccessToken(token: string): AppSessionPayload | null { + try { + const payload = verifyJwtToken(token, getAppSessionSecret()) + if (payload?.typ !== APP_TOKEN_TYPE || !payload?.sub) return null + return { + sub: String(payload.sub), + sso: String(payload.sso || payload.sub), + email: String(payload.email || ''), + role: String(payload.role || 'user'), + } + } catch { + return null + } +} + +export function issueAppSession(event: H3Event, user: AppUserDocument) { + const token = createJwtToken(sessionClaims(user), getAppSessionSecret(), APP_SESSION_TTL_SECONDS) + + setCookie(event, APP_SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: APP_SESSION_TTL_SECONDS, + // Host-only on purpose: no `domain`, so the cookie never leaks to the + // issuer's origin and self-hosted instances behave identically. + secure: process.env.NODE_ENV === 'production', + }) + + return token +} + +export function readAppSession(event: H3Event): AppSessionPayload | null { + const token = getCookie(event, APP_SESSION_COOKIE) + if (!token) return null + return verifyAppAccessToken(token) +} + +export function clearAppSession(event: H3Event) { + deleteCookie(event, APP_SESSION_COOKIE, { path: '/' }) +} diff --git a/server/utils/telemetry.ts b/server/utils/telemetry.ts new file mode 100644 index 0000000..25fb952 --- /dev/null +++ b/server/utils/telemetry.ts @@ -0,0 +1,145 @@ +/** + * Outbound telemetry mirror. + * + * The app always writes to its **own** database first — that is its data, and + * this channel must never be a dependency of anything. Only if both + * TELEMETRY_URL and SERVICE_SECRET are set is a copy additionally mirrored to + * the hosted service's ingest endpoint. + * + * Self-host default: both unset → nothing ever leaves the instance. That is the + * point, not a fallback. + * + * Design constraints, all of them deliberate: + * - fire-and-forget: emit() never awaits the network and never throws + * - bounded buffer: on overflow the oldest entries are dropped, so a dead sink + * can cost memory neither indefinitely nor unboundedly + * - no retries beyond the current batch: /api/atc/say must return 200 whether + * or not a log sink exists + * + * The wire contract below — {kind, payload} — is the interface between the two + * repos. Neither side shares a Mongoose schema with the other; each owns its + * own. That is what makes two independent databases possible. + */ + +export type TelemetryKind = + | 'transmission-log' + | 'bug-report' + | 'llm-routing-decision' + | 'product-usage-session' + +export interface TelemetryEnvelope { + kind: TelemetryKind + payload: Record + emittedAt: string +} + +const MAX_BUFFERED = 500 +const MAX_BATCH = 50 +const FLUSH_DELAY_MS = 2_000 +const REQUEST_TIMEOUT_MS = 5_000 + +const buffer: TelemetryEnvelope[] = [] +let flushTimer: ReturnType | null = null +let flushing = false +let droppedSinceLastWarning = 0 +let warnedMissingConfig = false + +interface TelemetryConfig { + url: string + secret: string +} + +function getTelemetryConfig(): TelemetryConfig | null { + const url = (process.env.TELEMETRY_URL || '').trim() + const secret = (process.env.SERVICE_SECRET || '').trim() + if (!url || !secret) { + if (url && !secret && !warnedMissingConfig) { + console.warn('[telemetry] TELEMETRY_URL is set but SERVICE_SECRET is not — mirroring stays off.') + warnedMissingConfig = true + } + return null + } + return { url, secret } +} + +export function isTelemetryEnabled() { + return getTelemetryConfig() !== null +} + +/** + * Queue one record for mirroring. Returns immediately; callers do not await and + * do not handle errors, because a telemetry failure is not their problem. + */ +export function emit(kind: TelemetryKind, payload: Record) { + if (!isTelemetryEnabled()) return + + if (buffer.length >= MAX_BUFFERED) { + // Drop the oldest: a stalled sink must not push out fresh data forever. + buffer.shift() + droppedSinceLastWarning += 1 + } + + buffer.push({ kind, payload, emittedAt: new Date().toISOString() }) + scheduleFlush() +} + +function scheduleFlush() { + if (flushTimer || flushing) return + flushTimer = setTimeout(() => { + flushTimer = null + void flush() + }, FLUSH_DELAY_MS) + // Never hold the process open for a telemetry batch. + if (typeof flushTimer === 'object' && flushTimer && 'unref' in flushTimer) { + ;(flushTimer as unknown as { unref: () => void }).unref() + } +} + +export async function flush() { + const config = getTelemetryConfig() + if (!config || flushing || !buffer.length) return + + flushing = true + try { + if (droppedSinceLastWarning > 0) { + console.warn(`[telemetry] dropped ${droppedSinceLastWarning} record(s): buffer full`) + droppedSinceLastWarning = 0 + } + + while (buffer.length) { + const batch = buffer.splice(0, MAX_BATCH) + try { + await $fetch(config.url, { + method: 'POST', + headers: { 'x-service-secret': config.secret }, + body: { records: batch }, + timeout: REQUEST_TIMEOUT_MS, + }) + } catch (error: any) { + // The batch is gone. Re-queueing it would let an unreachable sink build + // an unbounded backlog, which is exactly what this must not do. + console.warn(`[telemetry] mirroring ${batch.length} record(s) failed:`, error?.message || error) + break + } + } + } finally { + flushing = false + if (buffer.length) scheduleFlush() + } +} + +/** Test seam — the buffer is module-local and otherwise unreachable. */ +export function __resetTelemetryBufferForTests() { + buffer.length = 0 + droppedSinceLastWarning = 0 + warnedMissingConfig = false + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } +} + +/** Test seam — inspect what is queued without flushing. */ +export function __peekTelemetryBufferForTests(): readonly TelemetryEnvelope[] { + return buffer +} diff --git a/tests/server/bridgeMe.handler.test.ts b/tests/server/bridgeMe.handler.test.ts index e689968..af6362d 100644 --- a/tests/server/bridgeMe.handler.test.ts +++ b/tests/server/bridgeMe.handler.test.ts @@ -33,14 +33,14 @@ describe('/api/bridge/me handler', () => { const mod = await import('~~/server/api/bridge/me.get') const handler = mod.default + // The handler no longer populates: it reads the raw reference and resolves + // it through resolveBridgeUser (AppUser mirror, see server/utils/bridge.ts). const originalFindOne = (BridgeToken as any).findOne - ;(BridgeToken as any).findOne = () => ({ - populate: async () => ({ - token: 'bridge-token-abc', - user: null, - connectedAt: new Date('2025-01-01T10:00:00.000Z'), - lastStatusAt: null, - }), + ;(BridgeToken as any).findOne = async () => ({ + token: 'bridge-token-abc', + user: null, + connectedAt: new Date('2025-01-01T10:00:00.000Z'), + lastStatusAt: null, }) try { @@ -64,21 +64,21 @@ describe('/api/bridge/me handler', () => { const mod = await import('~~/server/api/bridge/me.get') const handler = mod.default + // An already-resolved user object (as an AppUser document would be) is + // passed straight through by resolveBridgeUser — no DB round trip. const originalFindOne = (BridgeToken as any).findOne - ;(BridgeToken as any).findOne = () => ({ - populate: async () => ({ - token: 'bridge-token-live', - user: { - _id: '507f1f77bcf86cd799439055', - email: 'pilot@example.com', - name: 'Pilot', - }, - simConnected: true, - flightActive: true, - connectedAt: undefined, - updatedAt: new Date('2025-01-01T11:00:00.000Z'), - lastStatusAt: new Date('2025-01-01T11:02:00.000Z'), - }), + ;(BridgeToken as any).findOne = async () => ({ + token: 'bridge-token-live', + user: { + _id: '507f1f77bcf86cd799439055', + email: 'pilot@example.com', + name: 'Pilot', + }, + simConnected: true, + flightActive: true, + connectedAt: undefined, + updatedAt: new Date('2025-01-01T11:00:00.000Z'), + lastStatusAt: new Date('2025-01-01T11:02:00.000Z'), }) try {