Merge the environment and ingest route group

This commit is contained in:
Marc-André Bombeck committed 2026-08-22 13:28:18 +02:00
commit c985f634aa
44 files changed
+3943 -242

No files matched your search

@@ -178,10 +178,22 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => {
);
});
// Was `.rejects.toThrow(/between 10 and 600/)`. The route threw an
// HttpError for this one field while every other bad value was skipped in
// silence; it runs one Zod parse now, so the refusal is the standard
// multi-issue 422 that every sibling route answers with.
it("rejects an out-of-range response timeout", async () => {
await expect(
patch(patchRequest({ responseTimeoutSeconds: 5 })),
).rejects.toThrow(/between 10 and 600/);
const res = await patch(patchRequest({ responseTimeoutSeconds: 5 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta?: { errorCode?: string };
details?: { issues?: Array<{ path: string; message?: string }> };
};
expect(body.meta?.errorCode).toBe("ai_provider.invalid");
const issue = body.details?.issues?.find(
(i) => i.path === "responseTimeoutSeconds",
);
expect(issue?.message).toMatch(/between 10 and 600/);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
@@ -268,6 +280,98 @@ describe("PATCH /api/user/ai-provider — the gateway's own columns (#470)", ()
});
});
/**
* A wrongly-typed field must be refused, not skipped.
*
* The route inspected the body key by key with `typeof` guards. A value of
* the wrong type failed every guard and simply produced no entry in the
* update object, so the write went ahead WITHOUT it and answered 200 — the
* caller was told the save succeeded while the field they set was discarded.
* A body of nothing but such keys fell through to "No valid fields", naming
* none of them, which is the same silence with a worse message.
*/
describe("PATCH /api/user/ai-provider — wrongly-typed fields are named", () => {
const patch = PATCH as (req: Request) => Promise<Response>;
it.each([
["model", 42],
["baseUrl", true],
["compatModel", { nested: "object" }],
["anthropicKey", 12345],
["openaiKey", ["array"]],
["responseTimeoutSeconds", "120"],
])("refuses a %s of the wrong type and names its path", async (key, bad) => {
const res = await patch(patchRequest({ [key]: bad }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta?: { errorCode?: string };
details?: { issues?: Array<{ path: string }> };
};
expect(body.meta?.errorCode).toBe("ai_provider.invalid");
expect(body.details?.issues?.map((i) => i.path)).toContain(key);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("refuses the whole write when one field of several is wrongly typed", async () => {
vi.mocked(isPublicUrl).mockReturnValue(true);
const res = await patch(
patchRequest({ provider: "OPENAI", model: 42, openaiKey: "sk-real" }),
);
expect(res.status).toBe(422);
// Partial application would have persisted the provider and dropped the
// model, leaving the account in a state the caller never asked for.
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("refuses an unrecognised provider and names its path", async () => {
const res = await patch(patchRequest({ provider: "GEMINI" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details?: { issues?: Array<{ path: string }> };
};
expect(body.details?.issues?.map((i) => i.path)).toContain("provider");
});
it("never echoes a rejected credential back in the error body", async () => {
const res = await patch(
patchRequest({ anthropicKey: 12345, openaiKey: "sk-super-secret" }),
);
expect(res.status).toBe(422);
expect(JSON.stringify(await res.json())).not.toContain("sk-super-secret");
});
it("still answers 'No valid fields' for a body of nothing it knows", async () => {
const res = await patch(patchRequest({ somethingElse: "value" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.error).toBe("No valid fields");
expect(body.meta?.errorCode).toBe("ai_provider.no_fields");
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("keeps accepting the three-state contract on a known field", async () => {
vi.mocked(isPublicUrl).mockReturnValue(true);
await patch(patchRequest({ model: " gpt-4o ", anthropicKey: "" }));
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
aiModel: "gpt-4o",
aiAnthropicKeyEncrypted: null,
}),
}),
);
// An omitted key is untouched, not cleared.
const data = vi.mocked(prisma.user.update).mock.calls[0][0].data as Record<
string,
unknown
>;
expect(data).not.toHaveProperty("aiOpenaiKeyEncrypted");
});
});
describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => {
it("reports the base URL and model, and the key as presence only", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+61 -80
View File
@@ -1,25 +1,21 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
import { encrypt, decrypt } from "@/lib/crypto";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { annotate } from "@/lib/logging/context";
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
export const dynamic = "force-dynamic";
const ALLOWED = new Set([
"OPENAI",
"ANTHROPIC",
"LOCAL",
// v1.33.1 (#470) — the OpenAI-compatible gateway (LiteLLM / OpenRouter /
// vLLM). Its base URL, key and model live in their own three columns.
"OPENAI_COMPATIBLE",
"CHATGPT_OAUTH",
]);
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "user.ai-provider.get" } });
@@ -78,39 +74,47 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.ai-provider.update" } });
const { data: body, error } = await safeJson<Record<string, unknown>>(
request,
{ maxBytes: 64 * 1024 },
);
const { data: rawBody, error } = await safeJson<unknown>(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
// Every accepted field states its type in the schema, so a wrongly-typed
// value is a named issue rather than a key quietly dropped on the floor.
// The multi-issue envelope is safe to return even though this body carries
// plaintext credentials: Zod's type and range messages name the received
// TYPE, never the received value. See the note in the validations module
// before adding a `.refine()` here.
const parsed = aiProviderPatchSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "user.ai-provider.update" },
meta: {
outcome: "validation_failed",
issue_count: parsed.error.issues.length,
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "ai_provider.invalid",
});
}
const body = parsed.data;
const updates: Record<string, unknown> = {};
if (body.provider !== undefined) {
if (body.provider === null || body.provider === "") {
updates.aiProvider = null;
} else if (
typeof body.provider === "string" &&
ALLOWED.has(body.provider)
) {
updates.aiProvider = body.provider;
} else {
throw new HttpError(422, "Invalid provider");
}
updates.aiProvider =
body.provider === null || body.provider === "" ? null : body.provider;
}
if (body.model !== undefined) {
if (body.model === null || body.model === "") {
updates.aiModel = null;
} else if (typeof body.model === "string") {
updates.aiModel = body.model.trim() || null;
}
updates.aiModel = body.model === null ? null : body.model.trim() || null;
}
if (body.baseUrl !== undefined) {
if (body.baseUrl === null || body.baseUrl === "") {
updates.aiBaseUrl = null;
} else if (typeof body.baseUrl === "string") {
} else {
const trimmed = body.baseUrl.trim();
// SSRF guard: by default reject private/internal hostnames so a
// compromised user account cannot point the server at the cloud
@@ -137,7 +141,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
if (body.compatBaseUrl !== undefined) {
if (body.compatBaseUrl === null || body.compatBaseUrl === "") {
updates.aiCompatBaseUrl = null;
} else if (typeof body.compatBaseUrl === "string") {
} else {
const trimmed = body.compatBaseUrl.trim();
// Same SSRF floor as the Local provider: a public host always, a
// private one only when the operator allowlisted it. Gateways on a LAN
@@ -154,72 +158,49 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
}
if (body.compatModel !== undefined) {
if (body.compatModel === null || body.compatModel === "") {
updates.aiCompatModel = null;
} else if (typeof body.compatModel === "string") {
updates.aiCompatModel = body.compatModel.trim() || null;
}
updates.aiCompatModel =
body.compatModel === null ? null : body.compatModel.trim() || null;
}
if (body.compatKey !== undefined) {
if (body.compatKey === null || body.compatKey === "") {
updates.aiCompatKeyEncrypted = null;
} else if (typeof body.compatKey === "string") {
updates.aiCompatKeyEncrypted = encrypt(body.compatKey.trim());
}
updates.aiCompatKeyEncrypted =
body.compatKey === null || body.compatKey === ""
? null
: encrypt(body.compatKey.trim());
}
if (body.anthropicKey !== undefined) {
if (body.anthropicKey === null || body.anthropicKey === "") {
updates.aiAnthropicKeyEncrypted = null;
} else if (typeof body.anthropicKey === "string") {
updates.aiAnthropicKeyEncrypted = encrypt(body.anthropicKey.trim());
}
updates.aiAnthropicKeyEncrypted =
body.anthropicKey === null || body.anthropicKey === ""
? null
: encrypt(body.anthropicKey.trim());
}
if (body.localKey !== undefined) {
if (body.localKey === null || body.localKey === "") {
updates.aiLocalKeyEncrypted = null;
} else if (typeof body.localKey === "string") {
updates.aiLocalKeyEncrypted = encrypt(body.localKey.trim());
}
updates.aiLocalKeyEncrypted =
body.localKey === null || body.localKey === ""
? null
: encrypt(body.localKey.trim());
}
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());
}
updates.aiOpenaiKeyEncrypted =
body.openaiKey === null || body.openaiKey === ""
? null
: encrypt(body.openaiKey.trim());
}
// ── v1.22 (#89) — response timeout (seconds) ──────────────────
// The 10–600 bounds are the schema's; an out-of-range value never reaches
// here.
if (body.responseTimeoutSeconds !== undefined) {
if (body.responseTimeoutSeconds === null) {
updates.aiResponseTimeoutSeconds = null;
} else if (
typeof body.responseTimeoutSeconds === "number" &&
Number.isInteger(body.responseTimeoutSeconds)
) {
// Sane bounds: 10 s floor (a stricter value just trips the timeout) and
// 600 s ceiling (a generous cap for a slow self-hosted backend).
if (
body.responseTimeoutSeconds < 10 ||
body.responseTimeoutSeconds > 600
) {
throw new HttpError(
422,
"Response timeout must be between 10 and 600 seconds",
);
}
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
} else {
throw new HttpError(422, "Invalid response timeout");
}
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
}
if (Object.keys(updates).length === 0) {
throw new HttpError(422, "No valid fields");
return apiError("No valid fields", 422, {
errorCode: "ai_provider.no_fields",
});
}
// When the provider switches away from LOCAL, drop any stored