diff --git a/src/app/api/admin/ai-settings/route.ts b/src/app/api/admin/ai-settings/route.ts new file mode 100644 index 000000000..27fc07a07 --- /dev/null +++ b/src/app/api/admin/ai-settings/route.ts @@ -0,0 +1,103 @@ +import { NextRequest } from "next/server"; +import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; +import { encrypt } from "@/lib/crypto"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.ai-settings.get" } }); + + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + + const hasKey = Boolean(settings?.adminAiKeyEncrypted); + const model = settings?.adminAiModel ?? "gpt-4o-mini"; + const baseUrl = settings?.adminAiBaseUrl ?? "https://api.openai.com/v1"; + + return apiSuccess({ + hasKey, + keyPreview: hasKey && settings?.adminAiKeyEncrypted + ? `${settings.adminAiKeyEncrypted.slice(0, 7)}...` + : null, + model, + baseUrl, + }); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.ai-settings.update" } }); + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const { apiKey, model, baseUrl } = body as { + apiKey?: string; + model?: string; + baseUrl?: string; + }; + + const updates: Record = {}; + const auditDetails: Record = {}; + + if (apiKey !== undefined) { + const trimmed = apiKey.trim(); + if (trimmed) { + updates.adminAiKeyEncrypted = encrypt(trimmed); + auditDetails.apiKeyUpdated = true; + } else { + updates.adminAiKeyEncrypted = null; + auditDetails.apiKeyUpdated = false; + } + } + + if (model !== undefined) { + const trimmed = model.trim(); + if (!trimmed) throw new HttpError(422, "Model cannot be empty"); + updates.adminAiModel = trimmed; + auditDetails.model = trimmed; + } + + if (baseUrl !== undefined) { + const trimmed = baseUrl.trim(); + if (!trimmed) throw new HttpError(422, "Base URL cannot be empty"); + if (!trimmed.startsWith("https://")) { + throw new HttpError(422, "Base URL must start with https://"); + } + updates.adminAiBaseUrl = trimmed; + auditDetails.baseUrl = trimmed; + } + + if (Object.keys(updates).length === 0) { + throw new HttpError(422, "No valid fields"); + } + + const settings = await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: updates, + create: { id: "singleton", ...updates }, + }); + + await auditLog("admin.ai-settings.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: auditDetails, + }); + + const hasKey = Boolean(settings.adminAiKeyEncrypted); + + return apiSuccess({ + hasKey, + keyPreview: hasKey && settings.adminAiKeyEncrypted + ? `${settings.adminAiKeyEncrypted.slice(0, 7)}...` + : null, + model: settings.adminAiModel, + baseUrl: settings.adminAiBaseUrl, + }); +}); diff --git a/src/app/api/auth/codex/authorize/route.ts b/src/app/api/auth/codex/authorize/route.ts new file mode 100644 index 000000000..e44d01f7c --- /dev/null +++ b/src/app/api/auth/codex/authorize/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + generatePKCE, + generateState, + buildAuthorizationUrl, +} from "@/lib/ai/codex-oauth"; +import { cookies } from "next/headers"; +import { annotate } from "@/lib/logging/context"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { verifier, challenge } = generatePKCE(); + const state = generateState(); + + const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL; + if (!appUrl) throw new Error("APP_URL not configured"); + + const redirectUri = `${appUrl}/api/auth/codex/callback`; + + const cookieStore = await cookies(); + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + maxAge: 300, + }; + + cookieStore.set("codex_verifier", verifier, cookieOptions); + cookieStore.set("codex_state", state, cookieOptions); + + const authUrl = buildAuthorizationUrl({ + codeChallenge: challenge, + state, + redirectUri, + }); + annotate({ action: { name: "codex.oauth.authorize" } }); + + return NextResponse.redirect(authUrl); +}); diff --git a/src/app/api/auth/codex/callback/route.ts b/src/app/api/auth/codex/callback/route.ts new file mode 100644 index 000000000..29c5b72b7 --- /dev/null +++ b/src/app/api/auth/codex/callback/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { exchangeCodeForTokens, encryptTokens } from "@/lib/ai/codex-oauth"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { getClientIp } from "@/lib/api-response"; +import { cookies } from "next/headers"; +import { annotate } from "@/lib/logging/context"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const url = new URL(request.url); + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL; + if (!appUrl) throw new Error("APP_URL not configured"); + + if (error) { + annotate({ meta: { oauth_error: error } }); + return NextResponse.redirect( + `${appUrl}/settings?codex_error=${encodeURIComponent(error)}`, + ); + } + + if (!code || !state) { + return NextResponse.redirect( + `${appUrl}/settings?codex_error=missing_params`, + ); + } + + const cookieStore = await cookies(); + const storedState = cookieStore.get("codex_state")?.value; + const storedVerifier = cookieStore.get("codex_verifier")?.value; + + cookieStore.delete("codex_state"); + cookieStore.delete("codex_verifier"); + + if (!storedState || !storedVerifier || state !== storedState) { + return NextResponse.redirect( + `${appUrl}/settings?codex_error=invalid_state`, + ); + } + + const redirectUri = `${appUrl}/api/auth/codex/callback`; + + try { + const tokens = await exchangeCodeForTokens({ + code, + codeVerifier: storedVerifier, + redirectUri, + }); + const encrypted = encryptTokens({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { + codexAccessTokenEncrypted: encrypted.accessEncrypted, + codexRefreshTokenEncrypted: encrypted.refreshEncrypted, + codexTokenExpiresAt: new Date( + Date.now() + tokens.expires_in * 1000, + ), + codexConnectedAt: new Date(), + codexConnectionStatus: "connected", + insightsCachedAt: null, + insightsCachedText: null, + }, + }); + + await auditLog("codex.oauth.connected", { + userId: user.id, + ipAddress: getClientIp(request), + }); + annotate({ action: { name: "codex.oauth.callback.success" } }); + + return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`); + } catch (err) { + const message = err instanceof Error ? err.message : "unknown"; + annotate({ meta: { codex_token_error: message } }); + return NextResponse.redirect( + `${appUrl}/settings?codex_error=token_exchange_failed`, + ); + } +}); diff --git a/src/app/api/auth/codex/disconnect/route.ts b/src/app/api/auth/codex/disconnect/route.ts new file mode 100644 index 000000000..5ea6afa27 --- /dev/null +++ b/src/app/api/auth/codex/disconnect/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + await prisma.user.update({ + where: { id: user.id }, + data: { + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + codexTokenExpiresAt: null, + codexConnectedAt: null, + codexConnectionStatus: "disconnected", + insightsCachedAt: null, + insightsCachedText: null, + }, + }); + + await auditLog("codex.oauth.disconnected", { + userId: user.id, + ipAddress: getClientIp(request), + }); + annotate({ action: { name: "codex.oauth.disconnect" } }); + + return apiSuccess({ disconnected: true }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 952b27810..f006fd5bd 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -17,6 +17,7 @@ const PUBLIC_PATHS = [ "/api/withings/webhook", "/api/telegram/webhook", "/api/integrations/moodlog/webhook", + "/api/auth/codex/callback", "/api/ingest/", "/onboarding", "/robots.txt",