fix(ai): treat a stored provider key that cannot be opened as no key

A key id retired from ENCRYPTION_KEYS, or a corrupt row, made decrypt throw
inside the provider builders, and a briefing generation answered 500. An
unreadable key now drops its entry from the chain like a missing one, with a
warning on the event, so the generation answers the no-provider 422
(ai.provider.none). The connection test reads it as no stored key. The
parity walk holds that case to the contract again.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 16:44:50 +02:00
1 parent 976b2ba0bc
commit b02caed221
3 files changed
+64 -32

No files matched your search

+54 -18
View File
@@ -34,6 +34,7 @@ import {
} from "@/lib/sharing/provider-work-authority";
import { memoizePerRequest } from "@/lib/request-cache";
import { emitSignal } from "@/lib/logging/signal";
import { getEvent } from "@/lib/logging/context";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
@@ -370,6 +371,22 @@ function isOperatorOwned(row: { role: string } | null | undefined): boolean {
return row?.role === "ADMIN";
}
/**
* A stored provider key, or null when it cannot be opened: a key id retired
* from `ENCRYPTION_KEYS`, a corrupt row. An unreadable key is an unconfigured
* provider, the same as no key at all. Its entry drops out of the chain, so a
* generation answers the no-provider refusal (422 `ai.provider.none`)
* instead of the decrypt's 500, and the operator sees the warning.
*/
function openStoredKey(encrypted: string): string | null {
try {
return decrypt(encrypted);
} catch {
getEvent()?.addWarning("ai: a stored provider key could not be opened");
return null;
}
}
/**
* v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's
* three dedicated columns. The signature names every column this provider is
@@ -400,8 +417,12 @@ function buildCompatProvider(row: {
}): AIProvider | null {
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
if (!row.aiCompatBaseUrl || !model) return null;
const apiKey = row.aiCompatKeyEncrypted
? openStoredKey(row.aiCompatKeyEncrypted)
: "";
if (apiKey === null) return null;
return new OpenAIClient({
apiKey: row.aiCompatKeyEncrypted ? decrypt(row.aiCompatKeyEncrypted) : "",
apiKey,
model,
baseUrl: row.aiCompatBaseUrl,
providerType: "openai-compatible",
@@ -424,17 +445,21 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
// configuration survived in the row, refuse to forward an Anthropic
// key to it. Anthropic has no per-tenant base URL the UI exposes;
// the SDK default is correct.
const apiKey = openStoredKey(row.aiAnthropicKeyEncrypted);
if (apiKey === null) return null;
return new AnthropicClient({
apiKey: decrypt(row.aiAnthropicKeyEncrypted),
apiKey,
model: row.aiModel ?? "claude-sonnet-4-6",
});
}
case "LOCAL": {
if (!row.aiBaseUrl) return null;
const apiKey = row.aiLocalKeyEncrypted
? openStoredKey(row.aiLocalKeyEncrypted)
: null;
if (row.aiLocalKeyEncrypted && apiKey === null) return null;
return new LocalOpenAICompatibleClient({
apiKey: row.aiLocalKeyEncrypted
? decrypt(row.aiLocalKeyEncrypted)
: null,
apiKey,
model: row.aiModel ?? "local-model",
baseUrl: row.aiBaseUrl,
operatorTrusted: isOperatorOwned(row),
@@ -455,8 +480,10 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
// shared with LOCAL, so a stale LAN URL there would otherwise
// redirect the user's OpenAI key to a private host.
if (!row.aiOpenaiKeyEncrypted) return null;
const apiKey = openStoredKey(row.aiOpenaiKeyEncrypted);
if (apiKey === null) return null;
return new OpenAIClient({
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
apiKey,
model: row.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
@@ -515,17 +542,20 @@ async function resolveAdminProvider(): Promise<AIProvider> {
where: { id: "singleton" },
});
if (settings?.adminAiKeyEncrypted) {
const adminKey = settings?.adminAiKeyEncrypted
? openStoredKey(settings.adminAiKeyEncrypted)
: null;
if (settings && adminKey !== null) {
const baseUrl = settings.adminAiBaseUrl ?? "https://api.openai.com/v1";
if (isAnthropicBaseUrl(baseUrl)) {
return new AnthropicClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
apiKey: adminKey,
model: settings.adminAiModel ?? "claude-sonnet-4-6",
baseUrl,
});
}
return new OpenAIClient({
apiKey: decrypt(settings.adminAiKeyEncrypted),
apiKey: adminKey,
model: settings.adminAiModel ?? "gpt-4o",
baseUrl,
// The instance-wide provider is the operator's own configuration.
@@ -1352,8 +1382,10 @@ async function resolveProviderForType(
case "openai": {
const enc = ctx.userRow?.aiOpenaiKeyEncrypted;
if (!enc) return null;
const apiKey = openStoredKey(enc);
if (apiKey === null) return null;
return new OpenAIClient({
apiKey: decrypt(enc),
apiKey,
model: ctx.userRow?.aiModel ?? "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
@@ -1361,17 +1393,21 @@ async function resolveProviderForType(
case "anthropic": {
const enc = ctx.userRow?.aiAnthropicKeyEncrypted;
if (!enc) return null;
const apiKey = openStoredKey(enc);
if (apiKey === null) return null;
return new AnthropicClient({
apiKey: decrypt(enc),
apiKey,
model: ctx.userRow?.aiModel ?? "claude-sonnet-4-6",
});
}
case "local": {
if (!ctx.userRow?.aiBaseUrl) return null;
const apiKey = ctx.userRow.aiLocalKeyEncrypted
? openStoredKey(ctx.userRow.aiLocalKeyEncrypted)
: null;
if (ctx.userRow.aiLocalKeyEncrypted && apiKey === null) return null;
return new LocalOpenAICompatibleClient({
apiKey: ctx.userRow.aiLocalKeyEncrypted
? decrypt(ctx.userRow.aiLocalKeyEncrypted)
: null,
apiKey,
model: ctx.userRow.aiModel ?? "local-model",
baseUrl: ctx.userRow.aiBaseUrl,
operatorTrusted: isOperatorOwned(ctx.userRow),
@@ -1505,7 +1541,7 @@ async function resolveProviderForTestUnbound(
const apiKey =
override.anthropicKey?.trim() ||
(stored?.aiAnthropicKeyEncrypted
? decrypt(stored.aiAnthropicKeyEncrypted)
? (openStoredKey(stored.aiAnthropicKeyEncrypted) ?? "")
: "");
if (!apiKey) {
throw new AITestConfigError(422, "Anthropic API key not configured");
@@ -1534,7 +1570,7 @@ async function resolveProviderForTestUnbound(
const apiKey =
override.localKey?.trim() ||
(stored?.aiLocalKeyEncrypted
? decrypt(stored.aiLocalKeyEncrypted)
? (openStoredKey(stored.aiLocalKeyEncrypted) ?? null)
: null);
return new LocalOpenAICompatibleClient({
apiKey,
@@ -1583,7 +1619,7 @@ async function resolveProviderForTestUnbound(
const apiKey =
override.compatKey?.trim() ||
(stored?.aiCompatKeyEncrypted
? decrypt(stored.aiCompatKeyEncrypted)
? (openStoredKey(stored.aiCompatKeyEncrypted) ?? "")
: "");
return new OpenAIClient({
apiKey,
@@ -1608,7 +1644,7 @@ async function resolveProviderForTestUnbound(
const userKey =
override.openaiKey?.trim() ||
(stored?.aiOpenaiKeyEncrypted
? decrypt(stored.aiOpenaiKeyEncrypted)
? (openStoredKey(stored.aiOpenaiKeyEncrypted) ?? "")
: "");
if (userKey) {
return new OpenAIClient({
@@ -1,23 +1,23 @@
{
"status": 500,
"status": 422,
"body": {
"data": null,
"error": "Interner Serverfehler",
"error": "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
"meta": {
"errorCode": "http.internal",
"errorCode": "ai.provider.none",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 500,
"status": 422,
"action": null,
"meta": null,
"warnings": null,
"error": {
"type": "TypeError",
"message": "Invalid authentication tag length: 0"
}
"warnings": [
"ai: a stored provider key could not be opened",
"ai: a stored provider key could not be opened"
],
"error": null
}
],
"effects": [],
@@ -797,11 +797,7 @@ describe("POST /api/insights/generate parity", () => {
await post(
"P09-unreadable-key",
{ force: true },
{
auth: unreadableKey,
userId: UNREADABLE_KEY_ID,
undeclared: "an unreadable stored key ends in 500 (pinned as it is)",
},
{ auth: unreadableKey, userId: UNREADABLE_KEY_ID },
);
});