mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-30 13:16:31 +08:00
refactor(split): decouple app from website in preparation for the repo split
Phase 0 of the OpenSquawk repo separation. Everything happens inside the
monorepo so that the split itself becomes a mechanical path filter — filtering
first and repairing afterwards would leave two broken repos at once.
AUTH_MODE (0.1)
New server/utils/authMode.ts, session.ts and jwt.ts (the latter extracted
from auth.ts). requireUserSession now resolves in three steps: the app's own
session cookie, an app-minted bearer token, then the website access token.
Only the last one is transitional; it is marked PHASE 1 and disappears with
the User collection. The app's session is its own JWT in a host-only cookie
plus a short-lived bearer, so the existing Authorization call sites are
unchanged.
AUTH_MODE defaults to 'sso', not 'open' as the plan proposed: while the admin
and editor surface still lives here, an unset variable would otherwise serve
it to everyone as a local admin. requireAdmin additionally refuses in open
mode. Both are one-line removals in Phase 1 and marked as such.
SSO handoff (0.2)
Issuer: /api/service/auth/sso/{authorize,exchange}. Codes are stored as
SHA-256 hashes with a TTL index and claimed by a single atomic update, so
concurrent redemption cannot succeed twice. redirect_uri is matched against
SSO_REDIRECT_ORIGINS by exact origin — a prefix check would accept
app.opensquawk.de.evil.tld. There is no default and no wildcard: an empty
allowlist disables the handoff rather than opening a redirector.
Consumer: /api/auth/sso/callback plus app/pages/auth/callback.vue. The
browser only ever carries the code; it is redeemed server-to-server.
Hardcoded values and leaks (0.3)
Hotjar ID, the dome-light webhook URL and the bug-report recipient were
compiled in. All three are env-gated and off by default now, so a foreign
instance cannot ship analytics, cockpit telemetry or its users' bug reports
to us. Setting HOTJAR_ID, DOME_LIGHT_WEBHOOK_URL and BUG_REPORT_NOTIFY_EMAIL
restores the current behaviour on opensquawk.de.
Two databases, no shared Mongo (0.6)
AppUser mirrors an identity locally. Its _id is deliberately the SSO subject,
i.e. the website's User._id, so every existing LearnProfile, PilotProfile and
BridgeToken reference keeps resolving without a migration.
telemetry.ts mirrors records to the hosted service only when TELEMETRY_URL
and SERVICE_SECRET are both set — the self-host default is that nothing ever
leaves the instance. It writes locally first, buffers with a bound, drops on
overflow and never blocks the request path.
/api/service/user-deleted purges the app's half on account deletion. Unlike
telemetry this is deliberately loud: the admin delete aborts with the user
intact if the purge fails, because their id is the only handle for retrying.
?force=true overrides it and says so in the response.
Also here
/api/service/analytics/product-session was an unauthenticated public write
endpoint; it moves to /api/analytics/product-session behind the auth guard.
The bridge no longer populates against User but resolves through the mirror,
backfilling missing rows so live bridges never have to re-pair.
.claude/worktrees was tracked and would have reached the public repo.
scripts/split-paths.txt carries the filter list, verified by
scripts/verify-split-paths.mjs: every path exists, nothing website-only is
kept, and no kept file imports a dropped one. That check found real gaps —
tests/ cannot be taken wholesale, and two shared modules were missing. Ten
remaining edges are allowlisted, each annotated PHASE 1 in the code.
Open item, flagged and not resolved: flightlabTelemetryStore is an in-process
singleton written by the bridge (app) and read by FlightLab (website). Two
repos means two processes, so that read breaks regardless of which side it
lands on. FlightLab needs an HTTP path in Phase 2/3.
Verified: 609 tests pass, vue-tsc clean. Ran against two throwaway local
MongoDBs: open mode reaches /classroom and /live-atc with no login and
persists progress; the full SSO loop works and the mirror _id matches the
website User._id; lookalike origins, code reuse, forged codes and wrong
service secrets are all rejected; ingest is idempotent on bug-report code;
deletion purges all five collections; and with the app unreachable the admin
delete fails 502 with the user still present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
39
.env.example
39
.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
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,3 +32,7 @@ storage/**
|
||||
|
||||
# Worktrees
|
||||
.worktrees
|
||||
.claude/worktrees
|
||||
|
||||
# Lokaler Zwei-DB-Teststack (scripts/dev-local-stack.sh)
|
||||
.local-stack
|
||||
|
||||
11
app/app.vue
11
app/app.vue
@@ -6,7 +6,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { useHead, useHotjar, useRoute, useState } from '#imports';
|
||||
import { useHead, useHotjar, useRoute, useRuntimeConfig, useState } from '#imports';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { HOTJAR_LOCAL_STORAGE_KEY, useCookieConsent } from '~/composables/useCookieConsent';
|
||||
import {
|
||||
@@ -128,6 +128,13 @@ const persistLocalPreference = (value: 'granted' | 'denied' | null) => {
|
||||
};
|
||||
|
||||
const scheduleHotjarInitialization = () => {
|
||||
// No HOTJAR_ID configured means analytics is off for this instance —
|
||||
// initialize() would throw, and a self-hosted deployment must not be sending
|
||||
// sessions anywhere in the first place.
|
||||
if (!useRuntimeConfig().public.hotjar?.hotjarId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hotjarInitialized.value || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -164,7 +171,7 @@ const flushProductSession = () => {
|
||||
headers.Authorization = `Bearer ${authStore.accessToken}`;
|
||||
}
|
||||
|
||||
fetch('/api/service/analytics/product-session', {
|
||||
fetch('/api/analytics/product-session', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
import { defineNuxtRouteMiddleware, navigateTo } from '#app'
|
||||
import { defineNuxtRouteMiddleware, navigateTo, useRuntimeConfig } from '#app'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// AUTH_MODE=open: a self-hosted instance has no login, so there is nothing to
|
||||
// guard. The server resolves every request to the single local identity.
|
||||
if (config.public.authMode === 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.accessToken) {
|
||||
await auth.tryRefresh().catch(() => false)
|
||||
}
|
||||
|
||||
if (!auth.initialized) {
|
||||
await auth.fetchUser().catch(() => null)
|
||||
} else if (!auth.user) {
|
||||
if (!auth.initialized || !auth.user) {
|
||||
await auth.fetchUser().catch(() => null)
|
||||
}
|
||||
|
||||
if (!auth.user) {
|
||||
const redirect = encodeURIComponent(to.fullPath || '/')
|
||||
return navigateTo(`/login?redirect=${redirect}`)
|
||||
if (auth.user) return
|
||||
|
||||
const target = to.fullPath || '/'
|
||||
const issuer = String(config.public.authIssuer || '').replace(/\/+$/, '')
|
||||
|
||||
// No issuer configured means website and app still share an origin — the
|
||||
// local /login page is the login page.
|
||||
if (!issuer) {
|
||||
return navigateTo(`/login?redirect=${encodeURIComponent(target)}`)
|
||||
}
|
||||
|
||||
// The issuer needs an absolute URL to come back to, and it will only accept
|
||||
// one whose origin is on its allowlist.
|
||||
const redirect = new URL(target, window.location.origin).toString()
|
||||
return navigateTo(`${issuer}/login?redirect=${encodeURIComponent(redirect)}`, {
|
||||
external: true,
|
||||
})
|
||||
})
|
||||
|
||||
132
app/pages/auth/callback.vue
Normal file
132
app/pages/auth/callback.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="callback-shell">
|
||||
<div class="callback-card">
|
||||
<template v-if="error">
|
||||
<h1 class="callback-title">Anmeldung fehlgeschlagen</h1>
|
||||
<p class="callback-text">{{ error }}</p>
|
||||
<button class="callback-action" type="button" @click="retry">Erneut anmelden</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="callback-spinner" aria-hidden="true" />
|
||||
<h1 class="callback-title">Anmeldung wird abgeschlossen …</h1>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter, useRuntimeConfig, navigateTo } from '#app'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
// Consumer end of the SSO handoff: the issuer sent the browser here with a
|
||||
// one-time code. Redeeming it happens server-side (the code alone is useless
|
||||
// without SERVICE_SECRET); all this page does is trigger it and get out of the
|
||||
// way.
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const config = useRuntimeConfig()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const error = ref('')
|
||||
|
||||
function safeRedirectTarget(): string {
|
||||
const raw = String(route.query.redirect || '/')
|
||||
// Only same-origin paths — never bounce onward to an absolute URL supplied
|
||||
// in the query string.
|
||||
return raw.startsWith('/') && !raw.startsWith('//') ? raw : '/'
|
||||
}
|
||||
|
||||
function retry() {
|
||||
const issuer = String(config.public.authIssuer || '').replace(/\/+$/, '')
|
||||
const target = new URL(safeRedirectTarget(), window.location.origin).toString()
|
||||
if (!issuer) {
|
||||
return router.replace(`/login?redirect=${encodeURIComponent(safeRedirectTarget())}`)
|
||||
}
|
||||
return navigateTo(`${issuer}/login?redirect=${encodeURIComponent(target)}`, { external: true })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const code = String(route.query.code || '').trim()
|
||||
if (!code) {
|
||||
error.value = 'Es wurde kein Anmelde-Code übergeben.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.ssoCallback(code)
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage
|
||||
|| err?.statusMessage
|
||||
|| 'Der Anmelde-Code ist abgelaufen oder wurde bereits verwendet.'
|
||||
return
|
||||
}
|
||||
|
||||
await router.replace(safeRedirectTarget())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callback-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #0b1020;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.callback-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.callback-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.callback-text {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.callback-spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(34, 211, 238, 0.25);
|
||||
border-top-color: #22d3ee;
|
||||
animation: callback-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.callback-action {
|
||||
padding: 9px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.45);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
color: #22d3ee;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.callback-action:hover {
|
||||
background: rgba(34, 211, 238, 0.18);
|
||||
}
|
||||
|
||||
@keyframes callback-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.callback-spinner { animation-duration: 2.4s; }
|
||||
}
|
||||
</style>
|
||||
@@ -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<AuthUser>('/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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
77
server/api/analytics/product-session.post.ts
Normal file
77
server/api/analytics/product-session.post.ts
Normal file
@@ -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<ProductSessionBody>(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 }
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
36
server/api/auth/refresh.post.ts
Normal file
36
server/api/auth/refresh.post.ts
Normal file
@@ -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)
|
||||
})
|
||||
90
server/api/auth/sso/callback.post.ts
Normal file
90
server/api/auth/sso/callback.post.ts
Normal file
@@ -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<ExchangeResponse>(`${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,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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<string, DomeLightMode | null>()
|
||||
|
||||
/**
|
||||
@@ -155,8 +153,13 @@ export default defineEventHandler(async (event) => {
|
||||
console.table( telemetryKeys.reduce((acc, key) => { acc[key] = telemetry[key]; return acc }, {} as Record<string, any>) )
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BugReportSource, string> = {
|
||||
'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: `<h2>Neuer Bug Report</h2>
|
||||
<p><strong>Bereich:</strong> ${sourceLabel}</p>
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
69
server/api/service/user-deleted.post.ts
Normal file
69
server/api/service/user-deleted.post.ts
Normal file
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
42
server/models/AppUser.ts
Normal file
42
server/models/AppUser.ts
Normal file
@@ -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<AppUserDocument>(
|
||||
{
|
||||
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<AppUserDocument> | undefined) ||
|
||||
mongoose.model<AppUserDocument>('AppUser', appUserSchema)
|
||||
@@ -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<BridgeTokenDocument>(
|
||||
{
|
||||
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 },
|
||||
|
||||
@@ -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<Buffer>
|
||||
|
||||
@@ -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<string, any>, 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<string, any>
|
||||
}
|
||||
|
||||
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' })
|
||||
|
||||
135
server/utils/authMode.ts
Normal file
135
server/utils/authMode.ts
Normal file
@@ -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<AppUserDocument> | 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<AppUserDocument> {
|
||||
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<AppUserDocument>
|
||||
}
|
||||
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<string, number>()
|
||||
|
||||
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<string, unknown> = {
|
||||
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()
|
||||
}
|
||||
@@ -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<AppUserDocument | null> {
|
||||
if (!user) return null
|
||||
|
||||
// Already populated by the caller's .populate('user', ...).
|
||||
if (typeof user === 'object' && 'email' in (user as Record<string, unknown>)) {
|
||||
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 })
|
||||
}
|
||||
|
||||
53
server/utils/jwt.ts
Normal file
53
server/utils/jwt.ts
Normal file
@@ -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<string, any>, 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<string, any>
|
||||
}
|
||||
108
server/utils/session.ts
Normal file
108
server/utils/session.ts
Normal file
@@ -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: '/' })
|
||||
}
|
||||
145
server/utils/telemetry.ts
Normal file
145
server/utils/telemetry.ts
Normal file
@@ -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<string, unknown>
|
||||
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<typeof setTimeout> | 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<string, unknown>) {
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user