merge(FIX-E): v1.20.2 audit fixes

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 09:28:43 +02:00
commit ca1cf53d2c
19 files changed
+223 -30

No files matched your search

+1
View File
@@ -6725,6 +6725,7 @@
"linksExisting": "Verknüpft mit vorhandenem {name}",
"duplicateWarning": "Ein passender Wert existiert bereits",
"lowConfidence": "Bitte diesen Wert überprüfen",
"lowConfidenceBadge": "Geringe Sicherheit",
"valueUnreadable": "Wert unleserlich — bitte manuell eingeben",
"saveSelected": "{count} ausgewählte speichern",
"discardAll": "Alle verwerfen",
+1
View File
@@ -6725,6 +6725,7 @@
"linksExisting": "Links to existing {name}",
"duplicateWarning": "A matching reading already exists",
"lowConfidence": "Please double-check this value",
"lowConfidenceBadge": "Low confidence",
"valueUnreadable": "Value unreadable — enter it manually",
"saveSelected": "Save {count} selected",
"discardAll": "Discard all",
+1
View File
@@ -6725,6 +6725,7 @@
"linksExisting": "Se vincula a {name} existente",
"duplicateWarning": "Ya existe un valor coincidente",
"lowConfidence": "Verifica este valor",
"lowConfidenceBadge": "Confianza baja",
"valueUnreadable": "Valor ilegible — introdúcelo manualmente",
"saveSelected": "Guardar {count} seleccionados",
"discardAll": "Descartar todo",
+1
View File
@@ -6725,6 +6725,7 @@
"linksExisting": "Lié au {name} existant",
"duplicateWarning": "Une valeur correspondante existe déjà",
"lowConfidence": "Veuillez vérifier cette valeur",
"lowConfidenceBadge": "Confiance faible",
"valueUnreadable": "Valeur illisible — saisissez-la manuellement",
"saveSelected": "Enregistrer {count} sélectionnées",
"discardAll": "Tout abandonner",
+1
View File
@@ -6725,6 +6725,7 @@
"linksExisting": "Collegato a {name} esistente",
"duplicateWarning": "Esiste già un valore corrispondente",
"lowConfidence": "Controlla questo valore",
"lowConfidenceBadge": "Bassa attendibilità",
"valueUnreadable": "Valore illeggibile — inseriscilo manualmente",
"saveSelected": "Salva {count} selezionati",
"discardAll": "Scarta tutto",
+1
View File
@@ -6725,6 +6725,7 @@
"linksExisting": "Łączy z istniejącym {name}",
"duplicateWarning": "Pasujący wynik już istnieje",
"lowConfidence": "Sprawdź tę wartość",
"lowConfidenceBadge": "Niska pewność",
"valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie",
"saveSelected": "Zapisz {count} wybranych",
"discardAll": "Odrzuć wszystko",
@@ -119,11 +119,20 @@ describe("GET /api/admin/app-logs", () => {
expect(json.data.events).toHaveLength(10);
});
it("rejects an invalid level value", async () => {
it("rejects an invalid level value with a 422 (not a thrown 500)", async () => {
appendLogEvent(makeEvent());
// Invalid `level` — Zod parse should throw and the apiHandler test
// mock rethrows it (we mock only `requireAdmin`, not the wrapper
// error-translator). We simply assert the call rejects.
await expect(GET(req("level=critical"))).rejects.toThrow();
// Invalid `level` — the query is parsed with `safeParse` and returns the
// standard 422 multi-issue envelope rather than throwing (which the
// apiHandler catch ladder would otherwise surface as a 500 + incident).
const res = await GET(req("level=critical"));
expect(res.status).toBe(422);
const json = (await res.json()) as {
data: null;
error: string;
details: { issues: unknown[] };
};
expect(json.data).toBeNull();
expect(json.error).toBe("Validation failed");
expect(json.details.issues.length).toBeGreaterThan(0);
});
});
+4 -2
View File
@@ -1,5 +1,5 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
import { redactSecrets } from "@/lib/logging/redact";
@@ -35,7 +35,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "admin.app-logs.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
const result = querySchema.safeParse({
traceId: searchParams.get("traceId") ?? undefined,
level: searchParams.get("level") ?? undefined,
action: searchParams.get("action") ?? undefined,
@@ -43,6 +43,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
until: searchParams.get("until") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});
if (!result.success) return returnAllZodIssues(result.error);
const parsed = result.data;
const events = readLogBuffer({
traceId: parsed.traceId,
+4 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { redactSecrets } from "@/lib/logging/redact";
import { NextRequest } from "next/server";
@@ -56,7 +56,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "admin.audit-log.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
const result = querySchema.safeParse({
limit: searchParams.get("limit") ?? undefined,
offset: searchParams.get("offset") ?? undefined,
filter: searchParams.get("filter") ?? undefined,
@@ -68,6 +68,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
since: searchParams.get("since") ?? undefined,
until: searchParams.get("until") ?? undefined,
});
if (!result.success) return returnAllZodIssues(result.error);
const parsed = result.data;
// Resolve perPage: clamp to allowed set, fall back to 50.
const perPageResolved =
+4 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -59,9 +59,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "admin.host-metrics.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
const result = querySchema.safeParse({
since: searchParams.get("since") ?? undefined,
});
if (!result.success) return returnAllZodIssues(result.error);
const parsed = result.data;
const sinceMs = presetToMs(parsed.since);
const cutoff = new Date(Date.now() - sinceMs);
+1 -1
View File
@@ -4,7 +4,7 @@
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
* for the calling user's configured AI provider. The surface stays dark for
* codex-only / text-only-model users.
* users with no provider configured and no local-OCR opt-in.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -0,0 +1,129 @@
/**
* v1.20.1 — POST /api/labs/ocr/extract, TEXT mode.
*
* Focus: the text-mode structuring pass reserves the proportionate text budget
* ceiling (`AI_BUDGETS.ocrExtractText`), not the far larger vision ceiling, and
* on a clean extraction failure it refunds the reservation in full rather than
* charging it.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAuth: vi.fn(),
};
});
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/labs/ocr-capability", () => ({
resolveTextProvider: vi.fn(),
resolveVisionProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-26"),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/labs/ocr-extract", async () => {
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
"@/lib/labs/ocr-extract",
);
return { ...actual, runOcrExtraction: vi.fn() };
});
import { POST } from "../route";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { resolveTextProvider } from "@/lib/labs/ocr-capability";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function textReq(text = "Glucose 95 mg/dL"): Request {
return new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mode: "text", text }),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAuth).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
labsLocalOcrEnabled: true,
} as never);
vi.mocked(resolveTextProvider).mockResolvedValue({
chain: [{ providerType: "openai", instance: {} as never }],
pick: {
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
},
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
reserved: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
totalAfter: AI_BUDGETS.ocrExtractText.maxTokens ?? 0,
} as never);
});
describe("POST /api/labs/ocr/extract — text mode budget", () => {
it("reserves the cheaper text ceiling, not the vision ceiling", async () => {
vi.mocked(runOcrExtraction).mockResolvedValue({ rows: [] } as never);
const res = await POST(textReq());
expect(res.status).toBe(200);
// The reservation must use the text ceiling — proven distinct from the
// vision ceiling so a future merge can't silently re-point it.
expect(AI_BUDGETS.ocrExtractText.maxTokens).toBeLessThan(
AI_BUDGETS.ocrExtract.maxTokens ?? Infinity,
);
expect(reserveBudget).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtractText.maxTokens,
"2026-06-26",
);
});
it("refunds the reservation in full on a clean extract failure", async () => {
vi.mocked(runOcrExtraction).mockRejectedValue(
new OcrExtractError("unreadable"),
);
const res = await POST(textReq());
expect(res.status).toBe(422);
// actual spend reconciles to 0 — the failed structuring pass is refunded,
// not charged at the reserved estimate.
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
AI_BUDGETS.ocrExtractText.maxTokens,
0,
"2026-06-26",
);
});
});
+10 -9
View File
@@ -138,12 +138,14 @@ async function handleTextExtract(
});
}
// Budget (text is cheaper than vision; reuse the same ceiling as the upper
// bound — a text-only structuring call always spends at most this).
// Budget — text mode is a plain text→JSON structuring pass, far cheaper than
// a vision call, so it reserves the proportionate text ceiling rather than
// the vision budget. Over-charging the vision rate for a text call would
// exhaust the day budget against spend that never happened.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtract.maxTokens,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
);
if (!reservation.allowed) {
@@ -163,6 +165,8 @@ async function handleTextExtract(
providerType: pick.providerType,
ocrText: parsed.data.text,
});
// A clean structuring pass spent (at most) the reservation; the provider
// already billed it, so reconcile against the reserved estimate.
await reconcileSpend(
userId,
reservation.reserved,
@@ -171,12 +175,9 @@ async function handleTextExtract(
);
return apiSuccess(result);
} catch (err) {
await reconcileSpend(
userId,
reservation.reserved,
reservation.reserved,
dateKey,
);
// A failed structuring call produced no usable rows; mirror the vision
// path and refund the reservation in full rather than charging it.
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
if (err instanceof OcrExtractError) {
return apiError("Couldn't read the report. Try a clearer photo.", 422, {
errorCode: "labs.ocr.extractFailed",
+17
View File
@@ -49,6 +49,17 @@ export function OcrRowEditor({
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
const valueUnreadable = !isQualitative && row.value === null;
// The model self-scores each field it transcribed. Surface a single calm
// per-row flag when ANY tracked field came back below the threshold so a
// shaky read is visible at a glance — including on qualitative rows, which
// have no value-confidence footnote of their own. Informative, not alarming.
const lowRowConfidence =
row.confidence.analyte < CONFIDENCE_THRESHOLD ||
row.confidence.unit < CONFIDENCE_THRESHOLD ||
(isQualitative
? row.confidence.value < CONFIDENCE_THRESHOLD
: lowValueConfidence);
// v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range
// from THIS extracted row. The range is therefore load-bearing and worth a
// second look. Surface the range confidence prominently for new numeric
@@ -106,6 +117,12 @@ export function OcrRowEditor({
{t("labs.ocr.duplicateWarning")}
</Badge>
) : null}
{lowRowConfidence ? (
<Badge variant="secondary" className="text-muted-foreground">
<AlertCircle aria-hidden />
{t("labs.ocr.lowConfidenceBadge")}
</Badge>
) : null}
</div>
</div>
</div>
+12
View File
@@ -109,4 +109,16 @@ export const AI_BUDGETS = {
* gates with a tight 6/hour rate bucket + reserveBudget + upload-size cap.
*/
ocrExtract: { temperature: 0, maxTokens: 4000 },
/**
* v1.20.1 — Lab-OCR text-mode structuring. The browser OCR's the image
* (tesseract.js) and POSTs only the extracted TEXT, so the provider does a
* plain text→JSON structuring pass — no expensive vision input tokens. The
* output contract is identical (the same dozen-plus analyte rows), but the
* spend ceiling is far lower than the vision path's 4000, so the day-budget
* reservation should reflect that rather than over-charging a text call at
* the vision rate. 1500 tokens comfortably covers a dense report's JSON
* envelope. Temperature 0 — faithful structuring, not generation.
*/
ocrExtractText: { temperature: 0, maxTokens: 1500 },
} as const satisfies Record<string, AiBudget>;
+4 -2
View File
@@ -20,8 +20,10 @@
* mutation or egress surface.
*
* The builder result is memoised by the 60s snapshot LRU keyed on
* `(userId, window, sources)`, so several tool calls in one turn (and the
* base-context inventory build) share the underlying reads.
* `(userId, window, sources)`. Each tool scopes to a different source set, so
* tool calls for distinct domains do NOT share a cache entry; sharing happens
* only when the SAME scope recurs (a repeated call, or a turn that re-enters
* the same domain) within the 60s window.
*/
import { z } from "zod/v4";
+16 -3
View File
@@ -47,6 +47,14 @@ import {
/** Refresh the access token this many ms before `tokenExpiresAt`. */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
/**
* Delimiter joining `(type, externalId)` into a single dedup map key. A
* control character keeps it unambiguous against any value a real type or
* externalId could contain, while staying a normal printable-safe constant
* (a literal NUL in the source made text tooling treat the file as binary).
*/
const DEDUP_KEY_DELIM = "\u0000";
/**
* Overlap window for the incremental sync, in ms. Fitbit finalises daily
* summaries after the fact (a daily SpO2 / HRV / RHR can settle hours after the
@@ -396,7 +404,10 @@ export async function upsertFitbitMeasurements(
liveByKey = new Map(
existing
.filter((e) => e.externalId !== null)
.map((e) => [`${e.type}${e.externalId}`, { id: e.id }]),
.map((e) => [
`${e.type}${DEDUP_KEY_DELIM}${e.externalId}`,
{ id: e.id },
]),
);
} catch (err) {
// A probe failure must not strand the whole batch; fall back to treating
@@ -421,7 +432,7 @@ export async function upsertFitbitMeasurements(
const plannedCreateKeys = new Set<string>();
for (const r of readings) {
const type = r.type as MeasurementType;
const key = `${type}${r.externalId}`;
const key = `${type}${DEDUP_KEY_DELIM}${r.externalId}`;
const live = liveByKey.get(key);
if (live) {
toUpdate.push({ id: live.id, r });
@@ -438,7 +449,9 @@ export async function upsertFitbitMeasurements(
} else {
// A duplicate fresh key inside the same batch — overwrite the planned
// create's payload so last-write-wins, matching the prior upsert loop.
const idx = toCreate.findIndex((c) => `${c.type}${c.externalId}` === key);
const idx = toCreate.findIndex(
(c) => `${c.type}${DEDUP_KEY_DELIM}${c.externalId}` === key,
);
if (idx >= 0) {
toCreate[idx] = {
type,
+1 -3
View File
@@ -29,9 +29,7 @@ const capabilityResponse = z
.object({
available: z.boolean(),
mode: z.enum(["vision", "text"]).nullable(),
reason: z
.enum(["no-provider", "text-only-model", "enable-local-ocr"])
.nullable(),
reason: z.enum(["no-provider", "enable-local-ocr"]).nullable(),
pdfSupported: z.boolean(),
})
.meta({
+1 -1
View File
@@ -207,7 +207,7 @@ export interface OcrCapabilityDto {
*/
mode: "vision" | "text" | null;
/** Why scanning is unavailable, when it is. */
reason: "no-provider" | "text-only-model" | "enable-local-ocr" | null;
reason: "no-provider" | "enable-local-ocr" | null;
/** Whether PDF uploads are accepted (Anthropic vision provider only). */
pdfSupported: boolean;
}