mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(settings): KI-provider — OpenAI key field, model preset dropdown, gated OAuth
Bucket C of the v1.4.3 polish pass. Three independent bugs in the KI-provider settings, fixed together because they all live on the same panel: 1. **Selecting "OpenAI" left no way to provide a key.** The schema stored Anthropic and Local API keys, but OPENAI was a valid `aiProvider` value with no matching credential column — the resolver silently fell through to the admin-level OpenAI key. So a self-hosted user who picked OpenAI and typed nothing got the admin's key (might not exist), and a user who *wanted* their own OpenAI key had no UI surface to enter it. Migration `0026_user_ai_openai_key` adds `users.ai_openai_key_ encrypted`. The resolver's `OPENAI` branch now picks up the user-level key first and only falls back to admin if the user slot is empty. The settings UI grew an OpenAI-key field gated on `provider === "OPENAI"` mirroring the Anthropic flow. The `/api/ai/test` override schema accepts `openaiKey` so the "Verbindung testen" button can validate an unsaved key without persisting plaintext. 2. **Model field was a freetext Input, easy to typo.** Replaced with a `<select>` populated from `MODEL_PRESETS` per provider: gpt-4o / gpt-4o-mini / gpt-4-turbo / gpt-5 / o3-mini for OPENAI; claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / claude- 3-5-sonnet-latest for ANTHROPIC; llama3.1:8b / llama3.1:70b / mistral / qwen2.5 for LOCAL. An "Eigenes…" sentinel still surfaces the freetext Input so power users can target preview models or fine-tunes. The dropdown disables when no provider is chosen — saves the user from filling a model that won't be used. 3. **"Mit ChatGPT verbinden" silently bounced to chatgpt.com login with no OAuth grant.** The authorize URL was built without a `client_id` parameter, so chatgpt.com couldn't recognise the request as an OAuth handshake — it just rendered the regular login form. The user came back, nothing connected, the button "did nothing". Added `CODEX_OAUTH_CLIENT_ID` env var. The URL builder now throws `CodexOAuthNotConfiguredError` if it's unset; the authorize route maps that to a 503 with an actionable message instead of dispatching the dead URL. The settings card reads `codexOauthConfigured` from `/api/insights/settings` and hides the Connect button when the flag is false, replacing it with a tiny italicised "ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert — nutze stattdessen einen eigenen API-Key unten." `CHATGPT_OAUTH` was also removed as a manually-selectable provider option in the dropdown — the top connect button is the path; having both confused the affordance. Refresh + token-exchange both forward the client_id now too. Test fixture seeds `process.env.CODEX_OAUTH_CLIENT_ID="test_client_id"` in `beforeEach` so the existing URL-shape assertion still works, plus a new test asserts the unset case throws. Tests + typecheck green (670/670 — one new assertion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
622099c0fd
commit
c05f48f520
10 files changed
+317
-49
No files matched your search
@@ -0,0 +1,14 @@
|
||||
-- v1.4.3: per-user OpenAI API key.
|
||||
--
|
||||
-- Until this release the schema stored Anthropic and Local-provider API
|
||||
-- keys but no field for the OPENAI provider, even though OPENAI was a
|
||||
-- valid value in `users.ai_provider`. Users who picked it had no place
|
||||
-- in the UI to drop their key and the runtime fell through to whichever
|
||||
-- admin-level fallback was configured. This column is the missing slot.
|
||||
--
|
||||
-- AES-256-GCM encrypted at the application layer (`src/lib/crypto.ts`)
|
||||
-- before the value lands on disk. Plaintext keys never touch Postgres
|
||||
-- backups.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "ai_openai_key_encrypted" TEXT;
|
||||
@@ -70,6 +70,11 @@ model User {
|
||||
aiBaseUrl String? @map("ai_base_url")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
|
||||
// v1.4.3: per-user OpenAI API key for the OPENAI provider — previously
|
||||
// the schema only stored Anthropic and Local keys, leaving OPENAI without
|
||||
// a credential surface. Encrypted with the active ENCRYPTION_KEYS entry
|
||||
// before storage.
|
||||
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
|
||||
@@ -22,6 +22,7 @@ const overrideSchema = z
|
||||
baseUrl: z.string().url().max(2048).optional().nullable(),
|
||||
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
||||
localKey: z.string().min(1).max(500).optional().nullable(),
|
||||
openaiKey: z.string().min(1).max(500).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
generatePKCE,
|
||||
generateState,
|
||||
buildAuthorizationUrl,
|
||||
CodexOAuthNotConfiguredError,
|
||||
isCodexOAuthConfigured,
|
||||
} from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -12,6 +14,17 @@ import { annotate } from "@/lib/logging/context";
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Surface configuration errors as 503 instead of silently redirecting
|
||||
// to a dead chatgpt.com URL. The v1.4.2 build dropped the `client_id`
|
||||
// out of the authorize URL — the user landed on the chatgpt.com login
|
||||
// page, no callback ever fired, and the connect button "did nothing".
|
||||
if (!isCodexOAuthConfigured()) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
|
||||
);
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
@@ -35,11 +48,21 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
cookieStore.set("codex_verifier", verifier, cookieOptions);
|
||||
cookieStore.set("codex_state", state, cookieOptions);
|
||||
|
||||
const authUrl = buildAuthorizationUrl({
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
redirectUri,
|
||||
});
|
||||
let authUrl: string;
|
||||
try {
|
||||
authUrl = buildAuthorizationUrl({
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
redirectUri,
|
||||
});
|
||||
} catch (err) {
|
||||
// Race between the env check above and the URL build — extremely
|
||||
// unlikely but the typed error path is cheap insurance.
|
||||
if (err instanceof CodexOAuthNotConfiguredError) {
|
||||
throw new HttpError(503, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
annotate({ action: { name: "codex.oauth.authorize" } });
|
||||
|
||||
return NextResponse.redirect(authUrl);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isCodexOAuthConfigured } from "@/lib/ai/codex-oauth";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -28,6 +29,11 @@ export const GET = apiHandler(async () => {
|
||||
codexStatus: dbUser?.codexConnectionStatus ?? "disconnected",
|
||||
codexConnectedAt: dbUser?.codexConnectedAt ?? null,
|
||||
hasAdminKey: !!settings?.adminAiKeyEncrypted,
|
||||
// v1.4.3: surface whether the operator has a `CODEX_OAUTH_CLIENT_ID`
|
||||
// configured. Without it the Connect-with-ChatGPT flow is dead, so
|
||||
// the UI hides the button instead of redirecting to a chatgpt.com
|
||||
// login the user can never complete.
|
||||
codexOauthConfigured: isCodexOAuthConfigured(),
|
||||
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
lastInsightAt: dbUser?.insightsCachedAt ?? null,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export const GET = apiHandler(async () => {
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,6 +35,10 @@ export const GET = apiHandler(async () => {
|
||||
? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
hasLocalKey: Boolean(u?.aiLocalKeyEncrypted),
|
||||
hasOpenaiKey: Boolean(u?.aiOpenaiKeyEncrypted),
|
||||
openaiKeyPreview: u?.aiOpenaiKeyEncrypted
|
||||
? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +105,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (body.openaiKey !== undefined) {
|
||||
if (body.openaiKey === null || body.openaiKey === "") {
|
||||
updates.aiOpenaiKeyEncrypted = null;
|
||||
} else if (typeof body.openaiKey === "string") {
|
||||
updates.aiOpenaiKeyEncrypted = encrypt(body.openaiKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new HttpError(422, "No valid fields");
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
codexConnectedAt: string | null;
|
||||
hasAdminKey: boolean;
|
||||
/** True when the operator has set `CODEX_OAUTH_CLIENT_ID` on this
|
||||
* instance. The UI hides the "Connect with ChatGPT" button when
|
||||
* false to avoid the v1.4.2 dead-end where the click bounced the
|
||||
* user to chatgpt.com without any OAuth flow. */
|
||||
codexOauthConfigured?: boolean;
|
||||
privacyMode: string;
|
||||
lastInsightAt: string | null;
|
||||
}
|
||||
@@ -29,8 +34,30 @@ interface UserAIProvider {
|
||||
hasAnthropicKey: boolean;
|
||||
anthropicKeyPreview: string | null;
|
||||
hasLocalKey: boolean;
|
||||
hasOpenaiKey: boolean;
|
||||
openaiKeyPreview: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model presets per provider — keeps users out of the freetext box for
|
||||
* the common case and signals which models the resolver will actually
|
||||
* use as defaults if the field is left blank. The `__custom__` sentinel
|
||||
* surfaces a freetext Input so power users can still target any model
|
||||
* the underlying API speaks (e.g. preview models, fine-tunes).
|
||||
*/
|
||||
const MODEL_PRESETS: Record<string, ReadonlyArray<string>> = {
|
||||
OPENAI: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-5", "o3-mini"],
|
||||
ANTHROPIC: [
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-7",
|
||||
"claude-haiku-4-5",
|
||||
"claude-3-5-sonnet-latest",
|
||||
],
|
||||
LOCAL: ["llama3.1:8b", "llama3.1:70b", "mistral", "qwen2.5"],
|
||||
};
|
||||
|
||||
const CUSTOM_MODEL_SENTINEL = "__custom__";
|
||||
|
||||
export function AiSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -275,14 +302,21 @@ function InsightsSettingsCard({
|
||||
Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Mit ChatGPT verbinden
|
||||
</Button>
|
||||
{settings?.codexOauthConfigured ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Mit ChatGPT verbinden
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert —
|
||||
nutze stattdessen einen eigenen API-Key unten.
|
||||
</p>
|
||||
)}
|
||||
{settings?.hasAdminKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Alternativ nutzt HealthLog den vom Administrator
|
||||
@@ -353,10 +387,12 @@ function InsightsSettingsCard({
|
||||
function UserAIProviderSubsection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [provider, setProvider] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [modelChoice, setModelChoice] = useState<string>("");
|
||||
const [customModel, setCustomModel] = useState<string>("");
|
||||
const [baseUrl, setBaseUrl] = useState<string>("");
|
||||
const [anthropicKey, setAnthropicKey] = useState<string>("");
|
||||
const [localKey, setLocalKey] = useState<string>("");
|
||||
const [openaiKey, setOpenaiKey] = useState<string>("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveOk, setSaveOk] = useState<boolean>(false);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
@@ -374,26 +410,57 @@ function UserAIProviderSubsection() {
|
||||
});
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
// The model dropdown reflects the saved value: an exact match against
|
||||
// the preset list selects that preset; anything else (or the empty
|
||||
// "use default" string) collapses to the custom-input mode so the
|
||||
// user keeps full control.
|
||||
const dataKey = data
|
||||
? `${data.provider ?? ""}|${data.model ?? ""}|${data.baseUrl ?? ""}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (dataKey && dataKey !== seededKey) {
|
||||
setSeededKey(dataKey);
|
||||
setProvider(data!.provider ?? "");
|
||||
setModel(data!.model ?? "");
|
||||
const nextProvider = data!.provider ?? "";
|
||||
setProvider(nextProvider);
|
||||
const presets = MODEL_PRESETS[nextProvider] ?? [];
|
||||
const savedModel = data!.model ?? "";
|
||||
if (savedModel && presets.includes(savedModel)) {
|
||||
setModelChoice(savedModel);
|
||||
setCustomModel("");
|
||||
} else if (savedModel) {
|
||||
setModelChoice(CUSTOM_MODEL_SENTINEL);
|
||||
setCustomModel(savedModel);
|
||||
} else {
|
||||
setModelChoice("");
|
||||
setCustomModel("");
|
||||
}
|
||||
setBaseUrl(data!.baseUrl ?? "");
|
||||
}
|
||||
|
||||
const presets = MODEL_PRESETS[provider] ?? [];
|
||||
const effectiveModel =
|
||||
modelChoice === CUSTOM_MODEL_SENTINEL
|
||||
? customModel.trim()
|
||||
: modelChoice.trim();
|
||||
|
||||
// Reset model dropdown when provider changes — the preset list is
|
||||
// provider-specific. Don't wipe the custom model the user typed in
|
||||
// case they wanted to keep using it (rare but cheap).
|
||||
function handleProviderChange(next: string) {
|
||||
setProvider(next);
|
||||
setModelChoice("");
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
provider: provider || null,
|
||||
model: model || null,
|
||||
model: effectiveModel || null,
|
||||
baseUrl: baseUrl || null,
|
||||
};
|
||||
if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim();
|
||||
if (localKey.trim()) body.localKey = localKey.trim();
|
||||
if (openaiKey.trim()) body.openaiKey = openaiKey.trim();
|
||||
const res = await fetch("/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -407,6 +474,7 @@ function UserAIProviderSubsection() {
|
||||
setSaveOk(true);
|
||||
setAnthropicKey("");
|
||||
setLocalKey("");
|
||||
setOpenaiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
},
|
||||
@@ -422,10 +490,11 @@ function UserAIProviderSubsection() {
|
||||
try {
|
||||
const overrideBody: Record<string, string> = {};
|
||||
if (provider) overrideBody.provider = provider;
|
||||
if (model.trim()) overrideBody.model = model.trim();
|
||||
if (effectiveModel) overrideBody.model = effectiveModel;
|
||||
if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim();
|
||||
if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim();
|
||||
if (localKey.trim()) overrideBody.localKey = localKey.trim();
|
||||
if (openaiKey.trim()) overrideBody.openaiKey = openaiKey.trim();
|
||||
const hasOverride = Object.keys(overrideBody).length > 0;
|
||||
const res = await fetch("/api/ai/test", {
|
||||
method: "POST",
|
||||
@@ -460,7 +529,8 @@ function UserAIProviderSubsection() {
|
||||
<p className="text-sm font-medium">KI-Provider (persönlich)</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen
|
||||
für Standard.
|
||||
für Standard. Für ChatGPT-Pro/Max-OAuth nutze den Verbinden-Button
|
||||
oben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -470,34 +540,80 @@ function UserAIProviderSubsection() {
|
||||
<select
|
||||
id="ai-provider-select"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
>
|
||||
<option value="">— Standard (Admin/Codex) —</option>
|
||||
<option value="OPENAI">OpenAI</option>
|
||||
<option value="OPENAI">OpenAI (API-Key)</option>
|
||||
<option value="ANTHROPIC">Anthropic (Claude)</option>
|
||||
<option value="LOCAL">Lokal (OpenAI-kompatibel)</option>
|
||||
<option value="CHATGPT_OAUTH">ChatGPT OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ai-model-input">Modell</Label>
|
||||
<Input
|
||||
id="ai-model-input"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === "ANTHROPIC"
|
||||
? "claude-3-5-sonnet-latest"
|
||||
: provider === "LOCAL"
|
||||
? "llama3:8b"
|
||||
: "gpt-4o-mini"
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<Label htmlFor="ai-model-select">Modell</Label>
|
||||
<select
|
||||
id="ai-model-select"
|
||||
value={modelChoice}
|
||||
onChange={(e) => setModelChoice(e.target.value)}
|
||||
disabled={!provider}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="">— Standard —</option>
|
||||
{presets.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
{provider && (
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>Eigenes…</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{modelChoice === CUSTOM_MODEL_SENTINEL && provider && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-model-custom">Eigener Modellname</Label>
|
||||
<Input
|
||||
id="ai-model-custom"
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === "ANTHROPIC"
|
||||
? "claude-3-5-sonnet-latest"
|
||||
: provider === "LOCAL"
|
||||
? "llama3:8b"
|
||||
: "gpt-4o-mini"
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "OPENAI" && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-openai-key">
|
||||
OpenAI API Key
|
||||
{data?.hasOpenaiKey && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
(gespeichert {data.openaiKeyPreview})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ai-openai-key"
|
||||
value={openaiKey}
|
||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Eigener Key — keine Verbindung zu deinem ChatGPT-Abo. Für das
|
||||
Abo nutze den Verbinden-Button oben.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "ANTHROPIC" && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-anthropic-key">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generatePKCE, generateState, buildAuthorizationUrl } from "../codex-oauth";
|
||||
import { afterEach, beforeEach, describe, it, expect } from "vitest";
|
||||
import {
|
||||
generatePKCE,
|
||||
generateState,
|
||||
buildAuthorizationUrl,
|
||||
CodexOAuthNotConfiguredError,
|
||||
} from "../codex-oauth";
|
||||
|
||||
describe("codex-oauth", () => {
|
||||
describe("generatePKCE", () => {
|
||||
@@ -29,6 +34,26 @@ describe("codex-oauth", () => {
|
||||
});
|
||||
|
||||
describe("buildAuthorizationUrl", () => {
|
||||
const ORIGINAL_CLIENT_ID = process.env.CODEX_OAUTH_CLIENT_ID;
|
||||
|
||||
beforeEach(() => {
|
||||
// v1.4.3: the URL builder requires CODEX_OAUTH_CLIENT_ID. The
|
||||
// v1.4.2 build silently dropped this so the resulting URL just
|
||||
// hit chatgpt.com login with no OAuth grant flow at all — the
|
||||
// "Connect with ChatGPT" button effectively did nothing in
|
||||
// production. Setting an explicit value here keeps the test
|
||||
// hermetic regardless of the runner env.
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = "test_client_id";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_CLIENT_ID === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID;
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = ORIGINAL_CLIENT_ID;
|
||||
}
|
||||
});
|
||||
|
||||
it("builds correct URL with all params", () => {
|
||||
const url = buildAuthorizationUrl({
|
||||
codeChallenge: "test-challenge",
|
||||
@@ -40,10 +65,22 @@ describe("codex-oauth", () => {
|
||||
expect(parsed.origin).toBe("https://chatgpt.com");
|
||||
expect(parsed.pathname).toBe("/authorize");
|
||||
expect(parsed.searchParams.get("response_type")).toBe("code");
|
||||
expect(parsed.searchParams.get("client_id")).toBe("test_client_id");
|
||||
expect(parsed.searchParams.get("code_challenge")).toBe("test-challenge");
|
||||
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(parsed.searchParams.get("state")).toBe("test-state");
|
||||
expect(parsed.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
||||
});
|
||||
|
||||
it("throws CodexOAuthNotConfiguredError when CODEX_OAUTH_CLIENT_ID is unset", () => {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID;
|
||||
expect(() =>
|
||||
buildAuthorizationUrl({
|
||||
codeChallenge: "x",
|
||||
state: "y",
|
||||
redirectUri: "https://example.com/cb",
|
||||
}),
|
||||
).toThrow(CodexOAuthNotConfiguredError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,33 @@ export function generateState(): string {
|
||||
return base64url(randomBytes(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when the operator has not configured `CODEX_OAUTH_CLIENT_ID` —
|
||||
* the v1.4.2 build silently dropped the param and rendered a generic
|
||||
* chatgpt.com login that never returned a code, so the "Connect with
|
||||
* ChatGPT" button effectively did nothing. Failing here lets the route
|
||||
* surface a 503 with an actionable message instead of redirecting to
|
||||
* a dead-end page.
|
||||
*/
|
||||
export class CodexOAuthNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
|
||||
);
|
||||
this.name = "CodexOAuthNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getCodexClientId(): string {
|
||||
const id = process.env.CODEX_OAUTH_CLIENT_ID?.trim();
|
||||
if (!id) throw new CodexOAuthNotConfiguredError();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function isCodexOAuthConfigured(): boolean {
|
||||
return Boolean(process.env.CODEX_OAUTH_CLIENT_ID?.trim());
|
||||
}
|
||||
|
||||
export function buildAuthorizationUrl(params: {
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
@@ -22,6 +49,7 @@ export function buildAuthorizationUrl(params: {
|
||||
}): string {
|
||||
const url = new URL("https://chatgpt.com/authorize");
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", getCodexClientId());
|
||||
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", params.state);
|
||||
@@ -46,6 +74,7 @@ export async function exchangeCodeForTokens(params: {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: getCodexClientId(),
|
||||
code: params.code,
|
||||
code_verifier: params.codeVerifier,
|
||||
redirect_uri: params.redirectUri,
|
||||
@@ -65,6 +94,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenRes
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
client_id: getCodexClientId(),
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
+34
-11
@@ -32,11 +32,7 @@ type UserAIRow = {
|
||||
aiBaseUrl: string | null;
|
||||
aiAnthropicKeyEncrypted: string | null;
|
||||
aiLocalKeyEncrypted: string | null;
|
||||
// legacy admin-key fallback (OpenAI uses adminAiKeyEncrypted via app_settings)
|
||||
// We store user-level OpenAI key in aiAnthropicKeyEncrypted? No — for OPENAI
|
||||
// selection at user-level we read app-settings admin key. The user override
|
||||
// for OpenAI is intentionally minimal: provider+model+baseUrl. Personal keys
|
||||
// are scoped to Anthropic/Local for now (matches the migration columns).
|
||||
aiOpenaiKeyEncrypted: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,11 +62,19 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
baseUrl: row.aiBaseUrl,
|
||||
});
|
||||
}
|
||||
case "OPENAI":
|
||||
// User selected OPENAI but personal keys live in admin app_settings;
|
||||
// signal "use admin OpenAI" by returning null and letting caller fall
|
||||
// through to resolveAdminProvider().
|
||||
return null;
|
||||
case "OPENAI": {
|
||||
// v1.4.3: user-level OpenAI key gets first crack — only fall back
|
||||
// to the admin key if the user hasn't supplied their own. The
|
||||
// model-default mirrors the admin path for consistency so a saved
|
||||
// user "OPENAI" without an explicit model still produces an
|
||||
// OpenAIClient with `gpt-4o-mini`.
|
||||
if (!row.aiOpenaiKeyEncrypted) return null;
|
||||
return new OpenAIClient({
|
||||
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
|
||||
model: row.aiModel ?? "gpt-4o-mini",
|
||||
baseUrl: row.aiBaseUrl ?? "https://api.openai.com/v1",
|
||||
});
|
||||
}
|
||||
case "CHATGPT_OAUTH":
|
||||
// Caller handles Codex OAuth via the dedicated branch; signal here.
|
||||
return null;
|
||||
@@ -209,6 +213,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,6 +246,7 @@ export type AITestOverride = {
|
||||
baseUrl?: string | null;
|
||||
anthropicKey?: string | null;
|
||||
localKey?: string | null;
|
||||
openaiKey?: string | null;
|
||||
};
|
||||
|
||||
export class AITestConfigError extends Error {
|
||||
@@ -270,6 +276,7 @@ export async function resolveProviderForTest(
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -333,11 +340,27 @@ export async function resolveProviderForTest(
|
||||
throw new AITestConfigError(422, "ChatGPT OAuth is not connected");
|
||||
}
|
||||
case "OPENAI": {
|
||||
// Test path mirrors the persistent resolution: user key first,
|
||||
// admin fallback if absent. We accept an `openaiKey` override
|
||||
// from the test endpoint so a user can verify a not-yet-saved
|
||||
// key dropdown change without persisting anything.
|
||||
const userKey =
|
||||
override.openaiKey?.trim() ||
|
||||
(stored?.aiOpenaiKeyEncrypted
|
||||
? decrypt(stored.aiOpenaiKeyEncrypted)
|
||||
: "");
|
||||
if (userKey) {
|
||||
return new OpenAIClient({
|
||||
apiKey: userKey,
|
||||
model: model || "gpt-4o-mini",
|
||||
baseUrl: baseUrl || "https://api.openai.com/v1",
|
||||
});
|
||||
}
|
||||
const admin = await resolveAdminProvider();
|
||||
if (admin.type === "none") {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
"Admin OpenAI key is not configured for this instance",
|
||||
"OpenAI key not configured (neither user nor admin)",
|
||||
);
|
||||
}
|
||||
return admin;
|
||||
|
||||
Reference in new issue
Block a user