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 {