mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +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:
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 }
|
||||
})
|
||||
Reference in New Issue
Block a user