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

@@ -314,6 +314,7 @@ function sourceClient() {
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
@@ -59,6 +59,7 @@ import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -120,6 +121,7 @@ interface RestoreResponse {
userAchievements: number;
environmentContexts: number;
environmentTravelLocations: number;
ecgRecordings: number;
};
}
@@ -564,6 +566,23 @@ const handler = apiHandler(
const medByName = new Map<string, string>();
const restoredMedicationIds = new Set<string>();
// The efficacy targets cannot ride inside the medication create
// beside the schedules and the packs: `biomarkerId` is a foreign key
// into a catalogue this transaction has not restored yet. They are
// collected here against the id the drug ACTUALLY got and written in
// a second pass once the biomarkers exist; the pass itself says why
// hoisting the biomarker restore above the medications is the worse
// of the two orderings.
const pendingEfficacyTargets: Array<{
medicationId: string;
target: (typeof payload.medications)[number]["efficacyTargets"][number];
}> = [];
// Supersede pointers the file states and the restore cannot honour.
// Reported as JSON paths into the file rather than as ids: the file
// is where an operator has to go to see what the position meant, and
// an id would be one the restore never wrote.
const unresolvedRevisionLinks: string[] = [];
let medicationIndex = 0;
for (const m of payload.medications) {
const created = await tx.medication.create({
data: {
@@ -769,7 +788,93 @@ const handler = apiHandler(
});
restoredMedicationIds.add(created.id);
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
// ── The archived schedule eras, in two passes ────────────────
//
// An era carries `supersededByRevisionId`, the pointer from a
// corrected ARCHIVED row to the MANUAL one that replaced it. The
// column has no `@relation`, so a value addressing nothing costs
// no error — it just stops meaning anything, which is the more
// dangerous shape here, because every era consumer SKIPS a row
// that carries the pointer. Lose it and the superseded original
// goes live again beside its own correction: two overlapping eras
// for one window, and past days minted against a plan the account
// had already corrected.
//
// A portable restore mints fresh ids, so the pointer cannot travel
// as one. It travels as a POSITION in this drug's own ordered era
// list, because that list is the only thing both ends agree on:
// the builder sorts it totally (validFrom, then createdAt, then
// id) so two eras sharing an instant cannot swap places between
// two exports of the same account, and the pointer never crosses a
// drug — every route that writes it scopes the update to one
// medication — so the drug's own list is a complete address space.
//
// Pass one writes every era with the link NULL, because the row a
// link addresses is as often written after the row addressing it
// as before. Pass two patches the links once every position has an
// id.
const revisionIds: string[] = [];
for (const revision of m.scheduleRevisions) {
const row = await tx.medicationScheduleRevision.create({
data: {
...(revision.id ? { id: revision.id } : {}),
medicationId: created.id,
validFrom: new Date(revision.validFrom),
validUntil: new Date(revision.validUntil),
payload: toJson(revision.payload),
source: revision.source ?? "ARCHIVED",
supersededByRevisionId: null,
...(revision.createdAt
? { createdAt: new Date(revision.createdAt) }
: {}),
},
select: { id: true },
});
revisionIds.push(row.id);
}
for (const [index, revision] of m.scheduleRevisions.entries()) {
const to = revision.supersededByIndex;
if (to === null || to === undefined) continue;
// A hand-edited file can name a position this drug does not
// have, or the era's OWN position — and a row that supersedes
// itself is skipped by every consumer, so the era would
// disappear from the timeline while still sitting in the table.
// Neither can be honoured and neither may be guessed at, so the
// link stays NULL and the position is reported. The era still
// restores: the window and the plan it held are the history, and
// the correction pointer is the smaller loss.
if (
!Number.isInteger(to) ||
to < 0 ||
to >= revisionIds.length ||
to === index
) {
unresolvedRevisionLinks.push(
`medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`,
);
continue;
}
await tx.medicationScheduleRevision.update({
where: { id: revisionIds[index] },
data: { supersededByRevisionId: revisionIds[to] },
});
}
for (const target of m.efficacyTargets) {
pendingEfficacyTargets.push({
medicationId: created.id,
target,
});
}
medicationIndex += 1;
}
recordUnknownKeys(
skips,
"scheduleRevisionLink",
[...new Set(unresolvedRevisionLinks)],
unresolvedRevisionLinks,
);
if (payload.intakeEvents.length > 0) {
// An event whose medication did not come back used to be mapped to
@@ -1228,6 +1333,65 @@ const handler = apiHandler(
// `committedRecordId` names the lab result it was committed to, and
// that reference is resolved against the rows this loop writes.
const restoredLabResultIds = new Set<string>();
// What each drug was supposed to move, written now that both ends
// exist. The drugs came back several hundred lines up and the
// analytes only just now, and one of those two had to move for a
// target to reach the database at all.
//
// The second pass wins over hoisting the biomarker restore above the
// medications, for two reasons. `biomarkerByName` and
// `restoredBiomarkerIds` are read by the lab-result loop directly
// below, so moving the block would separate a map from its only
// other consumer and leave a future edit free to re-order them back
// without anything noticing. And the direction of the dependency
// would read backwards: a pinned target is a leaf of the medication
// tree, and letting a leaf dictate where the clinical record is
// rebuilt inverts what the file is about. A second pass states the
// constraint where it applies instead of hiding it in a line order.
//
// A named analyte the restore did not put back cannot be written:
// `biomarkerId` is a real foreign key, so a dangling value would
// fail the constraint and roll the entire account back over one
// override. The row is dropped and NAMED. Dropping rather than
// nulling is deliberate here: the override IS the reference, and a
// row with neither arm set is what the resolver already reads as "no
// override", so keeping it would restore something that means
// nothing while still claiming the primary slot. A target that
// carried no name in the first place is a genuine live state (the
// analyte was deleted before the export) and comes back as written.
const unresolvedTargetAnalytes: string[] = [];
const writableTargets = pendingEfficacyTargets.filter((pending) => {
const name = pending.target.biomarkerName;
if (!name || biomarkerByName.has(name)) return true;
unresolvedTargetAnalytes.push(name);
return false;
});
if (writableTargets.length > 0) {
await tx.medicationEfficacyTarget.createMany({
data: writableTargets.map(({ medicationId, target }) => ({
...(target.id ? { id: target.id } : {}),
medicationId,
measurementType: target.measurementType ?? null,
biomarkerId: target.biomarkerName
? (biomarkerByName.get(target.biomarkerName) ?? null)
: null,
primary: target.primary ?? true,
...(target.createdAt
? { createdAt: new Date(target.createdAt) }
: {}),
...(target.updatedAt
? { updatedAt: new Date(target.updatedAt) }
: {}),
})),
});
}
recordUnknownKeys(
skips,
"medicationTarget",
[...new Set(unresolvedTargetAnalytes)],
unresolvedTargetAnalytes,
);
for (const lab of payload.labResults) {
const biomarkerId =
lab.biomarkerId !== undefined
@@ -1678,6 +1842,23 @@ const handler = apiHandler(
skips,
);
// The ECG strips. AFTER the measurements on purpose: a recording
// carries a `measurementId` that is a real foreign key against the
// EVENT row it was filed with, so a reference written before that
// row exists does not merely dangle — it violates the constraint
// and rolls the whole restore back. The id set is the one the
// measurement branch actually wrote, threaded in rather than
// re-queried so the function stays a pure reader of this
// transaction. Both ends of this section live in
// `src/lib/export/ecg-backup.ts`.
const ecgCleared = await restoreEcgData(
tx,
ownerId,
payload,
new Set(stableRows.map((row) => row.id)),
skips,
);
// The per-day readings and the location periods that explain them.
// Neither references anything but the account, so this section has
// no ordering constraint against any other and sits here beside the
@@ -1742,6 +1923,7 @@ const handler = apiHandler(
environmentContexts: environmentCleared.environmentContexts,
environmentTravelLocations:
environmentCleared.environmentTravelLocations,
ecgRecordings: ecgCleared.ecgRecordings,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
+2 -28
View File
@@ -1,5 +1,4 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
resolveProviderForTest,
@@ -16,6 +15,7 @@ import {
reserveBudget,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
/**
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
@@ -39,32 +39,6 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export const dynamic = "force-dynamic";
const overrideSchema = z
.object({
provider: z
.enum([
"OPENAI",
"ANTHROPIC",
"LOCAL",
"OPENAI_COMPATIBLE",
"CHATGPT_OAUTH",
])
.optional()
.nullable(),
model: z.string().min(1).max(120).optional().nullable(),
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(),
// v1.33.1 (#470) — the OpenAI-compatible gateway's own fields, kept
// separate from `baseUrl` / `openaiKey` so testing an unsaved gateway
// config can never reach the pinned OpenAI arm and vice versa.
compatBaseUrl: z.string().url().max(2048).optional().nullable(),
compatKey: z.string().min(1).max(500).optional().nullable(),
compatModel: z.string().min(1).max(120).optional().nullable(),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "ai.test" } });
@@ -93,7 +67,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = overrideSchema.safeParse(data);
const parsed = aiTestOverrideSchema.safeParse(data);
if (!parsed.success) {
return apiError("Invalid override payload", 422);
}
@@ -450,6 +450,52 @@ describe("GET /api/analytics", () => {
// slice critical path).
});
/**
* `?slice=` is a closed set, and the cost of it not being one was the point.
*
* The route compared the raw string against `"summaries"` and fell through to
* the DEFAULT body on anything else, so `?slice=summary` answered 200 after
* running the heaviest chain on the surface. A client could sit on that typo
* indefinitely and see nothing but a slow, correct-looking response — the
* failure mode was invisible and expensive at the same time.
*/
describe("GET /api/analytics — the slice parameter is a closed set", () => {
const call = (url: string) =>
(GET as unknown as (request: Request) => Promise<Response>)(
new Request(url),
);
it.each(["summary", "SUMMARIES", "default", ""])(
"refuses ?slice=%s rather than serving the expensive default body",
async (value) => {
const res = await call(
`http://localhost/api/analytics?slice=${encodeURIComponent(value)}`,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta?: { errorCode?: string };
details?: { issues?: Array<{ path: string }> };
};
expect(body.meta?.errorCode).toBe("analytics.invalid_query");
expect(body.details?.issues?.map((i) => i.path)).toContain("slice");
},
);
it("still serves the default body when the parameter is absent", async () => {
const res = await call("http://localhost/api/analytics");
expect(res.status).toBe(200);
});
it("still serves the slim body for the one legal value", async () => {
const res = await call("http://localhost/api/analytics?slice=summaries");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
// The slim slice is recognisable by what it does NOT carry.
expect(body.data).not.toHaveProperty("healthScore");
expect(body.data).toHaveProperty("summaries");
});
});
/**
* v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist.
*
+26 -6
View File
@@ -1,7 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { analyticsQuerySchema } from "@/lib/validations/analytics";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
@@ -46,13 +47,17 @@ export const dynamic = "force-dynamic";
* tests instantiate `new Request("http://localhost/api/analytics")`
* and cast through). Falls back to the raw `URL(request.url)` parse
* so the slim slice branch is reachable from both call shapes.
*
* Returns the object the query schema parses: an absent parameter is an
* absent KEY, not an empty string, so `slice` stays optional rather than
* failing the enum on every unparameterised call.
*/
function readSliceParam(request: Request | undefined): string | null {
if (!request) return null;
function readQueryParams(request: Request | undefined): Record<string, string> {
if (!request) return {};
try {
return new URL(request.url).searchParams.get("slice");
return Object.fromEntries(new URL(request.url).searchParams);
} catch {
return null;
return {};
}
}
@@ -60,6 +65,21 @@ export const GET = apiHandler(async (request?: Request) => {
const { user } = await requireAuth();
annotate({ action: { name: "analytics.get" } });
// A `slice` outside the closed set is refused rather than served the
// default body. Falling through was the expensive failure: `?slice=summary`
// ran the full 30-query chain and answered 200, so a client could sit on a
// typo indefinitely while paying for the heaviest read on the surface.
const parsedQuery = analyticsQuerySchema.safeParse(readQueryParams(request));
if (!parsedQuery.success) {
annotate({
action: { name: "analytics.get" },
meta: { outcome: "invalid_query" },
});
return returnAllZodIssues(parsedQuery.error, 422, {
errorCode: "analytics.invalid_query",
});
}
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
// The default path runs 30+ chunked findMany reads to feed
// `summarize()` per type; under `?slice=summaries` the route
@@ -72,7 +92,7 @@ export const GET = apiHandler(async (request?: Request) => {
// behind a hover-prefetch from the Coach drawer + the correlation
// tile's `<InView>` boundary. Consumers that need only the headline
// tile values land on this branch and skip the heavy chain.
if (readSliceParam(request) === "summaries") {
if (parsedQuery.data.slice === "summaries") {
// v1.4.34 IW-G — read-through the analytics cache keyed on
// (userId, slice). The slim slice is the dashboard tile strip's hot
// path; multiple dashboard mounts inside a 60-second TTL all hit a
@@ -60,6 +60,7 @@ vi.mock("@/lib/db", () => ({
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -151,6 +152,7 @@ beforeEach(() => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
@@ -63,6 +63,7 @@ vi.mock("@/lib/db", () => ({
// What a document was filed against, and what was read out of it.
documentConditionLink: { findMany: vi.fn() },
extractedFact: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
},
}));
@@ -174,6 +175,7 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -58,6 +58,7 @@ vi.mock("@/lib/db", () => ({
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
@@ -32,6 +32,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/app-settings", () => ({
@@ -62,16 +63,19 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
function postReq(body: unknown): NextRequest {
function postReq(
body: unknown,
bearer: string | null = "hlk_test_token",
): NextRequest {
return new NextRequest("http://localhost/api/ingest/medication", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer hlk_test_token",
...(bearer === null ? {} : { authorization: `Bearer ${bearer}` }),
},
body: JSON.stringify(body),
});
@@ -80,6 +84,10 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.7",
} as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
@@ -190,6 +198,10 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
function wireHappyPath() {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.7",
} as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
@@ -433,3 +445,136 @@ describe("POST /api/ingest/medication — idempotency-key stability floor", () =
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
});
});
/**
* A bridge is identified by its TOKEN, not by the address it dials from.
*
* The bucket used to be `ingest:${ip}`, so a household running a chat bridge,
* a button and a home-automation rule — the normal deployment for this route,
* not an edge case — put all three in one 60/min cell and let the chattiest
* of them starve the other two. What makes it a defect rather than a
* trade-off is that the route already resolves a per-caller identity two
* lines further down and threw it away.
*
* The fallback matters as much as the fix: a request that resolves no token
* has no identity but its address, and must stay capped on it. Keying the
* failure path on anything shared — or on nothing — would hand an
* unauthenticated flood a free pass, or collapse every anonymous caller in
* the world into one cell, which is worse than what it replaces.
*/
describe("POST /api/ingest/medication — the rate-limit bucket", () => {
it("keys the bucket on the token, not on the client IP", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-001" }),
);
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledTimes(1);
const [key, limit, windowMs] = vi.mocked(checkRateLimit).mock.calls[0];
expect(key).toBe("ingest:token:tok-1");
expect(limit).toBe(60);
expect(windowMs).toBe(60_000);
// The address must not appear in the key at all — that is the whole
// point. A NAT'd sibling on the same address gets its own cell.
expect(key).not.toContain("203.0.113");
});
it("gives two tokens on one address two independent buckets", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.apiToken.findUnique)
.mockResolvedValueOnce({
id: "tok-bridge",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never)
.mockResolvedValueOnce({
id: "tok-button",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never);
await POST(postReq({ medicationName: "A", idempotencyKey: "k-aaaa-001" }));
await POST(postReq({ medicationName: "B", idempotencyKey: "k-bbbb-001" }));
const keys = vi.mocked(checkRateLimit).mock.calls.map(([k]) => k);
expect(keys).toEqual([
"ingest:token:tok-bridge",
"ingest:token:tok-button",
]);
expect(new Set(keys).size).toBe(2);
});
it("refuses an exhausted token bucket with the standard envelope", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
} as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-002" }),
);
expect(res.status).toBe(429);
const body = (await res.json()) as {
data: null;
error: string;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
// The 429 was hand-rolled through `NextResponse.json` and carried no
// `meta`, so a client branching on `errorCode` could not see it.
expect(body.meta?.errorCode).toBe("ingest.rate_limited");
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
});
it("falls back to the per-IP bucket when no token is presented", async () => {
const res = await POST(
postReq(
{ medicationName: "Metformin", idempotencyKey: "k-anon-0001" },
null,
),
);
expect(res.status).toBe(401);
// The anonymous path must still be capped, and on the address — the only
// identity such a request has.
expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1);
const [, prefix, limit, windowMs] = vi.mocked(checkAuthSurfaceRateLimit)
.mock.calls[0];
expect(prefix).toBe("ingest:ip");
expect(limit).toBe(60);
expect(windowMs).toBe(60_000);
// No token resolved, so no token bucket was charged.
expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled();
});
it("falls back to the per-IP bucket when the token does not resolve", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-bogus-001" }),
);
expect(res.status).toBe(401);
expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1);
expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled();
});
it("caps an invalid-token flood before it can answer 401 forever", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
ip: "203.0.113.7",
} as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-flood-001" }),
);
expect(res.status).toBe(429);
});
});
+65 -15
View File
@@ -11,7 +11,12 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
type RateLimitResult,
} from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import {
unstableExternalIdMeta,
@@ -26,13 +31,49 @@ import {
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
const RATE_LIMIT = 60;
const RATE_WINDOW_MS = 60 * 1000;
/** The 429, with the standard headers and the standard error envelope. */
function tooManyRequests(rl: RateLimitResult) {
const response = apiError("Rate limit exceeded", 429, {
errorCode: "ingest.rate_limited",
});
for (const [key, value] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(key, value);
}
return response;
}
/**
* Refuse a caller that presented no usable token, capped on the way out.
*
* The bucket is the caller's ADDRESS, because that is the only identity an
* unauthenticated request has. `checkAuthSurfaceRateLimit` is the shared
* helper for exactly this shape: it keys on the trusted-proxy IP normally,
* and when the proxy chain does not match the configured hop count it routes
* every anonymous caller into one tight global bucket instead of the
* free-for-all that a shared `"unknown"` cell would be.
*/
async function refuseUnauthenticated(request: NextRequest, message: string) {
const rl = await checkAuthSurfaceRateLimit(
request,
"ingest:ip",
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) return tooManyRequests(rl);
return apiError(message, 401);
}
/**
* External medication ingest endpoint.
* Auth: Bearer token (hashed and looked up in api_tokens table).
* Idempotent via idempotencyKey.
* Rate limit: 60 requests per minute per IP.
* Rate limit: 60 requests per minute per TOKEN (per client IP for a request
* that presents no valid one).
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "ingest.medication" } });
@@ -43,19 +84,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
// Rate limiting: 60 requests per minute per IP
const rl = await checkRateLimit(`ingest:${ip}`, 60, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
// Extract bearer token
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return apiError("Authorization header required", 401);
return refuseUnauthenticated(request, "Authorization header required");
}
const token = authHeader.slice(7);
@@ -74,13 +106,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!apiToken || apiToken.revoked) {
return apiError("Invalid or revoked token", 401);
return refuseUnauthenticated(request, "Invalid or revoked token");
}
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
return apiError("Token expired", 401);
return refuseUnauthenticated(request, "Token expired");
}
// A bridge is identified by its TOKEN, not by the address it dials from.
// The bucket used to be `ingest:${ip}`, so every integration behind one
// NAT shared a single 60/min cell and one chatty bridge starved the rest —
// and a household running a chat bridge, a button and a home-automation
// rule is the normal deployment here, not an edge case.
//
// The identity is only known after the lookup, so the lookup now runs
// before the limiter: one indexed single-row read ahead of what was
// already an upsert against `rate_limits`. A caller whose token does not
// resolve is still capped on the per-IP bucket it was always capped on,
// one step earlier than it reaches anything expensive.
const rl = await checkRateLimit(
`ingest:token:${apiToken.id}`,
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) return tooManyRequests(rl);
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes("medication:ingest")
+2 -12
View File
@@ -5,17 +5,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
const onboardingSchema = z.object({
displayName: z.string().trim().min(1).max(50).optional(),
heightCm: z.number().min(50).max(300).optional(),
dateOfBirth: z.string().optional(),
// Same three values the profile schema stores. A narrower enum here
// rejected the whole onboarding submission over a field the account
// is allowed to hold, and left the value unrecordable until the person
// found the setting again afterwards.
gender: z.enum(["MALE", "FEMALE", "OTHER"]).optional(),
});
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
/**
* Complete the onboarding flow. Saves optional profile data and marks
@@ -30,7 +20,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const result = z.safeParse(onboardingSchema, body);
const result = z.safeParse(onboardingCompleteSchema, body);
if (!result.success) {
return apiError("Invalid input", 422, {
errorCode: "onboarding.complete.invalid",
+10 -19
View File
@@ -1,5 +1,4 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -14,10 +13,8 @@ import { prisma, toJson } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
ONBOARDING_GOAL_SLUGS,
buildGoalSeededDashboardLayout,
} from "@/lib/onboarding/goals";
import { buildGoalSeededDashboardLayout } from "@/lib/onboarding/goals";
import { onboardingStepSchema } from "@/lib/validations/onboarding";
/**
* v1.4.25 W14b — POST /api/onboarding/step.
@@ -45,19 +42,13 @@ import {
* but tight enough to defang a stuck retry loop.
*/
const stepBodySchema = z.object({
step: z.number().int().min(1).max(4),
// v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2
// submit). Validated against the closed slug enum so an unknown slug
// 422s; deduped + capped at the full set size. Persisted to
// `User.onboardingGoals` (field-by-field, no mass assignment) and on
// completion (step 4) seeds the dashboard layout when the user never
// customized it. Omitted leaves the stored goals untouched.
goals: z
.array(z.enum(ONBOARDING_GOAL_SLUGS))
.max(ONBOARDING_GOAL_SLUGS.length)
.optional(),
});
// v1.17.1 — the optional goal slugs from the GoalsChipPicker (step 2 submit)
// ride the same body. Validated against the closed slug enum so an unknown
// slug 422s; persisted to `User.onboardingGoals` field-by-field (no mass
// assignment) and, on completion (step 4), used to seed the dashboard layout
// when the user never customized it. Omitted leaves the stored goals
// untouched. The schema itself lives in `@/lib/validations/onboarding` so the
// OpenAPI registry reads the same object this parse does.
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -83,7 +74,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const parsed = stepBodySchema.safeParse(body);
const parsed = onboardingStepSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "onboarding.step" },
@@ -82,17 +82,24 @@ describe("GET /api/personal-records", () => {
});
});
it("drops an unknown metricType silently (loose-typed filter contract)", async () => {
// Was "drops an unknown metricType silently (loose-typed filter contract)".
// That test pinned the defect rather than the contract: dropping the filter
// does not degrade gracefully, it WIDENS the read to every metric, so a
// typo returned more of the record than the caller asked for and the 200
// said nothing about it.
it("refuses an unknown metricType instead of widening the read", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?metricType=BOGUS"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta?: { errorCode?: string };
details?: { issues?: Array<{ path: string }> };
};
expect(body.meta?.errorCode).toBe("personal_records.invalid_query");
expect(body.details?.issues?.map((i) => i.path)).toContain("metricType");
expect(prisma.personalRecord.findMany).not.toHaveBeenCalled();
});
it("honours an explicit ?limit param", async () => {
@@ -108,10 +115,10 @@ describe("GET /api/personal-records", () => {
});
});
it("clamps ?limit to the project-wide ceiling of 500", async () => {
it("serves the ceiling itself", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?limit=999999"),
req("http://localhost/api/personal-records?limit=500"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
@@ -121,19 +128,36 @@ describe("GET /api/personal-records", () => {
});
});
it("falls back to default on garbage ?limit (defence-in-depth)", async () => {
// Was "clamps ?limit to the project-wide ceiling of 500". Silently serving
// 500 rows to a caller that asked for 999 999 is the same class of quiet
// substitution as the default-fallback below: the answer differs from the
// question and nothing says so.
it("refuses a ?limit above the ceiling instead of clamping", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?limit=abc"),
req("http://localhost/api/personal-records?limit=999999"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
expect(res.status).toBe(422);
expect(prisma.personalRecord.findMany).not.toHaveBeenCalled();
});
// Was "falls back to default on garbage ?limit (defence-in-depth)". A
// fallback that cannot be observed is not defence, it is a typo the caller
// never learns about.
it.each(["abc", "-1", "0", "2.5"])(
"refuses a garbage ?limit=%s",
async (value) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req(`http://localhost/api/personal-records?limit=${value}`),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("personal_records.invalid_query");
expect(prisma.personalRecord.findMany).not.toHaveBeenCalled();
},
);
it("seeded record round-trips through the response", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const fakeRecord = {
+26 -36
View File
@@ -7,10 +7,18 @@
* v1.5 iOS-Swift app can build its query path against a stable
* contract from day one.
*
* Query params:
* Query params (validated against `listPersonalRecordsSchema`; a bad value
* is a 422, not a shrug):
* - metricType: optional MeasurementType filter (e.g. ?metricType=VO2_MAX)
* - limit: optional pagination cap (default 100, max 500)
*
* Both were parsed defensively until this release — an unknown `metricType`
* was dropped and a garbage `limit` reverted to the default. That read as
* robustness and behaved as silence, and in one direction it was worse than
* silence: dropping the filter WIDENS the read to every metric, so a typo
* returned more rows than the caller asked for and nothing said so. The 500
* ceiling refuses rather than clamps, for the same reason.
*
* Response envelope (matches the project-wide `apiSuccess` contract):
* { data: PersonalRecord[], error: null }
*/
@@ -18,49 +26,31 @@ import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { listPersonalRecordsSchema } from "@/lib/validations/measurement";
export const dynamic = "force-dynamic";
// v1.4.25 W10 reconcile (Sr-M4) — pagination bounds. Matches the
// `listMeasurementsSchema` ceiling (max 500) so every ingest-and-read
// endpoint on the route surface shares the same upper bound. Today
// the worker hasn't populated any rows so even an uncapped findMany is
// harmless, but a power user with multi-year Apple Health history could
// accumulate 50+ PRs per metric × 14 PR-trackable metrics once the
// worker lands. Add the cap now so the v1.5 iOS app's query path is
// bounded from day one.
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "personalRecords.list" } });
const { searchParams } = new URL(request.url);
const metricTypeParam = searchParams.get("metricType");
const limitParam = searchParams.get("limit");
// Defensive parse — drop unknown values rather than 400 so the
// caller's loosely-typed filter doesn't take the page down.
const metricType: MeasurementType | null =
metricTypeParam && measurementTypeEnum.safeParse(metricTypeParam).success
? (metricTypeParam as MeasurementType)
: null;
// Limit clamp: parse → clamp [1, MAX_LIMIT] → default fallback. A
// garbage value (`?limit=abc`, `?limit=-1`, `?limit=999999`) silently
// clamps to the default rather than 400-ing — same defence-in-depth
// stance the metricType parse takes.
let limit = DEFAULT_LIMIT;
if (limitParam != null) {
const parsed = Number(limitParam);
if (Number.isInteger(parsed) && parsed > 0) {
limit = Math.min(parsed, MAX_LIMIT);
}
const parsed = listPersonalRecordsSchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
);
if (!parsed.success) {
annotate({
action: { name: "personalRecords.list" },
meta: {
outcome: "validation_failed",
issue_count: parsed.error.issues.length,
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "personal_records.invalid_query",
});
}
const { metricType, limit } = parsed.data;
const records = await prisma.personalRecord.findMany({
where: {
@@ -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