feat(api): PUT /api/insights/provider-chain persists user fallback chain

The B5b read-only endpoint now has a write counterpart so the new
Settings → AI dropdown UX can save reorder + enable/disable changes.
Server-side priority is recomputed from insertion order (so the UI's
visual contract — "first row = priority 1" — cannot drift from what
gets persisted), unknown provider types and duplicates 422, and an
empty chain is rejected outright. parseProviderChain still falls
back to the default chain when the persisted JSON is malformed,
so a stale-tab resubmit cannot poison generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:58:05 +02:00
1 parent 8352c6daa3
commit 3e037b4d42
2 files changed
+249 -6

No files matched your search

@@ -0,0 +1,166 @@
/**
* v1.4.16 phase B2 — write endpoint for the user's AI provider chain.
*
* The Settings → AI section drives reorder + enable/disable through a
* single dropdown + arrow controls; the persisted shape lives in
* `User.aiProviderChain` (Json), parsed back through
* `parseProviderChain()` on every read so a malformed payload from a
* stale client cannot poison the chain.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mirror `apiHandler`'s real behaviour: an `HttpError` thrown from the
// inner handler is converted to a JSON envelope. The tests assert on
// `res.status`, so the wrapper must surface the correct status code.
vi.mock("@/lib/api-handler", () => {
class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "HttpError";
}
}
return {
apiHandler:
<T extends (...args: unknown[]) => unknown>(fn: T) =>
async (...args: unknown[]) => {
try {
return await fn(...args);
} catch (e) {
if (e instanceof HttpError) {
return new Response(
JSON.stringify({ data: null, error: e.message }),
{
status: e.status,
headers: { "content-type": "application/json" },
},
);
}
throw e;
}
},
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
HttpError,
};
});
vi.mock("@/lib/db", () => ({
prisma: {
user: {
update: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
interface Envelope {
data?: { saved: true } | null;
error?: string | null;
}
function jsonRequest(body: unknown): Request {
return new Request("http://t/test", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/insights/provider-chain", () => {
it("persists a valid chain to user.aiProviderChain", async () => {
vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never);
const res = await (PUT as (req: Request) => Promise<Response>)(
jsonRequest({
chain: [
{ providerType: "openai", priority: 1, enabled: true },
{ providerType: "codex", priority: 2, enabled: false },
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as Envelope;
expect(body.data).toEqual({ saved: true });
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const callArg = vi.mocked(prisma.user.update).mock.calls[0][0];
expect(callArg.where).toEqual({ id: "u-1" });
expect(callArg.data).toEqual({
aiProviderChain: [
{ providerType: "openai", priority: 1, enabled: true },
{ providerType: "codex", priority: 2, enabled: false },
],
});
expect(annotate).toHaveBeenCalled();
});
it("rejects unknown provider types with 422", async () => {
const res = await (PUT as (req: Request) => Promise<Response>)(
jsonRequest({
chain: [{ providerType: "bogus", priority: 1, enabled: true }],
}),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an empty chain with 422", async () => {
const res = await (PUT as (req: Request) => Promise<Response>)(
jsonRequest({ chain: [] }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects duplicate provider types in the same chain with 422", async () => {
const res = await (PUT as (req: Request) => Promise<Response>)(
jsonRequest({
chain: [
{ providerType: "openai", priority: 1, enabled: true },
{ providerType: "openai", priority: 2, enabled: true },
],
}),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("normalises priority to insertion order even when client supplies arbitrary numbers", async () => {
vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never);
const res = await (PUT as (req: Request) => Promise<Response>)(
jsonRequest({
chain: [
{ providerType: "openai", priority: 99, enabled: true },
{ providerType: "codex", priority: 1, enabled: true },
],
}),
);
expect(res.status).toBe(200);
// The route MUST normalise priority to the wire order so the UI's
// drag-arrow contract matches what gets persisted: "first row in the
// list always has priority 1, second row priority 2, …".
const callArg = vi.mocked(prisma.user.update).mock.calls[0][0];
expect(callArg.data.aiProviderChain).toEqual([
{ providerType: "openai", priority: 1, enabled: true },
{ providerType: "codex", priority: 2, enabled: true },
]);
});
});
+83 -6
View File
@@ -1,8 +1,13 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import { apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveProviderChain } from "@/lib/ai/provider";
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
import { prisma } from "@/lib/db";
import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain";
/**
* v1.4.16 phase B5b — read-only chain summary for the Settings → AI
@@ -19,10 +24,13 @@ import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
* for the v1.4.17 UX where unconfigured slots should still render
* in the list with a "needs setup" pill.
*
* The full management UX (drag-to-reorder, enable/disable, add/remove
* provider entries) is owned by B2 (AI provider settings cleanup) — we
* intentionally keep this endpoint and the matching panel small so
* the two phases don't collide.
* v1.4.16 phase B2 added the matching `PUT` for the new dropdown-driven
* settings UX — the user reorders/toggles entries in the section and
* the section persists the chain through this endpoint. The persisted
* shape lives on `User.aiProviderChain` (Json); a malformed payload
* from an out-of-date client cannot poison generation because every
* read goes through `parseProviderChain()`, which falls back to the
* default chain when given garbage.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -47,3 +55,72 @@ export const GET = apiHandler(async () => {
})),
});
});
const chainEntrySchema = z.object({
providerType: z.enum(
PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]],
),
// Priority is recomputed server-side from insertion order so a stale
// client cannot persist a chain whose displayed order disagrees with
// its priority field. The number is accepted (and may be present) but
// ignored on the wire.
priority: z.number().int().optional(),
enabled: z.boolean(),
});
const chainBodySchema = z.object({
chain: z
.array(chainEntrySchema)
.min(1, "Chain must contain at least one provider")
.max(PROVIDER_CHAIN_TYPES.length, "Too many providers"),
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "insights.provider_chain.update" } });
const { data: body, error } = await safeJson<unknown>(request);
if (error) return error;
const parsed = chainBodySchema.safeParse(body);
if (!parsed.success) {
throw new HttpError(422, "Invalid provider chain payload");
}
// Defence-in-depth: reject duplicate provider types so a stale-tab
// resubmit cannot create a chain that walks the same entry twice.
// `parseProviderChain()` already deduplicates on read but we'd rather
// never persist a duplicate to begin with — drift between the two
// representations is the kind of bug that wastes an evening.
const seen = new Set<string>();
for (const entry of parsed.data.chain) {
if (seen.has(entry.providerType)) {
throw new HttpError(422, "Duplicate provider in chain");
}
seen.add(entry.providerType);
}
// Normalise priority to insertion order (1-based). UI hands us the
// visual order it wants persisted; we make priority match so a later
// GET reflects the same order without depending on the client's math.
const normalised = parsed.data.chain.map((entry, idx) => ({
providerType: entry.providerType,
priority: idx + 1,
enabled: entry.enabled,
}));
await prisma.user.update({
where: { id: user.id },
data: { aiProviderChain: normalised },
});
annotate({
meta: {
chain_length: normalised.length,
chain_first: normalised[0]?.providerType ?? null,
chain_disabled_count: normalised.filter((e) => !e.enabled).length,
},
});
return apiSuccess({ saved: true });
});