mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge the environment and ingest route group
This commit is contained in:
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) };
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user