mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge remote-tracking branch 'origin/main' into ready/pr-1131
This commit is contained in:
commit
fbd639d9f1
87 files changed
+5627
-772
No files matched your search
@@ -49,6 +49,8 @@ import {
|
||||
withIdempotency,
|
||||
defaultUserIdResolver,
|
||||
isCachableStatus,
|
||||
canonicalJson,
|
||||
requestFingerprint,
|
||||
} from "../idempotency";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
@@ -81,6 +83,13 @@ function makeRequest(
|
||||
});
|
||||
}
|
||||
|
||||
/** The fingerprint `makeRequest("POST", …)` produces. */
|
||||
const OK_BODY_FINGERPRINT = requestFingerprint(
|
||||
"POST",
|
||||
"/api/example",
|
||||
new TextEncoder().encode(JSON.stringify({ ok: true })),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
||||
@@ -144,6 +153,11 @@ describe("withIdempotency", () => {
|
||||
data: { responseBody: string; responseStatus: number };
|
||||
}
|
||||
).data;
|
||||
const claimedFingerprint = (
|
||||
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
||||
data: { requestFingerprint: string };
|
||||
}
|
||||
).data.requestFingerprint;
|
||||
|
||||
// Second call: cache hit returns persisted envelope.
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
|
||||
@@ -152,6 +166,7 @@ describe("withIdempotency", () => {
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
requestFingerprint: claimedFingerprint,
|
||||
responseStatus: persistedBody.responseStatus,
|
||||
responseBody: persistedBody.responseBody,
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
@@ -296,6 +311,7 @@ describe("withIdempotency concurrency claim (A3)", () => {
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
requestFingerprint: OK_BODY_FINGERPRINT,
|
||||
responseStatus: 0,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
@@ -928,3 +944,227 @@ describe("the record-session fence sits above the replay cache", () => {
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("request fingerprint", () => {
|
||||
const enc = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
it("sorts object keys recursively and keeps array order", () => {
|
||||
expect(canonicalJson({ b: 1, a: { d: [3, 1], c: null } })).toBe(
|
||||
'{"a":{"c":null,"d":[3,1]},"b":1}',
|
||||
);
|
||||
expect(canonicalJson([{ y: 1, x: 2 }, "s"])).toBe('[{"x":2,"y":1},"s"]');
|
||||
});
|
||||
|
||||
it("treats the same JSON object in a different key order as one request", () => {
|
||||
const a = requestFingerprint("POST", "/p", enc('{"type":"X","value":1}'));
|
||||
const b = requestFingerprint(
|
||||
"POST",
|
||||
"/p",
|
||||
enc('{ "value": 1, "type": "X" }'),
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("tells nested values and array order apart", () => {
|
||||
const base = requestFingerprint("POST", "/p", enc('{"a":{"b":[1,2]}}'));
|
||||
expect(requestFingerprint("POST", "/p", enc('{"a":{"b":[2,1]}}'))).not.toBe(
|
||||
base,
|
||||
);
|
||||
expect(requestFingerprint("POST", "/p", enc('{"a":{"b":[1,3]}}'))).not.toBe(
|
||||
base,
|
||||
);
|
||||
});
|
||||
|
||||
it("tells the two halves of a blood pressure apart", () => {
|
||||
const sys = requestFingerprint(
|
||||
"POST",
|
||||
"/api/measurements",
|
||||
enc('{"type":"BLOOD_PRESSURE_SYS","value":120}'),
|
||||
);
|
||||
const dia = requestFingerprint(
|
||||
"POST",
|
||||
"/api/measurements",
|
||||
enc('{"type":"BLOOD_PRESSURE_DIA","value":80}'),
|
||||
);
|
||||
expect(sys).not.toBe(dia);
|
||||
});
|
||||
|
||||
it("reads a rebuilt multipart form with a fresh boundary as the same upload", () => {
|
||||
const form = (boundary: string, name: string) =>
|
||||
enc(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/pdf\r\n\r\n%PDF-1.4 body\r\n--${boundary}--\r\n`,
|
||||
);
|
||||
const type = (boundary: string) =>
|
||||
`multipart/form-data; boundary=${boundary}`;
|
||||
const first = requestFingerprint(
|
||||
"POST",
|
||||
"/api/documents",
|
||||
form("----formdata-a1", "brief.pdf"),
|
||||
null,
|
||||
type("----formdata-a1"),
|
||||
);
|
||||
const retry = requestFingerprint(
|
||||
"POST",
|
||||
"/api/documents",
|
||||
form("----formdata-b2", "brief.pdf"),
|
||||
null,
|
||||
type("----formdata-b2"),
|
||||
);
|
||||
expect(retry).toBe(first);
|
||||
const other = requestFingerprint(
|
||||
"POST",
|
||||
"/api/documents",
|
||||
form("----formdata-b2", "other.pdf"),
|
||||
null,
|
||||
type("----formdata-b2"),
|
||||
);
|
||||
expect(other).not.toBe(first);
|
||||
});
|
||||
|
||||
it("hashes a non-JSON or empty body as its raw bytes", () => {
|
||||
const text = requestFingerprint("POST", "/p", enc("not json"));
|
||||
expect(text).toBe(requestFingerprint("POST", "/p", enc("not json")));
|
||||
expect(text).not.toBe(requestFingerprint("POST", "/p", enc("not json!")));
|
||||
const empty = requestFingerprint("DELETE", "/p", new Uint8Array());
|
||||
expect(empty).toBe(requestFingerprint("DELETE", "/p", new Uint8Array()));
|
||||
expect(empty).not.toBe(text);
|
||||
// Invalid UTF-8 is raw bytes, not an error.
|
||||
expect(() =>
|
||||
requestFingerprint("POST", "/p", new Uint8Array([0xff, 0xfe])),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("is scoped by method and path", () => {
|
||||
const body = enc('{"a":1}');
|
||||
const base = requestFingerprint("POST", "/p", body);
|
||||
expect(requestFingerprint("PUT", "/p", body)).not.toBe(base);
|
||||
expect(requestFingerprint("POST", "/q", body)).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withIdempotency with a different body under one key", () => {
|
||||
function postWith(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/example", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": "abc-12345678",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function liveRow(fingerprint: string | null, responseStatus: number) {
|
||||
return {
|
||||
id: "idem-1",
|
||||
userId: "u-1",
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
requestFingerprint: fingerprint,
|
||||
responseStatus,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a completed row for another body", "f".repeat(64), 201],
|
||||
["an in-flight claim for another body", "f".repeat(64), 0],
|
||||
["a legacy row without a fingerprint", null, 201],
|
||||
])(
|
||||
"runs the handler without claiming or caching over %s",
|
||||
async (_label, fingerprint, status) => {
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce(
|
||||
liveRow(fingerprint, status),
|
||||
);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "dia", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(
|
||||
handler,
|
||||
async () => "u-1",
|
||||
);
|
||||
const res = await wrapped(postWith({ type: "BLOOD_PRESSURE_DIA" }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.headers.get("X-Idempotent-Replay")).toBeNull();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.delete).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("runs the handler when a racing claim for another body wins the insert", async () => {
|
||||
vi.mocked(prisma.idempotencyKey.create).mockRejectedValueOnce(
|
||||
Object.assign(new Error("unique"), { code: "P2002" }),
|
||||
);
|
||||
// First lookup: nothing yet. Second, after the collision: the other body.
|
||||
vi.mocked(prisma.idempotencyKey.findUnique)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(liveRow("f".repeat(64), 0));
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "dia", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(postWith({ type: "BLOOD_PRESSURE_DIA" }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the fingerprint, never the body, on the claim", async () => {
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
await wrapped(postWith({ type: "BLOOD_PRESSURE_SYS", value: 123 }));
|
||||
|
||||
const claim = (
|
||||
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
).data;
|
||||
expect(claim.requestFingerprint).toBe(
|
||||
requestFingerprint(
|
||||
"POST",
|
||||
"/api/example",
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ type: "BLOOD_PRESSURE_SYS", value: 123 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(JSON.stringify(claim)).not.toContain("BLOOD_PRESSURE_SYS");
|
||||
});
|
||||
|
||||
it("leaves the body readable for the handler", async () => {
|
||||
const handler = vi.fn(async (req: NextRequest) =>
|
||||
NextResponse.json({ data: await req.json(), error: null }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(postWith({ type: "WEIGHT", value: 70 }));
|
||||
expect((await res.json()).data).toEqual({ type: "WEIGHT", value: 70 });
|
||||
});
|
||||
|
||||
it("reads at most a bounded prefix of a large body and still hands the whole body on", async () => {
|
||||
const big = "x".repeat(3 * 1024 * 1024);
|
||||
const handler = vi.fn(async (req: NextRequest) =>
|
||||
NextResponse.json({ data: (await req.text()).length, error: null }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(
|
||||
new NextRequest("http://localhost/api/example", {
|
||||
method: "POST",
|
||||
headers: { "idempotency-key": "abc-12345678" },
|
||||
body: big,
|
||||
}),
|
||||
);
|
||||
expect((await res.json()).data).toBe(big.length);
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isResponseFormatRejection } from "../json-dialect";
|
||||
|
||||
describe("isResponseFormatRejection", () => {
|
||||
it("reads a complaint about response_format as a JSON-mode rejection", () => {
|
||||
expect(
|
||||
isResponseFormatRejection(400, "Unsupported value: 'response_format'"),
|
||||
).toBe(true);
|
||||
expect(isResponseFormatRejection(422, "unknown parameter")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not read a complaint about reasoning_effort as one", () => {
|
||||
expect(
|
||||
isResponseFormatRejection(
|
||||
400,
|
||||
"Unrecognized request argument supplied: reasoning_effort",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isResponseFormatRejection(400, "unknown field `reasoning effort`"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still flips when both fields are named", () => {
|
||||
expect(
|
||||
isResponseFormatRejection(
|
||||
400,
|
||||
"unknown parameters: response_format, reasoning_effort",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores server errors", () => {
|
||||
expect(isResponseFormatRejection(500, "response_format")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* #1126 — every exported resolver hands out providers carrying their own
|
||||
* chain entry's reasoning setting, and the request each one then sends
|
||||
* carries exactly that value. A chain mixing a thinking model with one that
|
||||
* does not know the field is the case the setting exists for.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
||||
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/logging/context")>()),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch: (url: string, init: RequestInit) => fetch(url, init),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveProvider,
|
||||
resolveProviderChain,
|
||||
resolveProviderForTest,
|
||||
} from "../provider";
|
||||
import { resetLocalJsonDialectCache } from "../local-client";
|
||||
import { singleUserTurn, type AIProvider } from "../types";
|
||||
|
||||
const ROW = {
|
||||
aiProvider: "LOCAL",
|
||||
aiModel: "gemma4:12b",
|
||||
aiBaseUrl: "https://ollama.example.org/v1",
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "openai-key",
|
||||
aiCompatBaseUrl: "https://gateway.example.org/v1",
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: "llama3",
|
||||
role: "USER",
|
||||
useCentralCodex: false,
|
||||
managedProfileAt: null,
|
||||
aiResponseTimeoutSeconds: null,
|
||||
};
|
||||
|
||||
function withChain(aiProviderChain: unknown) {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
aiProviderChain,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
const MIXED = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
{ providerType: "openai-compatible", priority: 2, enabled: true },
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
];
|
||||
|
||||
/** The `reasoning_effort` the provider actually puts on the wire. */
|
||||
async function sentEffort(provider: AIProvider): Promise<unknown> {
|
||||
const mock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "ok" }, finish_reason: "stop" }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mock);
|
||||
await provider.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
vi.unstubAllGlobals();
|
||||
const body = JSON.parse(mock.mock.calls[0][1].body as string);
|
||||
return "reasoning_effort" in body ? body.reasoning_effort : "(absent)";
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetLocalJsonDialectCache();
|
||||
});
|
||||
|
||||
describe("resolveProviderChain binds each entry's own setting", () => {
|
||||
it("a mixed chain sends none, nothing, nothing", async () => {
|
||||
withChain(MIXED);
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(chain.map((e) => e.providerType)).toEqual([
|
||||
"local",
|
||||
"openai-compatible",
|
||||
"openai",
|
||||
]);
|
||||
expect(chain.map((e) => e.instance.reasoningEffort)).toEqual([
|
||||
"none",
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
const sent = [];
|
||||
for (const entry of chain) sent.push(await sentEffort(entry.instance));
|
||||
expect(sent).toEqual(["none", "(absent)", "(absent)"]);
|
||||
});
|
||||
|
||||
it("the gateway's own value reaches the gateway", async () => {
|
||||
withChain([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
]);
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(await sentEffort(chain[0].instance)).toBe("(absent)");
|
||||
expect(await sentEffort(chain[1].instance)).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProvider", () => {
|
||||
it("binds the selected Local provider's setting", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProvider("user-1");
|
||||
expect(provider.type).toBe("local");
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("reaches a selected gateway whose value sits on a disabled entry", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiProviderChain: [
|
||||
{ providerType: "codex", priority: 1, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: false,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const provider = await resolveProvider("user-1");
|
||||
expect(provider.type).toBe("openai-compatible");
|
||||
expect(await sentEffort(provider)).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProviderForTest — the test button runs with the setting", () => {
|
||||
it("testing the saved config (empty body) uses the saved provider's entry", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProviderForTest("user-1", {});
|
||||
expect(provider.type).toBe("local");
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("with no provider selected, the test runs the chain head with its value", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
aiProvider: null,
|
||||
aiProviderChain: MIXED,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
const provider = await resolveProviderForTest("user-1", {});
|
||||
expect(provider.type).toBe("local");
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("testing a named Local provider uses its entry's value", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProviderForTest("user-1", {
|
||||
provider: "LOCAL",
|
||||
});
|
||||
expect(await sentEffort(provider)).toBe("none");
|
||||
});
|
||||
|
||||
it("testing the personal OpenAI key never sends it", async () => {
|
||||
withChain(MIXED);
|
||||
const provider = await resolveProviderForTest("user-1", {
|
||||
provider: "OPENAI",
|
||||
});
|
||||
expect(await sentEffort(provider)).toBe("(absent)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* #1126 — the per-entry reasoning setting on the wire and in the schemas.
|
||||
*
|
||||
* Default must leave the request body byte-identical to what a setup that
|
||||
* never touched the setting has always sent, so the Default cases compare the
|
||||
* whole body string against a literal captured before the setting existed.
|
||||
* Every other choice adds exactly one key, `reasoning_effort`, and only on the
|
||||
* Local client and the OpenAI-compatible gateway.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../local-host-allowlist", () => ({
|
||||
aiEgressPolicyFor: () => ({}),
|
||||
isLocalAiHostAllowed: () => false,
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
// The pinned tags dial through undici's own fetch; route every call to the
|
||||
// stubbed global so the body can be read whatever the egress policy.
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch: (url: string, init: RequestInit) => fetch(url, init),
|
||||
}));
|
||||
|
||||
import { bindReasoningEffort, reasoningEffortFor } from "../reasoning-effort";
|
||||
import {
|
||||
LocalOpenAICompatibleClient,
|
||||
resetLocalJsonDialectCache,
|
||||
} from "../local-client";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import { AnthropicClient } from "../anthropic-client";
|
||||
import {
|
||||
chainWithReasoningEffort,
|
||||
parseProviderChain,
|
||||
PROVIDER_CHAIN_DEFAULT,
|
||||
serializeProviderChain,
|
||||
} from "../provider-chain";
|
||||
import { singleUserTurn, type AIProvider } from "../types";
|
||||
import {
|
||||
aiProviderPatchSchema,
|
||||
providerChainEntrySchema,
|
||||
providerChainPutSchema,
|
||||
} from "@/lib/validations/ai-provider";
|
||||
|
||||
const PARAMS = {
|
||||
...singleUserTurn({ system: "s", user: "u" }),
|
||||
maxTokens: 250,
|
||||
};
|
||||
|
||||
function okFetch(content = "ok") {
|
||||
const mock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content }, finish_reason: "stop" }],
|
||||
usage: { total_tokens: 5 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
function sentBody(mock: ReturnType<typeof okFetch>): string {
|
||||
return mock.mock.calls[0][1].body as string;
|
||||
}
|
||||
|
||||
function localClient() {
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "gemma4:12b",
|
||||
baseUrl: "http://ollama.example.org:11434/v1",
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayClient() {
|
||||
return new OpenAIClient({
|
||||
apiKey: "",
|
||||
model: "qwen3",
|
||||
baseUrl: "https://gateway.example.org/v1",
|
||||
providerType: "openai-compatible",
|
||||
});
|
||||
}
|
||||
|
||||
// Captured from the clients before the setting existed. Default must keep
|
||||
// sending exactly this.
|
||||
const LOCAL_BODY_BEFORE =
|
||||
'{"model":"gemma4:12b","messages":[{"role":"system","content":"s"},{"role":"user","content":"Return strict JSON only, no markdown, no commentary.\\n\\nu"}],"temperature":0.3,"max_tokens":250}';
|
||||
const GATEWAY_BODY_BEFORE =
|
||||
'{"model":"qwen3","messages":[{"role":"system","content":"s"},{"role":"user","content":"u"}],"max_tokens":250,"temperature":0.3}';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
resetLocalJsonDialectCache();
|
||||
});
|
||||
|
||||
describe("Local client", () => {
|
||||
it("Default sends the body it always sent, byte for byte", async () => {
|
||||
const mock = okFetch();
|
||||
await localClient().generateCompletion(PARAMS);
|
||||
expect(sentBody(mock)).toBe(LOCAL_BODY_BEFORE);
|
||||
expect(JSON.parse(sentBody(mock))).not.toHaveProperty("reasoning_effort");
|
||||
});
|
||||
|
||||
it("Off sends reasoning_effort none and nothing else new", async () => {
|
||||
const mock = okFetch();
|
||||
const client = localClient();
|
||||
client.reasoningEffort = "none";
|
||||
await client.generateCompletion(PARAMS);
|
||||
const body = JSON.parse(sentBody(mock));
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
const { reasoning_effort: _dropped, ...rest } = body;
|
||||
expect(JSON.stringify(rest)).toBe(LOCAL_BODY_BEFORE);
|
||||
});
|
||||
|
||||
it.each(["low", "medium", "high"] as const)("%s is sent as is", async (v) => {
|
||||
const mock = okFetch();
|
||||
const client = localClient();
|
||||
client.reasoningEffort = v;
|
||||
await client.generateCompletion(PARAMS);
|
||||
expect(JSON.parse(sentBody(mock)).reasoning_effort).toBe(v);
|
||||
});
|
||||
|
||||
it("the streaming request carries it too", async () => {
|
||||
const mock = okFetch();
|
||||
const client = localClient();
|
||||
client.reasoningEffort = "none";
|
||||
// A buffered answer to a stream request falls back cleanly; only the
|
||||
// first request body matters here.
|
||||
await client.generateCompletionStream(PARAMS, () => {}).catch(() => {});
|
||||
const body = JSON.parse(sentBody(mock));
|
||||
expect(body.stream).toBe(true);
|
||||
expect(body.reasoning_effort).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI-compatible gateway", () => {
|
||||
it("Default sends the body it always sent, byte for byte", async () => {
|
||||
const mock = okFetch();
|
||||
await gatewayClient().generateCompletion(PARAMS);
|
||||
expect(sentBody(mock)).toBe(GATEWAY_BODY_BEFORE);
|
||||
});
|
||||
|
||||
it("Off sends reasoning_effort none", async () => {
|
||||
const mock = okFetch();
|
||||
const client = gatewayClient();
|
||||
client.reasoningEffort = "none";
|
||||
await client.generateCompletion(PARAMS);
|
||||
expect(JSON.parse(sentBody(mock)).reasoning_effort).toBe("none");
|
||||
});
|
||||
|
||||
it("the OpenAI and Codex tags never send it, whatever is stamped", async () => {
|
||||
for (const providerType of ["admin-key", "codex"] as const) {
|
||||
vi.unstubAllGlobals();
|
||||
const mock = okFetch();
|
||||
const client = new OpenAIClient({
|
||||
apiKey: "k",
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://gateway.example.org/v1",
|
||||
providerType,
|
||||
});
|
||||
client.reasoningEffort = "high";
|
||||
await client.generateCompletion(PARAMS);
|
||||
expect(JSON.parse(sentBody(mock))).not.toHaveProperty("reasoning_effort");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindReasoningEffort", () => {
|
||||
const chain = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
];
|
||||
|
||||
it("stamps each entry's own value, keyed on the instance type", () => {
|
||||
expect(bindReasoningEffort(localClient(), chain).reasoningEffort).toBe(
|
||||
"none",
|
||||
);
|
||||
expect(bindReasoningEffort(gatewayClient(), chain).reasoningEffort).toBe(
|
||||
"high",
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps null on every other provider", () => {
|
||||
const openai = new OpenAIClient({
|
||||
apiKey: "k",
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
const anthropic: AIProvider = new AnthropicClient({
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
});
|
||||
expect(bindReasoningEffort(openai, chain).reasoningEffort).toBeNull();
|
||||
expect(bindReasoningEffort(anthropic, chain).reasoningEffort).toBeNull();
|
||||
});
|
||||
|
||||
it("stamps null for a chain that was never customised", () => {
|
||||
expect(bindReasoningEffort(localClient(), null).reasoningEffort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the stored chain", () => {
|
||||
it("keeps a valid value on local / openai-compatible and drops it elsewhere", () => {
|
||||
const parsed = parseProviderChain([
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
{
|
||||
providerType: "openai",
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 3,
|
||||
enabled: true,
|
||||
reasoningEffort: "max",
|
||||
},
|
||||
]);
|
||||
expect(parsed.map((e) => e.reasoningEffort)).toEqual([
|
||||
"low",
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
expect(serializeProviderChain(parsed)).toBe(
|
||||
'[{"providerType":"local","priority":1,"enabled":true,"reasoningEffort":"low"},{"providerType":"openai","priority":2,"enabled":true},{"providerType":"openai-compatible","priority":3,"enabled":true}]',
|
||||
);
|
||||
});
|
||||
|
||||
it("serialises a chain without the setting exactly as before", () => {
|
||||
expect(serializeProviderChain(PROVIDER_CHAIN_DEFAULT)).not.toContain(
|
||||
"reasoningEffort",
|
||||
);
|
||||
});
|
||||
|
||||
it("materialises the default chain to hold a value", () => {
|
||||
const next = chainWithReasoningEffort(null, "local", "none");
|
||||
expect(next.map((e) => e.providerType)).toEqual(
|
||||
PROVIDER_CHAIN_DEFAULT.map((e) => e.providerType),
|
||||
);
|
||||
expect(reasoningEffortFor(next, "local")).toBe("none");
|
||||
});
|
||||
|
||||
it("holds a value for a provider absent from the chain on a disabled entry", () => {
|
||||
const stored = [{ providerType: "codex", priority: 7, enabled: true }];
|
||||
const next = chainWithReasoningEffort(stored, "openai-compatible", "high");
|
||||
expect(next).toEqual([
|
||||
{ providerType: "codex", priority: 7, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 8,
|
||||
enabled: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears without adding an entry", () => {
|
||||
const stored = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
];
|
||||
expect(chainWithReasoningEffort(stored, "local", null)).toEqual([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
]);
|
||||
expect(chainWithReasoningEffort(stored, "openai-compatible", null)).toEqual(
|
||||
[
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schemas", () => {
|
||||
const VALUES = [null, "none", "low", "medium", "high"] as const;
|
||||
|
||||
it.each(["local", "openai-compatible"])(
|
||||
"a %s chain entry accepts Default, Off, Low, Medium and High",
|
||||
(providerType) => {
|
||||
for (const reasoningEffort of VALUES) {
|
||||
expect(
|
||||
providerChainEntrySchema.safeParse({
|
||||
providerType,
|
||||
enabled: true,
|
||||
reasoningEffort,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a value outside the five", () => {
|
||||
expect(
|
||||
providerChainEntrySchema.safeParse({
|
||||
providerType: "local",
|
||||
enabled: true,
|
||||
reasoningEffort: "max",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["openai", "anthropic", "codex", "admin-openai", "admin-codex"])(
|
||||
"refuses the setting on a %s entry, null included",
|
||||
(providerType) => {
|
||||
for (const reasoningEffort of VALUES) {
|
||||
const result = providerChainEntrySchema.safeParse({
|
||||
providerType,
|
||||
enabled: true,
|
||||
reasoningEffort,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(["reasoningEffort"]);
|
||||
}
|
||||
expect(
|
||||
providerChainEntrySchema.safeParse({ providerType, enabled: true })
|
||||
.success,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("the chain body still accepts a chain without the setting", () => {
|
||||
expect(
|
||||
providerChainPutSchema.safeParse({
|
||||
chain: [{ providerType: "local", priority: 1, enabled: true }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("the provider PATCH accepts the five values for both entries", () => {
|
||||
for (const v of VALUES) {
|
||||
expect(
|
||||
aiProviderPatchSchema.safeParse({
|
||||
localReasoningEffort: v,
|
||||
compatReasoningEffort: v,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
aiProviderPatchSchema.safeParse({ localReasoningEffort: "max" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -56,13 +56,21 @@ export function resetJsonModeDialectCache(): void {
|
||||
/**
|
||||
* True when a 4xx error body reads as "this endpoint rejects the
|
||||
* `response_format` field" — either it names the field outright or it
|
||||
* complains about an unknown/unexpected parameter.
|
||||
* complains about an unknown/unexpected parameter. A complaint that names
|
||||
* `reasoning_effort` and not `response_format` is about the reasoning field,
|
||||
* so it must not switch the endpoint's JSON mode off.
|
||||
*/
|
||||
export function isResponseFormatRejection(
|
||||
status: number,
|
||||
bodyExcerpt: string,
|
||||
): boolean {
|
||||
if (status < 400 || status >= 500) return false;
|
||||
if (
|
||||
/reasoning[\s_-]*effort/i.test(bodyExcerpt) &&
|
||||
!/response_format/i.test(bodyExcerpt)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/response_format/i.test(bodyExcerpt) ||
|
||||
/(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resetJsonModeDialectCache,
|
||||
type JsonModeDialect,
|
||||
} from "./json-dialect";
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
|
||||
interface LocalClientConfig {
|
||||
apiKey?: string | null;
|
||||
@@ -108,6 +109,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
readonly type = "local" as const;
|
||||
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
|
||||
responseTimeoutSeconds: number | null = null;
|
||||
/** See `AIProvider.reasoningEffort`; stamped by the resolver. */
|
||||
reasoningEffort: ReasoningEffort | null = null;
|
||||
/** Local servers commonly reject an unknown `tools` field — never send it. */
|
||||
readonly supportsTools = false;
|
||||
private config: LocalClientConfig;
|
||||
@@ -164,6 +167,12 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// most OpenAI-compatible local servers honour it; servers that
|
||||
// ignore it simply disregard the field. Omitted when unset.
|
||||
...(params.seed !== undefined ? { seed: params.seed } : {}),
|
||||
// #1126 — the entry's reasoning setting. Default sends nothing, so the
|
||||
// body of a setup that never touched it stays byte-identical; "Off" is
|
||||
// `none`, which stops a thinking model spending the answer budget.
|
||||
...(this.reasoningEffort
|
||||
? { reasoning_effort: this.reasoningEffort }
|
||||
: {}),
|
||||
// v1.28.28 (#470) — the JSON surfaces opt in via `responseFormat:
|
||||
// "json"`. Default dialect sends the STANDARD OpenAI field (the
|
||||
// top-level Ollama-native `format: "json"` this client used to send
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
rememberJsonModeDialect,
|
||||
} from "./json-dialect";
|
||||
import { aiEgressPolicyFor } from "./local-host-allowlist";
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
import {
|
||||
buildOpenAIMessages,
|
||||
buildOpenAITools,
|
||||
@@ -62,6 +63,11 @@ export class OpenAIClient implements AIProvider {
|
||||
readonly type: OpenAIProviderType;
|
||||
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
|
||||
responseTimeoutSeconds: number | null = null;
|
||||
/**
|
||||
* See `AIProvider.reasoningEffort`; stamped by the resolver. Only the
|
||||
* gateway tag ever sends it (`isGateway` below), whatever is stamped here.
|
||||
*/
|
||||
reasoningEffort: ReasoningEffort | null = null;
|
||||
private config: OpenAIClientConfig;
|
||||
|
||||
constructor(config: OpenAIClientConfig) {
|
||||
@@ -150,6 +156,11 @@ export class OpenAIClient implements AIProvider {
|
||||
: {}),
|
||||
...(tools ? { tools } : {}),
|
||||
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
|
||||
// #1126 — the gateway entry's reasoning setting; Default sends
|
||||
// nothing. `api.openai.com` and Codex never get it.
|
||||
...(this.isGateway && this.reasoningEffort
|
||||
? { reasoning_effort: this.reasoningEffort }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
// 60 s ceiling so a tar-pit upstream cannot pin a worker
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
* `PROVIDER_CHAIN_DEFAULT`.
|
||||
*/
|
||||
|
||||
import {
|
||||
isReasoningEffort,
|
||||
isReasoningProviderType,
|
||||
type ReasoningEffort,
|
||||
type ReasoningProviderType,
|
||||
} from "./reasoning-effort";
|
||||
|
||||
/**
|
||||
* Provider tags used in the chain. `admin-openai` is the legacy
|
||||
* `AppSettings.adminAiKeyEncrypted` fallback (so misconfigured users
|
||||
@@ -63,6 +70,8 @@ export interface ProviderChainEntry {
|
||||
providerType: ProviderChainType;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
/** Only ever set on a `REASONING_PROVIDER_TYPES` entry. Absent = Default. */
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +122,7 @@ export function parseProviderChain(
|
||||
providerType?: unknown;
|
||||
priority?: unknown;
|
||||
enabled?: unknown;
|
||||
reasoningEffort?: unknown;
|
||||
};
|
||||
const providerType = candidate.providerType;
|
||||
if (typeof providerType !== "string") continue;
|
||||
@@ -128,10 +138,18 @@ export function parseProviderChain(
|
||||
: valid.length + 1;
|
||||
const enabled =
|
||||
typeof candidate.enabled === "boolean" ? candidate.enabled : true;
|
||||
// A reasoning effort survives only on an entry that may carry one and
|
||||
// only with a known value; anything else reads as Default.
|
||||
const reasoningEffort =
|
||||
isReasoningProviderType(providerType) &&
|
||||
isReasoningEffort(candidate.reasoningEffort)
|
||||
? candidate.reasoningEffort
|
||||
: undefined;
|
||||
valid.push({
|
||||
providerType: providerType as ProviderChainType,
|
||||
priority,
|
||||
enabled,
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +171,39 @@ export function serializeProviderChain(
|
||||
providerType: e.providerType,
|
||||
priority: e.priority,
|
||||
enabled: e.enabled,
|
||||
...(e.reasoningEffort ? { reasoningEffort: e.reasoningEffort } : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chain to persist after setting (or, with `null`, clearing) one entry's
|
||||
* reasoning effort from that provider's own settings form.
|
||||
*
|
||||
* The setting lives on the chain entry, so a chain that was never customised
|
||||
* is materialised from the default first. A provider that is not in the chain
|
||||
* at all gets a DISABLED entry to hold the value: the setting must reach the
|
||||
* provider when it is resolved as the selected one, and a disabled entry is
|
||||
* inert everywhere else (every reader filters on `enabled`). Clearing on an
|
||||
* absent entry writes nothing new.
|
||||
*/
|
||||
export function chainWithReasoningEffort(
|
||||
rawChain: unknown,
|
||||
providerType: ReasoningProviderType,
|
||||
effort: ReasoningEffort | null,
|
||||
): ProviderChainEntry[] {
|
||||
const chain = parseProviderChain(rawChain).map((e) => ({ ...e }));
|
||||
const entry = chain.find((e) => e.providerType === providerType);
|
||||
if (entry) {
|
||||
if (effort) entry.reasoningEffort = effort;
|
||||
else delete entry.reasoningEffort;
|
||||
} else if (effort) {
|
||||
chain.push({
|
||||
providerType,
|
||||
priority: Math.max(0, ...chain.map((e) => e.priority)) + 1,
|
||||
enabled: false,
|
||||
reasoningEffort: effort,
|
||||
});
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
+33
-15
@@ -1,5 +1,6 @@
|
||||
import type { AIProvider, CompletionResult } from "./types";
|
||||
import { bindResponseTimeout } from "./effective-timeout";
|
||||
import { bindReasoningEffort } from "./reasoning-effort";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { CodexClient, resolveCodexVisionSlug } from "./codex-client";
|
||||
@@ -501,13 +502,19 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
role: true,
|
||||
managedProfileAt: true,
|
||||
aiResponseTimeoutSeconds: true,
|
||||
aiProviderChain: true,
|
||||
},
|
||||
});
|
||||
// The record's response-timeout setting rides on whatever this returns, on
|
||||
// every branch, the operator-key fallback for a managed profile included:
|
||||
// the person waiting on the record is who the call serves.
|
||||
// the person waiting on the record is who the call serves. The reasoning
|
||||
// effort (#1126) rides the same way; it only lands on a Local or gateway
|
||||
// instance, so the operator fallback never picks one up.
|
||||
const bind = (provider: AIProvider): AIProvider =>
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds);
|
||||
bindReasoningEffort(
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds),
|
||||
userRow?.aiProviderChain,
|
||||
);
|
||||
const policy = providerCredentialPolicy(
|
||||
authority,
|
||||
userRow?.managedProfileAt ?? null,
|
||||
@@ -533,18 +540,23 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The record owner's `aiResponseTimeoutSeconds`, for `resolveProviderForTest`,
|
||||
* whose many branches (saved config, unsaved override, the chain) are bound in
|
||||
* one place on the way out. The other two resolvers read it with their row.
|
||||
* The record owner's `aiResponseTimeoutSeconds` and `aiProviderChain`, for
|
||||
* `resolveProviderForTest`, whose many branches (saved config, unsaved
|
||||
* override, the chain) are bound in one place on the way out. The other two
|
||||
* resolvers read them with their row.
|
||||
*/
|
||||
async function readResponseTimeoutSeconds(
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
async function readBoundSettings(userId: string): Promise<{
|
||||
aiResponseTimeoutSeconds: number | null;
|
||||
aiProviderChain: unknown;
|
||||
}> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { aiResponseTimeoutSeconds: true },
|
||||
select: { aiResponseTimeoutSeconds: true, aiProviderChain: true },
|
||||
});
|
||||
return row?.aiResponseTimeoutSeconds ?? null;
|
||||
return {
|
||||
aiResponseTimeoutSeconds: row?.aiResponseTimeoutSeconds ?? null,
|
||||
aiProviderChain: row?.aiProviderChain ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,9 +607,12 @@ export async function resolveProviderChain(
|
||||
aiResponseTimeoutSeconds: true,
|
||||
},
|
||||
});
|
||||
// See `resolveProvider`: every instance carries the record's setting.
|
||||
// See `resolveProvider`: every instance carries the record's settings.
|
||||
const bind = (provider: AIProvider): AIProvider =>
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds);
|
||||
bindReasoningEffort(
|
||||
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds),
|
||||
userRow?.aiProviderChain,
|
||||
);
|
||||
const policy = providerCredentialPolicy(
|
||||
authority,
|
||||
userRow?.managedProfileAt ?? null,
|
||||
@@ -1184,10 +1199,13 @@ export async function resolveProviderForTest(
|
||||
): Promise<AIProvider> {
|
||||
// The test runs under the same timeout generation would, so a slow local
|
||||
// model that needs a raised setting is not reported as dead by the test.
|
||||
// The same holds for the entry's reasoning effort (#1126): a thinking model
|
||||
// that only answers with reasoning switched off must test green with it off.
|
||||
const provider = await resolveProviderForTestUnbound(userId, override);
|
||||
return bindResponseTimeout(
|
||||
provider,
|
||||
await readResponseTimeoutSeconds(userId),
|
||||
const settings = await readBoundSettings(userId);
|
||||
return bindReasoningEffort(
|
||||
bindResponseTimeout(provider, settings.aiResponseTimeoutSeconds),
|
||||
settings.aiProviderChain,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* #1126 — the per-entry reasoning setting of the Local and OpenAI-compatible
|
||||
* providers, and the binder that stamps it onto a resolved provider.
|
||||
*
|
||||
* A leaf on purpose: `types.ts` and the binder are reachable from workers that
|
||||
* must not reach the provider machinery (`data-arrival-provider-isolation`),
|
||||
* so nothing here imports `provider-chain.ts`. The stored entry is read from
|
||||
* the raw chain directly, first match wins, the same rule
|
||||
* `parseProviderChain` dedupes by.
|
||||
*/
|
||||
import type { AIProvider } from "./types";
|
||||
|
||||
/**
|
||||
* The `reasoning_effort` values a chain entry may carry. "Off" in the settings
|
||||
* is `none` on the wire. The absence of the key is "Default": nothing is sent
|
||||
* and the model decides, which is what every chain stored before the setting
|
||||
* existed keeps doing.
|
||||
*/
|
||||
export const REASONING_EFFORTS = ["none", "low", "medium", "high"] as const;
|
||||
|
||||
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
||||
|
||||
/**
|
||||
* The entries that may carry a reasoning effort: the two whose wire is a
|
||||
* person-chosen `/chat/completions` endpoint serving a model of their choice.
|
||||
* OpenAI, Anthropic and Codex pin their own model families and parameters,
|
||||
* so the setting is refused for them rather than silently dropped.
|
||||
*/
|
||||
export const REASONING_PROVIDER_TYPES = ["local", "openai-compatible"] as const;
|
||||
|
||||
export type ReasoningProviderType = (typeof REASONING_PROVIDER_TYPES)[number];
|
||||
|
||||
export function isReasoningProviderType(
|
||||
providerType: string,
|
||||
): providerType is ReasoningProviderType {
|
||||
return (REASONING_PROVIDER_TYPES as readonly string[]).includes(providerType);
|
||||
}
|
||||
|
||||
export function isReasoningEffort(value: unknown): value is ReasoningEffort {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(REASONING_EFFORTS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasoning effort stored on a provider type's entry in a persisted chain,
|
||||
* enabled or not. `null` is Default: no chain, no entry, no valid value, or a
|
||||
* type that cannot carry one.
|
||||
*/
|
||||
export function reasoningEffortFor(
|
||||
rawChain: unknown,
|
||||
providerType: string,
|
||||
): ReasoningEffort | null {
|
||||
if (!isReasoningProviderType(providerType) || !Array.isArray(rawChain)) {
|
||||
return null;
|
||||
}
|
||||
const entry = rawChain.find(
|
||||
(item): item is { reasoningEffort?: unknown } =>
|
||||
item != null &&
|
||||
typeof item === "object" &&
|
||||
(item as { providerType?: unknown }).providerType === providerType,
|
||||
);
|
||||
return isReasoningEffort(entry?.reasoningEffort)
|
||||
? entry.reasoningEffort
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the record owner's reasoning effort onto a resolved provider. Keyed on
|
||||
* the instance's runtime type, so only a Local client or an OpenAI-compatible
|
||||
* gateway can ever carry one: the operator's provider, the personal OpenAI
|
||||
* key, Anthropic and Codex resolve to `null` whatever the stored chain says.
|
||||
* Called by every exported resolver in `provider.ts` next to
|
||||
* `bindResponseTimeout`, with the raw `User.aiProviderChain` value.
|
||||
*/
|
||||
export function bindReasoningEffort<P extends AIProvider>(
|
||||
provider: P,
|
||||
aiProviderChain: unknown,
|
||||
): P {
|
||||
provider.reasoningEffort = reasoningEffortFor(aiProviderChain, provider.type);
|
||||
return provider;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// ─── Insight Result Schema ─────────────────────────────────
|
||||
@@ -123,6 +124,13 @@ export interface AIProvider {
|
||||
* `null` / absent → the surface's `timeoutMs`, else the 60 s default.
|
||||
*/
|
||||
responseTimeoutSeconds?: number | null;
|
||||
/**
|
||||
* #1126 — the `reasoning_effort` the record owner chose for this provider's
|
||||
* chain entry, stamped by the resolver (`bindReasoningEffort`). Only the
|
||||
* Local client and the OpenAI-compatible gateway read it, and only they are
|
||||
* ever stamped with a value. `null` / absent → Default: the key is not sent.
|
||||
*/
|
||||
reasoningEffort?: ReasoningEffort | null;
|
||||
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
|
||||
/**
|
||||
* v1.22 (#89) — optional true-streaming variant. A provider that can emit
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
|
||||
import { buildCycleBackupSection } from "../backup";
|
||||
|
||||
@@ -236,3 +239,87 @@ describe("a symptom the account created survives the round trip", () => {
|
||||
expect(section.customSymptoms).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a portable file carries the cycle free text readable", () => {
|
||||
// Ciphertext in a portable file is noise on any host with another key, so
|
||||
// the note, the sensitive envelope and a custom label travel opened, and
|
||||
// the restore seals them under the receiving host's key.
|
||||
const savedKey = process.env.ENCRYPTION_KEY;
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEY = "ab".repeat(32);
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = savedKey;
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
function client(dayLog: Record<string, unknown>, label: string | null) {
|
||||
return {
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ date: "2026-07-02", symptomLinks: [], ...dayLog },
|
||||
]),
|
||||
},
|
||||
cycleSymptom: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
key: "custom_ache",
|
||||
labelKey: "cycle.symptom.custom_ache",
|
||||
categoryId: "cat-1",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: label,
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
it("opens each sealed value and carries no ciphertext", async () => {
|
||||
const section = await buildCycleBackupSection(
|
||||
client(
|
||||
{
|
||||
notesEncrypted: encrypt("Cramps at four"),
|
||||
sensitiveEncrypted: encrypt(
|
||||
JSON.stringify({ sexualActivity: true, contraceptive: "ORAL" }),
|
||||
),
|
||||
},
|
||||
encrypt("Back ache"),
|
||||
),
|
||||
"user-1",
|
||||
{},
|
||||
);
|
||||
const day = section.cycleDayLogs[0];
|
||||
expect(day.note).toBe("Cramps at four");
|
||||
expect(day.sensitive).toEqual({
|
||||
sexualActivity: true,
|
||||
contraceptive: "ORAL",
|
||||
});
|
||||
expect(day).not.toHaveProperty("notesEncrypted");
|
||||
expect(day).not.toHaveProperty("sensitiveEncrypted");
|
||||
expect(section.customSymptoms[0].label).toBe("Back ache");
|
||||
expect(section.customSymptoms[0]).not.toHaveProperty("labelEncrypted");
|
||||
});
|
||||
|
||||
it("writes the unreadable marker where this host cannot open a value", async () => {
|
||||
const section = await buildCycleBackupSection(
|
||||
client(
|
||||
{
|
||||
notesEncrypted: "v9.bm90LWEtcmVhbC1jaXBoZXJ0ZXh0LWF0LWFsbC1yZWFsbHk=",
|
||||
},
|
||||
null,
|
||||
),
|
||||
"user-1",
|
||||
{},
|
||||
);
|
||||
expect(section.cycleDayLogs[0].note).toBe(UNREADABLE_EXPORT_MARKER);
|
||||
expect(section.cycleDayLogs[0].sensitive).toBeNull();
|
||||
expect(section.customSymptoms[0].label).toBeNull();
|
||||
});
|
||||
});
|
||||
+113
-22
@@ -5,10 +5,25 @@
|
||||
* never drift. Mirrors the `backupPayloadSchema` cycle shapes.
|
||||
*
|
||||
* Reads are scoped to `userId` + `deletedAt: null` and exclude predicted
|
||||
* (forecast) cycle rows — only observed history round-trips. `notesEncrypted`
|
||||
* is carried as the ciphertext envelope VERBATIM: the backup never decrypts
|
||||
* the day-log note, so a wrong-surface plaintext leak is impossible and the
|
||||
* owner's note round-trips encrypted at rest.
|
||||
* (forecast) cycle rows — only observed history round-trips.
|
||||
*
|
||||
* ## Free text follows the note contract
|
||||
*
|
||||
* Three columns are sealed: the day-log note, the sensitive-category envelope
|
||||
* and a custom symptom's label. A disaster-recovery file carries them as the
|
||||
* stored ciphertext, which the same instance's key reads back. A portable file
|
||||
* carries them readable (`note`, `sensitive`, `label`), and the restore seals
|
||||
* them under the receiving host's key, because a portable file exists to be
|
||||
* moved, and ciphertext from one host is noise on another.
|
||||
*
|
||||
* Until v1.40 the portable file carried the ciphertext too, so a note restored
|
||||
* onto a host with a different key was unreadable for good. Such a file is
|
||||
* still accepted: every ciphertext value in it is opened with this host's keys
|
||||
* before it is written, and one that does not open is kept out of the row and
|
||||
* named in the skip report (`cycleCiphertext`), never written back as
|
||||
* ciphertext nobody can read. The same check covers a disaster-recovery file,
|
||||
* which is why `RESTORE_SELF_VERIFIED_SECTIONS` lets these sections past the
|
||||
* restore's key preflight.
|
||||
*/
|
||||
import type {
|
||||
CervicalMucus,
|
||||
@@ -25,6 +40,12 @@ import type {
|
||||
PrismaClient,
|
||||
SecondarySymptom,
|
||||
} from "@/generated/prisma/client";
|
||||
import {
|
||||
openSealedForExport,
|
||||
sealForRestore,
|
||||
UNOPENED,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
@@ -91,8 +112,17 @@ export interface CycleBackupSection {
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
notesEncrypted: string | null;
|
||||
/** Ciphertext; present on a disaster-recovery payload. */
|
||||
sensitiveEncrypted?: string | null;
|
||||
/** Ciphertext; present on a disaster-recovery payload. */
|
||||
notesEncrypted?: string | null;
|
||||
/** Plaintext; present on a portable payload. */
|
||||
note?: string | null;
|
||||
/**
|
||||
* The sensitive-category envelope opened; present on a portable payload.
|
||||
* The unreadable marker when this instance could not open it.
|
||||
*/
|
||||
sensitive?: CycleSensitiveFields | string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
tz: string | null;
|
||||
@@ -142,18 +172,40 @@ export interface CycleBackupSection {
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
/**
|
||||
* The user's own words for the symptom, carried as ciphertext verbatim.
|
||||
* The user's own words for the symptom: ciphertext on a disaster-recovery
|
||||
* payload, plaintext (`label`) on a portable one.
|
||||
*
|
||||
* The builder has always emitted it and this interface did not declare it,
|
||||
* which held while the payload was assembled key by key and stopped holding
|
||||
* the moment the section is spread onto the wire: an undeclared field is a
|
||||
* field the wire type says is not there. Declared, so the type describes
|
||||
* the file.
|
||||
* Declared rather than left to the spread, because an undeclared field is
|
||||
* a field the wire type says is not there.
|
||||
*/
|
||||
labelEncrypted: string | null;
|
||||
labelEncrypted?: string | null;
|
||||
label?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** The five intent fields the sensitive-category envelope holds. */
|
||||
export interface CycleSensitiveFields {
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: string | null;
|
||||
progesteroneTest?: string | null;
|
||||
contraceptive?: string | null;
|
||||
}
|
||||
|
||||
/** The sensitive envelope opened for a portable file, or the marker. */
|
||||
function openSensitiveForExport(
|
||||
sealed: string | null,
|
||||
): CycleSensitiveFields | string | null {
|
||||
if (!sealed) return null;
|
||||
const opened = openSealedForExport(sealed, "cycle sensitive envelope");
|
||||
if (opened === UNREADABLE_EXPORT_MARKER) return opened;
|
||||
try {
|
||||
return JSON.parse(opened) as CycleSensitiveFields;
|
||||
} catch {
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cycle section of a user's full backup. Accepts any client with
|
||||
* the cycle delegates (the route's global client OR the worker's local
|
||||
@@ -259,8 +311,17 @@ export async function buildCycleBackupSection(
|
||||
pregnancyTest: d.pregnancyTest,
|
||||
progesteroneTest: d.progesteroneTest,
|
||||
contraceptive: d.contraceptive,
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
}
|
||||
: {
|
||||
sensitive: openSensitiveForExport(d.sensitiveEncrypted),
|
||||
note: d.notesEncrypted
|
||||
? openSealedForExport(d.notesEncrypted, "cycle day-log note")
|
||||
: null,
|
||||
}),
|
||||
source: d.source,
|
||||
externalId: d.externalId,
|
||||
tz: d.tz,
|
||||
@@ -277,7 +338,13 @@ export async function buildCycleBackupSection(
|
||||
icon: sym.icon,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
labelEncrypted: sym.labelEncrypted,
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: sym.labelEncrypted }
|
||||
: {
|
||||
label: sym.labelEncrypted
|
||||
? openSealedForExport(sym.labelEncrypted, "cycle symptom label")
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -383,8 +450,9 @@ function enumOrNull<T extends string>(
|
||||
* inside an existing transaction. Delete-then-recreate, matching the
|
||||
* measurement/mood restore contract. Symptom links are re-resolved against
|
||||
* the seeded catalogue by key, and each one gets back the 1-4 intensity the
|
||||
* file recorded for it, or none if the file recorded none. `notesEncrypted` is
|
||||
* written back as the ciphertext envelope verbatim — never re-encrypted.
|
||||
* file recorded for it, or none if the file recorded none. Sealed free text
|
||||
* comes back as the file-header comment describes: readable values sealed
|
||||
* under this host's key, ciphertext written back only when this host opens it.
|
||||
*
|
||||
* A key that resolves against nothing is dropped from the day's links and
|
||||
* recorded on `skips`. It is a required argument rather than an optional one:
|
||||
@@ -400,6 +468,9 @@ export async function restoreCycleData(
|
||||
payload: BackupPayload,
|
||||
skips: RestoreSkipLog,
|
||||
): Promise<CycleRestoreCleared> {
|
||||
// Sealed values from the file this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
// Wipe (child links cascade off the day-log delete).
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
@@ -488,7 +559,12 @@ export async function restoreCycleData(
|
||||
icon: sym.icon ?? null,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
labelEncrypted: sym.labelEncrypted ?? null,
|
||||
labelEncrypted: sealForRestore(
|
||||
sym.labelEncrypted,
|
||||
sym.label,
|
||||
`customSymptoms.${sym.key}.labelEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide. Leave the
|
||||
// catalogue row alone: the account's links resolve against it either way.
|
||||
@@ -591,9 +667,22 @@ export async function restoreCycleData(
|
||||
pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS),
|
||||
progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS),
|
||||
contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES),
|
||||
// Both ciphertext envelopes written back verbatim — never re-encrypted.
|
||||
sensitiveEncrypted: d.sensitiveEncrypted ?? null,
|
||||
notesEncrypted: d.notesEncrypted ?? null,
|
||||
sensitiveEncrypted: sealForRestore(
|
||||
d.sensitiveEncrypted,
|
||||
d.sensitive === undefined || d.sensitive === null
|
||||
? d.sensitive
|
||||
: typeof d.sensitive === "string"
|
||||
? UNOPENED
|
||||
: JSON.stringify(d.sensitive),
|
||||
`cycleDayLogs.${d.date}.sensitiveEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
notesEncrypted: sealForRestore(
|
||||
d.notesEncrypted,
|
||||
d.note,
|
||||
`cycleDayLogs.${d.date}.notesEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL",
|
||||
externalId: d.externalId ?? null,
|
||||
tz: d.tz ?? null,
|
||||
@@ -608,6 +697,8 @@ export async function restoreCycleData(
|
||||
});
|
||||
}
|
||||
|
||||
recordUnknownKeys(skips, "cycleCiphertext", unopened, unopened);
|
||||
|
||||
return {
|
||||
cycles: cycles.count,
|
||||
cycleDayLogs: dayLogs.count,
|
||||
|
||||
@@ -115,6 +115,6 @@ describe("generateThumbnail behind a closed gate", () => {
|
||||
Buffer.from([0xff, 0xd8, 0xff]),
|
||||
"image/jpeg",
|
||||
);
|
||||
expect(result).toEqual({ ok: false });
|
||||
expect(result).toEqual({ ok: false, reason: "native-canvas-unsupported" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Refs #776 — the raster-failed chain, end to end through the REAL
|
||||
@@ -68,4 +70,17 @@ describe("raster-failed end to end (#776)", () => {
|
||||
const vision = await prepareVisionInput(DOC as never, true);
|
||||
expect(vision).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("a PDF whose pages all render blank is rasterFailed, not an image of nothing (#1124)", async () => {
|
||||
// Text in render mode 3 only: pdfjs opens it and draws an empty page.
|
||||
// Before the blank-page guard this went to the provider as one white
|
||||
// JPEG and came back as a near-empty transcript indexed as a success.
|
||||
vi.mocked(decryptDocumentContent).mockReturnValue(
|
||||
readFileSync(
|
||||
join(process.cwd(), "tests/fixtures/pdf/invisible-text-only.pdf"),
|
||||
) as never,
|
||||
);
|
||||
const vision = await prepareVisionInput(DOC as never, false);
|
||||
expect(vision).toEqual({ ok: false, reason: "rasterFailed" });
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,16 @@ vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
canvasFactory: {
|
||||
create: () => ({
|
||||
canvas: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
toBuffer: () => Buffer.from([0xff, 0xd8]),
|
||||
},
|
||||
context: {},
|
||||
// One black pixel: an inked page, so the blank-page guard passes.
|
||||
context: {
|
||||
getImageData: () => ({
|
||||
data: new Uint8ClampedArray([0, 0, 0, 255]),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
getPage: async () => ({
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* GitHub #1124 — PDFs from older OCRmyPDF archives got no thumbnail and an
|
||||
* image-only vision read of them came back nearly empty.
|
||||
*
|
||||
* The runtime image has no system fonts. pdfjs was opened without its own
|
||||
* font data and told to lean on system fonts, so any text it could not draw
|
||||
* from an embedded program (a non-embedded standard font, or an embedded
|
||||
* program it cannot parse) came out as nothing, and the page that reached
|
||||
* the provider was the background alone. A workstation hides this because
|
||||
* its own fonts stand in, so the rendering assertions below are regression
|
||||
* guards here and the container is where they were proven red; the option
|
||||
* and registration assertions are red on any host.
|
||||
*
|
||||
* Fixtures (`tests/fixtures/pdf/`, synthetic text only):
|
||||
* - `ocrmypdf11-two-images-type1c.pdf` — the reporter's shape: PDF 1.7,
|
||||
* pikepdf producer, one 630.5 x 861.6 pt page with a 150 ppi RGB JPEG and
|
||||
* a 75 ppi RGB Flate image, Type 1C Helvetica and Palatino-Roman, and an
|
||||
* invisible OCR text layer.
|
||||
* - `fallback-fonts.pdf` — a letter in non-embedded Helvetica and in a
|
||||
* Type 1C Palatino-Roman whose program pdfjs cannot parse.
|
||||
* - `invisible-text-only.pdf` — text in render mode 3 and nothing else, so
|
||||
* every page renders blank.
|
||||
*/
|
||||
|
||||
const { annotate, registerFromPath } = vi.hoisted(() => ({
|
||||
annotate: vi.fn(),
|
||||
registerFromPath: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate }));
|
||||
|
||||
const getDocumentOptions: Record<string, unknown>[] = [];
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", async (importActual) => {
|
||||
const actual =
|
||||
await importActual<typeof import("pdfjs-dist/legacy/build/pdf.mjs")>();
|
||||
return {
|
||||
...actual,
|
||||
getDocument: (opts: Record<string, unknown>) => {
|
||||
getDocumentOptions.push(opts);
|
||||
return actual.getDocument(opts as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@napi-rs/canvas", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@napi-rs/canvas")>();
|
||||
return {
|
||||
...actual,
|
||||
GlobalFonts: {
|
||||
...actual.GlobalFonts,
|
||||
registerFromPath: (path: string, alias?: string) => {
|
||||
registerFromPath(path, alias);
|
||||
return actual.GlobalFonts.registerFromPath(path, alias);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import * as raster from "../rasterize-pdf";
|
||||
import { generateThumbnail } from "../thumbnail";
|
||||
|
||||
const FIXTURES = join(process.cwd(), "tests", "fixtures", "pdf");
|
||||
const fixture = (name: string) => readFileSync(join(FIXTURES, name));
|
||||
|
||||
/** Share of dark pixels in a JPEG, measured the way the rasterizer does. */
|
||||
async function inkOf(base64: string): Promise<number> {
|
||||
const image = await loadImage(Buffer.from(base64, "base64"));
|
||||
const canvas = createCanvas(image.width, image.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const { data } = ctx.getImageData(0, 0, image.width, image.height);
|
||||
let dark = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i]! + data[i + 1]! + data[i + 2]! < 384) dark++;
|
||||
}
|
||||
return dark / (data.length / 4);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
annotate.mockClear();
|
||||
registerFromPath.mockClear();
|
||||
getDocumentOptions.length = 0;
|
||||
raster.__resetPdfjsAssetsForTests();
|
||||
});
|
||||
|
||||
describe("rasterizePdf font and decoder data (#1124)", () => {
|
||||
it("gives pdfjs its font, CMap and wasm directories and no system fonts", async () => {
|
||||
const result = await raster.rasterizePdf(fixture("fallback-fonts.pdf"), 1);
|
||||
expect(result.ok).toBe(true);
|
||||
const opts = getDocumentOptions.at(-1)!;
|
||||
expect(opts.useSystemFonts).toBe(false);
|
||||
for (const [key, probe] of [
|
||||
["standardFontDataUrl", "FoxitSerif.pfb"],
|
||||
["standardFontDataUrl", "LiberationSans-Regular.ttf"],
|
||||
["cMapUrl", "UniJIS-UTF16-H.bcmap"],
|
||||
["wasmUrl", "openjpeg.wasm"],
|
||||
] as const) {
|
||||
expect(typeof opts[key], key).toBe("string");
|
||||
expect(existsSync(`${opts[key] as string}${probe}`), probe).toBe(true);
|
||||
}
|
||||
expect(opts.cMapPacked).toBe(true);
|
||||
});
|
||||
|
||||
it("registers a fallback face for every generic family pdfjs falls back to", async () => {
|
||||
await raster.rasterizePdf(fixture("fallback-fonts.pdf"), 1);
|
||||
const families = new Set(registerFromPath.mock.calls.map((c) => c[1]));
|
||||
expect(families).toEqual(new Set(["sans-serif", "serif", "monospace"]));
|
||||
for (const [path] of registerFromPath.mock.calls) {
|
||||
expect(existsSync(path as string)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the OCRmyPDF page with its scan and its text", async () => {
|
||||
const result = await raster.rasterizePdf(
|
||||
fixture("ocrmypdf11-two-images-type1c.pdf"),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(await inkOf(result.images[0]!.dataBase64)).toBeGreaterThan(0.005);
|
||||
});
|
||||
|
||||
it("renders a letter whose fonts pdfjs has to substitute", async () => {
|
||||
const result = await raster.rasterizePdf(fixture("fallback-fonts.pdf"));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(await inkOf(result.images[0]!.dataBase64)).toBeGreaterThan(0.001);
|
||||
});
|
||||
|
||||
it("reports a document whose every page renders blank as render-failed", async () => {
|
||||
const result = await raster.rasterizePdf(
|
||||
fixture("invisible-text-only.pdf"),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "render-failed" });
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "documents.rasterize.failed" },
|
||||
meta: { reason: "blank_pages", pages: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateThumbnail on the OCRmyPDF shape (#1124)", () => {
|
||||
it("produces a preview of the first page", async () => {
|
||||
const result = await generateThumbnail(
|
||||
fixture("ocrmypdf11-two-images-type1c.pdf"),
|
||||
"application/pdf",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -126,19 +126,19 @@ describe("generateThumbnail", () => {
|
||||
it("returns { ok: false } for an unsupported MIME type", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("plain text"), "text/plain"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "unsupported-type" });
|
||||
});
|
||||
|
||||
it("returns { ok: false } and never throws on malformed image bytes", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("not a real image"), "image/png"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "error" });
|
||||
});
|
||||
|
||||
it("returns { ok: false } on a malformed PDF", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("not a pdf"), "application/pdf"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "raster-failed" });
|
||||
});
|
||||
|
||||
it("refuses a decompression bomb before decoding (pixel cap)", async () => {
|
||||
@@ -153,6 +153,7 @@ describe("generateThumbnail", () => {
|
||||
bomb.writeUInt32BE(30_000, 20); // height
|
||||
await expect(generateThumbnail(bomb, "image/png")).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "pixel-cap",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +165,6 @@ describe("generateThumbnail", () => {
|
||||
loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 });
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("desynced jpeg bytes"), "image/jpeg"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,9 @@
|
||||
* preserves the "a bad document never aborts an upload/batch" contract.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
@@ -60,6 +63,14 @@ export const RASTER_TARGET_LONG_EDGE = 1600;
|
||||
/** JPEG quality (0-100). ~80 keeps text legible at a fraction of PNG's bytes. */
|
||||
export const RASTER_JPEG_QUALITY = 80;
|
||||
|
||||
/**
|
||||
* A rendered page counts as blank when fewer than this share of its pixels
|
||||
* are dark. One short line of 16 pt text on an A4 page is ~0.1 %; a page
|
||||
* number alone sits near this floor. A page set that is blank throughout is
|
||||
* not a reading of the document, whatever the provider would make of it.
|
||||
*/
|
||||
export const RASTER_MIN_INK_RATIO = 0.0001;
|
||||
|
||||
/** One rasterized page, shaped as a vision `input_image` part. */
|
||||
export interface RasterImage {
|
||||
mediaType: "image/jpeg";
|
||||
@@ -98,9 +109,17 @@ interface PdfCanvas {
|
||||
height: number;
|
||||
toBuffer(mime: "image/jpeg", quality?: number): Buffer;
|
||||
}
|
||||
interface PdfContext2D {
|
||||
getImageData(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { data: Uint8ClampedArray };
|
||||
}
|
||||
interface PdfCanvasAndContext {
|
||||
canvas: PdfCanvas;
|
||||
context: unknown;
|
||||
context: PdfContext2D;
|
||||
}
|
||||
interface PdfCanvasFactory {
|
||||
create(width: number, height: number): PdfCanvasAndContext;
|
||||
@@ -125,12 +144,127 @@ interface PdfLoadingTask {
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
interface PdfjsModule {
|
||||
getDocument(opts: {
|
||||
data: Uint8Array;
|
||||
verbosity?: number;
|
||||
isEvalSupported?: boolean;
|
||||
useSystemFonts?: boolean;
|
||||
}): PdfLoadingTask;
|
||||
getDocument(opts: PdfDocumentOptions): PdfLoadingTask;
|
||||
}
|
||||
interface PdfDocumentOptions {
|
||||
data: Uint8Array;
|
||||
verbosity?: number;
|
||||
isEvalSupported?: boolean;
|
||||
useSystemFonts?: boolean;
|
||||
standardFontDataUrl?: string;
|
||||
cMapUrl?: string;
|
||||
cMapPacked?: boolean;
|
||||
wasmUrl?: string;
|
||||
}
|
||||
interface GlobalFontsLike {
|
||||
registerFromPath(path: string, nameAlias?: string): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where pdfjs finds the data it does not carry in its JS: the standard 14
|
||||
* fonts (a PDF that names Helvetica or Times without embedding them), the
|
||||
* CMaps (CID fonts with a predefined encoding), and the wasm image decoders
|
||||
* (JPEG 2000, JBIG2). In a browser pdfjs fetches these from a URL; under Node
|
||||
* they are read from disk, and without a directory pdfjs simply draws the text
|
||||
* without glyphs.
|
||||
*
|
||||
* The runtime image carries no system fonts at all, so nothing else catches
|
||||
* that: a letter set in a non-embedded font rendered as an empty page in the
|
||||
* container while it rendered correctly on a workstation, where the operating
|
||||
* system's fonts stood in. The image-only provider then read the empty page
|
||||
* and returned a line of nothing.
|
||||
*
|
||||
* Resolved from the working directory with Node's own resolution, which is
|
||||
* what finds the hoisted `node_modules/pdfjs-dist` in the standalone image
|
||||
* and the pnpm link in a checkout. The specifier is assembled at runtime so
|
||||
* the bundler leaves the lookup alone. The ICC profile option is not set: its
|
||||
* loader needs a synchronous XMLHttpRequest that Node lacks, and pdfjs falls
|
||||
* back to each colour space's alternate.
|
||||
*/
|
||||
export interface PdfjsAssets {
|
||||
standardFontDataUrl: string;
|
||||
cMapUrl: string;
|
||||
wasmUrl: string;
|
||||
/** The Liberation Sans faces registered as the generic fallback families. */
|
||||
fallbackFonts: string[];
|
||||
}
|
||||
|
||||
let cachedAssets: PdfjsAssets | null | undefined;
|
||||
|
||||
export function resolvePdfjsAssets(): PdfjsAssets | null {
|
||||
if (cachedAssets !== undefined) return cachedAssets;
|
||||
cachedAssets = null;
|
||||
try {
|
||||
const anchor = createRequire(join(process.cwd(), "package.json"));
|
||||
const root = dirname(
|
||||
anchor.resolve(["pdfjs-dist", "package.json"].join("/")),
|
||||
);
|
||||
const fonts = join(root, "standard_fonts");
|
||||
const assets: PdfjsAssets = {
|
||||
standardFontDataUrl: `${fonts}/`,
|
||||
cMapUrl: `${join(root, "cmaps")}/`,
|
||||
wasmUrl: `${join(root, "wasm")}/`,
|
||||
fallbackFonts: [
|
||||
"LiberationSans-Regular.ttf",
|
||||
"LiberationSans-Bold.ttf",
|
||||
"LiberationSans-Italic.ttf",
|
||||
"LiberationSans-BoldItalic.ttf",
|
||||
].map((file) => join(fonts, file)),
|
||||
};
|
||||
if (
|
||||
existsSync(join(fonts, "FoxitSerif.pfb")) &&
|
||||
existsSync(join(root, "cmaps", "UniJIS-UTF16-H.bcmap")) &&
|
||||
existsSync(join(root, "wasm", "openjpeg.wasm")) &&
|
||||
assets.fallbackFonts.every((file) => existsSync(file))
|
||||
) {
|
||||
cachedAssets = assets;
|
||||
}
|
||||
} catch {
|
||||
// Unresolvable: render without the data, as before, and say so below.
|
||||
}
|
||||
return cachedAssets;
|
||||
}
|
||||
|
||||
let fallbackFamiliesRegistered = false;
|
||||
|
||||
/**
|
||||
* Give the canvas a face for the generic families pdfjs falls back to.
|
||||
*
|
||||
* When pdfjs cannot use a font program (a damaged or unsupported embedded
|
||||
* font, or a non-embedded face outside the standard 14) it draws the text
|
||||
* with the canvas' own `fillText` under the PDF's font name and a generic
|
||||
* family. With no system fonts in the image that text vanished. Registering
|
||||
* Liberation Sans, which pdfjs ships, under `sans-serif`, `serif` and
|
||||
* `monospace` makes it legible instead. Once per process.
|
||||
*/
|
||||
async function registerFallbackFamilies(assets: PdfjsAssets): Promise<void> {
|
||||
if (fallbackFamiliesRegistered) return;
|
||||
fallbackFamiliesRegistered = true;
|
||||
const { GlobalFonts } = (await import("@napi-rs/canvas")) as unknown as {
|
||||
GlobalFonts: GlobalFontsLike;
|
||||
};
|
||||
for (const family of ["sans-serif", "serif", "monospace"]) {
|
||||
for (const file of assets.fallbackFonts) {
|
||||
GlobalFonts.registerFromPath(file, family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Share of dark pixels on a rendered page (luminance below half). */
|
||||
function inkRatio(context: PdfContext2D, width: number, height: number) {
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
let dark = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i]! + data[i + 1]! + data[i + 2]! < 384) dark++;
|
||||
}
|
||||
const pixels = data.length / 4;
|
||||
return pixels > 0 ? dark / pixels : 0;
|
||||
}
|
||||
|
||||
/** Test hook: the asset lookup and the font registration are per process. */
|
||||
export function __resetPdfjsAssetsForTests(): void {
|
||||
cachedAssets = undefined;
|
||||
fallbackFamiliesRegistered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,11 +297,31 @@ export async function rasterizePdf(
|
||||
const pdfjs =
|
||||
(await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule;
|
||||
|
||||
const assets = resolvePdfjsAssets();
|
||||
if (assets) {
|
||||
await registerFallbackFamilies(assets);
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.assetsMissing" },
|
||||
meta: { reason: "pdfjs_assets_unresolved" },
|
||||
});
|
||||
}
|
||||
|
||||
task = pdfjs.getDocument({
|
||||
data: new Uint8Array(buffer),
|
||||
verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs.
|
||||
isEvalSupported: false, // never eval font programs (defence in depth).
|
||||
useSystemFonts: true,
|
||||
// There are no system fonts to use: substitutes come from pdfjs' own
|
||||
// standard font data and the fallback families registered above.
|
||||
useSystemFonts: false,
|
||||
...(assets
|
||||
? {
|
||||
standardFontDataUrl: assets.standardFontDataUrl,
|
||||
cMapUrl: assets.cMapUrl,
|
||||
cMapPacked: true,
|
||||
wasmUrl: assets.wasmUrl,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
doc = await task.promise;
|
||||
|
||||
@@ -175,6 +329,7 @@ export async function rasterizePdf(
|
||||
const cap = Math.min(Math.max(1, maxPages), RASTER_MAX_PAGES);
|
||||
const pageCount = Math.min(doc.numPages, cap);
|
||||
const images: RasterImage[] = [];
|
||||
let blankPages = 0;
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
||||
const page = await doc.getPage(pageNumber);
|
||||
@@ -196,6 +351,12 @@ export async function rasterizePdf(
|
||||
viewport,
|
||||
canvas: cc.canvas,
|
||||
}).promise;
|
||||
if (
|
||||
inkRatio(cc.context, cc.canvas.width, cc.canvas.height) <
|
||||
RASTER_MIN_INK_RATIO
|
||||
) {
|
||||
blankPages++;
|
||||
}
|
||||
const jpeg = cc.canvas.toBuffer("image/jpeg", RASTER_JPEG_QUALITY);
|
||||
images.push({
|
||||
mediaType: "image/jpeg",
|
||||
@@ -217,9 +378,19 @@ export async function rasterizePdf(
|
||||
});
|
||||
return { ok: false, reason: "render-failed" };
|
||||
}
|
||||
if (blankPages === images.length) {
|
||||
// Every rendered page came out empty. Sending those to a vision model
|
||||
// buys a transcript of nothing that would be indexed as a success; say
|
||||
// the render failed instead, so the read reports `raster-failed`.
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.failed" },
|
||||
meta: { reason: "blank_pages", pages: images.length },
|
||||
});
|
||||
return { ok: false, reason: "render-failed" };
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.ok" },
|
||||
meta: { pages: images.length, cappedFrom: doc.numPages },
|
||||
meta: { pages: images.length, cappedFrom: doc.numPages, blankPages },
|
||||
});
|
||||
return { ok: true, images, pageCount: doc.numPages };
|
||||
} catch (err) {
|
||||
|
||||
@@ -157,9 +157,23 @@ export interface Thumbnail {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why no preview was made. `unsupported-type` is the expected answer for an
|
||||
* Office file or a text document; every other reason means a preview should
|
||||
* have existed, so the thumbnail job logs it as a warning.
|
||||
*/
|
||||
export type ThumbnailSkipReason =
|
||||
| "unsupported-type"
|
||||
| "native-canvas-unsupported"
|
||||
| "pixel-cap"
|
||||
| "raster-failed"
|
||||
| "empty-render"
|
||||
| "error";
|
||||
|
||||
/** Best-effort generation outcome — never an exception. */
|
||||
export type ThumbnailResult =
|
||||
{ ok: true; thumbnail: Thumbnail } | { ok: false };
|
||||
| { ok: true; thumbnail: Thumbnail }
|
||||
| { ok: false; reason: ThumbnailSkipReason };
|
||||
|
||||
// Minimal structural types for the slice of `@napi-rs/canvas` this module uses.
|
||||
// A type-only shape (erased at build) so we never eagerly evaluate the module
|
||||
@@ -229,7 +243,7 @@ export async function generateThumbnail(
|
||||
action: { name: "documents.thumbnail.rejected" },
|
||||
meta: { reason: "native_canvas_unsupported" },
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "native-canvas-unsupported" };
|
||||
}
|
||||
// Lazy import: keeps @napi-rs/canvas (+ transitively pdfjs on the PDF path)
|
||||
// out of the eager server chunk. Resolved on first generation, then cached.
|
||||
@@ -244,7 +258,7 @@ export async function generateThumbnail(
|
||||
action: { name: "documents.thumbnail.rejected" },
|
||||
meta: { source: "image", reason: "pixel_cap", pixels },
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "pixel-cap" };
|
||||
}
|
||||
const image = await napi.loadImage(bytes);
|
||||
// Post-decode backstop: the header sniff returns null on any marker
|
||||
@@ -260,10 +274,10 @@ export async function generateThumbnail(
|
||||
pixels: image.width * image.height,
|
||||
},
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "pixel-cap" };
|
||||
}
|
||||
const thumb = drawScaledJpeg(napi, image);
|
||||
if (!thumb) return { ok: false };
|
||||
if (!thumb) return { ok: false, reason: "empty-render" };
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.ok" },
|
||||
meta: { source: "image", width: thumb.width, height: thumb.height },
|
||||
@@ -274,11 +288,13 @@ export async function generateThumbnail(
|
||||
if (mimeType === "application/pdf") {
|
||||
// Page 1 only — a preview never renders the whole document.
|
||||
const raster = await rasterizePdf(bytes, 1);
|
||||
if (!raster.ok || raster.images.length === 0) return { ok: false };
|
||||
if (!raster.ok || raster.images.length === 0) {
|
||||
return { ok: false, reason: "raster-failed" };
|
||||
}
|
||||
const pageJpeg = Buffer.from(raster.images[0]!.dataBase64, "base64");
|
||||
const image = await napi.loadImage(pageJpeg);
|
||||
const thumb = drawScaledJpeg(napi, image);
|
||||
if (!thumb) return { ok: false };
|
||||
if (!thumb) return { ok: false, reason: "empty-render" };
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.ok" },
|
||||
meta: { source: "pdf", width: thumb.width, height: thumb.height },
|
||||
@@ -287,7 +303,7 @@ export async function generateThumbnail(
|
||||
}
|
||||
|
||||
// Every other MIME (Office/text/TIFF/HEIC/XML/JSON) — no preview.
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "unsupported-type" };
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.failed" },
|
||||
@@ -297,6 +313,6 @@ export async function generateThumbnail(
|
||||
message: err instanceof Error ? err.message.slice(0, 300) : String(err),
|
||||
},
|
||||
});
|
||||
return { ok: false };
|
||||
return { ok: false, reason: "error" };
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
buildAccountSettingsBackupSection,
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
|
||||
|
||||
/** A 1x1 PNG, the smallest image the avatar upload accepts. */
|
||||
const PNG_1X1 =
|
||||
@@ -190,6 +191,38 @@ describe("admitAccountSettings", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// #1126 — the reasoning setting lives on the provider chain entries, so
|
||||
// the chain column carries it through a backup unchanged.
|
||||
it("carries the provider chain's reasoning settings through a round trip", async () => {
|
||||
const chain = [
|
||||
{
|
||||
providerType: "local",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "none",
|
||||
},
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: false,
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
{ providerType: "openai", priority: 3, enabled: true },
|
||||
];
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(rowWith({ aiProviderChain: chain })),
|
||||
"u1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
const restored = admitAccountSettings(
|
||||
JSON.parse(JSON.stringify(accountSettings)),
|
||||
ctx,
|
||||
).data.aiProviderChain;
|
||||
expect(restored).toEqual(chain);
|
||||
expect(reasoningEffortFor(restored, "local")).toBe("none");
|
||||
expect(reasoningEffortFor(restored, "openai-compatible")).toBe("high");
|
||||
});
|
||||
|
||||
it("writes a database null for a JSON setting the file clears", () => {
|
||||
expect(
|
||||
admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data
|
||||
|
||||
@@ -138,6 +138,30 @@ describe("assessBackupKeys", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the cycle sections to the restore, which opens each value itself", () => {
|
||||
// A same-id impostor in a cycle note must neither refuse the file nor be
|
||||
// picked as the sample that proves the key for the sections that need it.
|
||||
const fine = encrypt("a measurement note under the real key");
|
||||
useKeys({ old: K3, cur: K2 }, "old");
|
||||
const impostor = encrypt("x");
|
||||
useKeys({ old: K1, cur: K2 }, "cur");
|
||||
const file = {
|
||||
measurements: [{ notesEncrypted: fine }],
|
||||
cycleDayLogs: [
|
||||
{ notesEncrypted: impostor },
|
||||
{ notesEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` },
|
||||
],
|
||||
customSymptoms: [
|
||||
{ labelEncrypted: `gone.${Buffer.alloc(40).toString("base64")}` },
|
||||
],
|
||||
};
|
||||
const collector = new BackupKeyIdCollector();
|
||||
collector.visit(file);
|
||||
const verdict = assessBackupKeys(collector);
|
||||
expect(verdict.missing).toEqual([]);
|
||||
expect(verdict.unreadable).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches an id that is configured with different key material", () => {
|
||||
const file = fileWrittenUnderOld();
|
||||
useKeys({ old: K3, cur: K2 }, "cur");
|
||||
|
||||
@@ -97,6 +97,32 @@ export function isEncryptedMember(name: string): boolean {
|
||||
return name.endsWith("Encrypted") && name.length > "Encrypted".length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections whose restore opens every inner value with this host's keys itself,
|
||||
* and keeps out (and names in the skip report) any value it cannot open.
|
||||
*
|
||||
* The cycle day-logs, custom cycle symptoms, custom mood tags and mood
|
||||
* categories, practitioners, visits and vaccinations are here because a
|
||||
* portable file written before v1.40 carried their free text as the stored
|
||||
* ciphertext,
|
||||
* not as readable text. Such a file is still restorable onto another host:
|
||||
* what that host can open comes back, what it cannot stays out of the row and
|
||||
* is reported. Refusing the whole file over a note would cost every other
|
||||
* part of the account. The trade, stated: a disaster-recovery copy whose ONLY
|
||||
* missing key sits in these sections restores with those values reported as
|
||||
* skipped instead of being refused; any other section needing the key still
|
||||
* refuses it, which in practice is every real copy.
|
||||
*/
|
||||
export const RESTORE_SELF_VERIFIED_SECTIONS: ReadonlySet<string> = new Set([
|
||||
"cycleDayLogs",
|
||||
"customSymptoms",
|
||||
"customMoodTags",
|
||||
"customMoodTagCategories",
|
||||
"practitioners",
|
||||
"encounters",
|
||||
"vaccinations",
|
||||
]);
|
||||
|
||||
interface KeyUse {
|
||||
/** How many values need this key. */
|
||||
count: number;
|
||||
@@ -127,7 +153,10 @@ export class BackupKeyIdCollector {
|
||||
}
|
||||
use.count += 1;
|
||||
use.sections.add(section);
|
||||
// A sample from a self-verified section proves nothing the restore needs:
|
||||
// a value there that does not open is skipped, not fatal.
|
||||
if (
|
||||
!RESTORE_SELF_VERIFIED_SECTIONS.has(section) &&
|
||||
value.length <= MAX_SAMPLE_CHARS &&
|
||||
(!use.sample || value.length < use.sample.value.length)
|
||||
) {
|
||||
@@ -257,7 +286,9 @@ function openSample(sample: { value: string; form: InnerCiphertextForm }) {
|
||||
* key to prove the key under that id is the right one.
|
||||
*
|
||||
* `ignoreSections` leaves out sections the caller will not write: the
|
||||
* instance settings, when the operator has not asked for them back.
|
||||
* instance settings, when the operator has not asked for them back. The
|
||||
* sections in {@link RESTORE_SELF_VERIFIED_SECTIONS} are always left out,
|
||||
* because their restore checks each value itself.
|
||||
*/
|
||||
export function assessBackupKeys(
|
||||
collector: BackupKeyIdCollector,
|
||||
@@ -274,7 +305,14 @@ export function assessBackupKeys(
|
||||
for (const [keyId, use] of [...collector.entries()].sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if ([...use.sections].every((section) => ignore.has(section))) continue;
|
||||
if (
|
||||
[...use.sections].every(
|
||||
(section) =>
|
||||
ignore.has(section) || RESTORE_SELF_VERIFIED_SECTIONS.has(section),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
keyIds.push(keyId);
|
||||
configured ??= new Set(getConfiguredKeyIds());
|
||||
if (!configured.has(keyId)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { openSealedForExport } from "@/lib/export/sealed-text";
|
||||
import { iterateMeasurementPages } from "@/lib/export/paged-measurements";
|
||||
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
|
||||
import {
|
||||
@@ -1284,8 +1285,18 @@ export async function buildFullBackupPayload(
|
||||
icon: category.icon,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
// Ciphertext verbatim, like the tag label beside it.
|
||||
labelEncrypted: category.labelEncrypted,
|
||||
// The person's own words: ciphertext in a disaster-recovery payload,
|
||||
// readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: category.labelEncrypted }
|
||||
: {
|
||||
label: category.labelEncrypted
|
||||
? openSealedForExport(
|
||||
category.labelEncrypted,
|
||||
"mood category label",
|
||||
)
|
||||
: null,
|
||||
}),
|
||||
})),
|
||||
hiddenMoodTags: hiddenMoodTags.map((hidden) => ({
|
||||
key: hidden.moodTag.key,
|
||||
@@ -1300,9 +1311,15 @@ export async function buildFullBackupPayload(
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon,
|
||||
sortOrder: tag.sortOrder,
|
||||
// Ciphertext verbatim, like every other *Encrypted column in a DR
|
||||
// payload: the same instance's key reads it back unchanged.
|
||||
labelEncrypted: tag.labelEncrypted,
|
||||
// Ciphertext in a disaster-recovery payload (the same instance's key
|
||||
// reads it back), readable in a portable one (see `sealed-text.ts`).
|
||||
...(disasterRecovery
|
||||
? { labelEncrypted: tag.labelEncrypted }
|
||||
: {
|
||||
label: tag.labelEncrypted
|
||||
? openSealedForExport(tag.labelEncrypted, "mood tag label")
|
||||
: null,
|
||||
}),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
|
||||
+125
-102
@@ -66,6 +66,7 @@ import {
|
||||
listTenantEdges,
|
||||
} from "@/lib/export/tenant-boundary";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { sealForRestore } from "@/lib/export/sealed-text";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
@@ -1514,6 +1515,130 @@ export async function restoreBackup(
|
||||
}
|
||||
|
||||
reportSection("mood");
|
||||
// The account's own categories, tags and hidden set are written
|
||||
// whether or not the file carries a mood entry: an account that set
|
||||
// up its mood vocabulary before logging a single entry has them too,
|
||||
// and they used to be written only inside the entries branch.
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
// Labels this host's keys do not open, by file path.
|
||||
const unopenedMoodLabels: string[] = [];
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: sealForRestore(
|
||||
category.labelEncrypted,
|
||||
category.label,
|
||||
`customMoodTagCategories.${category.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
// "Unknown mood factor keys" — the file was complete and unusable.
|
||||
for (const tag of payload.customMoodTags) {
|
||||
await tx.moodTag.upsert({
|
||||
where: { key: tag.key },
|
||||
create: {
|
||||
...(tag.id ? { id: tag.id } : {}),
|
||||
userId: ownerId,
|
||||
key: tag.key,
|
||||
labelKey: tag.labelKey,
|
||||
categoryId: tag.categoryId,
|
||||
kind: tag.kind,
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder,
|
||||
labelEncrypted: sealForRestore(
|
||||
tag.labelEncrypted,
|
||||
tag.label,
|
||||
`customMoodTags.${tag.key}.labelEncrypted`,
|
||||
unopenedMoodLabels,
|
||||
),
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide.
|
||||
// Leave the catalogue row alone; the links resolve either way.
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodLabelCiphertext",
|
||||
unopenedMoodLabels,
|
||||
unopenedMoodLabels,
|
||||
);
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.moodEntries.length > 0) {
|
||||
const referencedFactorKeys = payload.moodEntries.flatMap((entry) =>
|
||||
entry.factors.map((factor) => factor.key),
|
||||
@@ -1523,108 +1648,6 @@ export async function restoreBackup(
|
||||
(entry) => entry.structuredTags,
|
||||
);
|
||||
const tagKeys = [...new Set(referencedTagKeys)];
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: category.labelEncrypted ?? null,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
// "Unknown mood factor keys" — the file was complete and unusable.
|
||||
for (const tag of payload.customMoodTags) {
|
||||
await tx.moodTag.upsert({
|
||||
where: { key: tag.key },
|
||||
create: {
|
||||
...(tag.id ? { id: tag.id } : {}),
|
||||
userId: ownerId,
|
||||
key: tag.key,
|
||||
labelKey: tag.labelKey,
|
||||
categoryId: tag.categoryId,
|
||||
kind: tag.kind,
|
||||
isActive: tag.isActive,
|
||||
icon: tag.icon ?? null,
|
||||
sortOrder: tag.sortOrder,
|
||||
labelEncrypted: tag.labelEncrypted ?? null,
|
||||
scaleMin: tag.scaleMin,
|
||||
scaleMax: tag.scaleMax,
|
||||
inverse: tag.inverse,
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide.
|
||||
// Leave the catalogue row alone; the links resolve either way.
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
const factorRows =
|
||||
factorKeys.length === 0
|
||||
? []
|
||||
|
||||
@@ -152,6 +152,25 @@
|
||||
* real foreign key, so the POINTER is dropped and the occurrence restores
|
||||
* unlinked. A portable export omits soft-deleted episodes, which is the
|
||||
* ordinary way an occurrence ends up naming one the file does not carry.
|
||||
*
|
||||
* The sixteenth, `cycleCiphertext`, is not a reference at all. It names a
|
||||
* sealed cycle value (a day-log note, the sensitive-category envelope, a
|
||||
* custom symptom's label) the file carried as ciphertext this host's keys do
|
||||
* not open — a portable file written before v1.40 on a host with another key.
|
||||
* Writing it back would store a value no reader can open, so the FIELD is kept
|
||||
* out and the day or the symptom restores without it. Its key is the path in
|
||||
* the file (`cycleDayLogs.<date>.notesEncrypted`), never the value.
|
||||
*
|
||||
* The seventeenth, `moodLabelCiphertext`, is the same for the label of a mood
|
||||
* tag or mood category the account created
|
||||
* (`customMoodTags.<key>.labelEncrypted`): the tag or category restores, under
|
||||
* its key, without the person's own name for it.
|
||||
*
|
||||
* The eighteenth and nineteenth, `visitCiphertext` and `vaccinationCiphertext`,
|
||||
* are the same again for the free text of the visits record (a practitioner's
|
||||
* note, address and phone; a visit's reason, outcome and body site) and of a
|
||||
* vaccination's note (`vaccinations.<id>.noteEncrypted`). The row restores
|
||||
* without the field.
|
||||
*/
|
||||
export type SkippedCatalogue =
|
||||
| "cycleSymptom"
|
||||
@@ -172,7 +191,11 @@ export type SkippedCatalogue =
|
||||
| "scheduleRevisionLink"
|
||||
| "checkupClosure"
|
||||
| "accountSetting"
|
||||
| "symptomEpisodeReference";
|
||||
| "symptomEpisodeReference"
|
||||
| "cycleCiphertext"
|
||||
| "moodLabelCiphertext"
|
||||
| "visitCiphertext"
|
||||
| "vaccinationCiphertext";
|
||||
|
||||
/** One key this instance does not know, and the links it cost. */
|
||||
export interface SkippedCatalogueKey {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Both ends of a sealed free-text column in a backup section: the string
|
||||
* codec (`<keyId>.<base64>` in a text column — the cycle day-log note and
|
||||
* sensitive envelope, custom cycle symptom and mood labels) and the shared
|
||||
* bytes codec (the practitioner note, the visit's reason, outcome and body
|
||||
* site, the vaccination note).
|
||||
*
|
||||
* The split is the one every other section uses. A disaster-recovery file
|
||||
* carries the stored ciphertext, which the same instance's key reads back. A
|
||||
* portable file carries the readable value (or the unreadable marker when
|
||||
* the writing host could not open it), and the restore seals it under the
|
||||
* receiving host's active key, because ciphertext from one host is noise on
|
||||
* another.
|
||||
*
|
||||
* Portable files written before v1.40 carried these columns as ciphertext.
|
||||
* They still restore: a ciphertext value is written back only when this
|
||||
* host's keys open it. One that does not open is kept out of the row and its
|
||||
* file path is recorded, so the caller can name it in the skip report rather
|
||||
* than store a value nobody can read. That per-value check is why the key
|
||||
* preflight leaves these sections out (`RESTORE_SELF_VERIFIED_SECTIONS`).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Open a sealed value for a portable file, or the unreadable marker. */
|
||||
export function openSealedForExport(sealed: string, field: string): string {
|
||||
try {
|
||||
return decrypt(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** A readable value the file could not supply: the writer could not open it. */
|
||||
export const UNOPENED: unique symbol = Symbol("unopened");
|
||||
|
||||
/**
|
||||
* The sealed column value for one free-text field from the file.
|
||||
*
|
||||
* Ciphertext is written back only when this host opens it — a value sealed
|
||||
* under a key this host does not hold (or the same key id over other key
|
||||
* material) would be a row nobody can read. A readable value is sealed under
|
||||
* this host's active key. Anything kept out is recorded under `path`.
|
||||
*/
|
||||
export function sealForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined | typeof UNOPENED,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): string | null {
|
||||
if (sealed) {
|
||||
try {
|
||||
decrypt(sealed);
|
||||
return sealed;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (readable === UNOPENED) {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
return readable ? encrypt(readable) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Bytes` twin of {@link openSealedForExport}, for columns written with
|
||||
* the shared bytes codec (`encryptToBytes`): the practitioner note, the
|
||||
* visit's reason, outcome and body site, the vaccination note.
|
||||
*/
|
||||
export function openSealedBytesForExport(
|
||||
sealed: Uint8Array | null,
|
||||
field: string,
|
||||
): string | null {
|
||||
if (!sealed || sealed.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(sealed);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored `Bytes` ciphertext as a disaster-recovery file carries it. */
|
||||
export function encodeSealedBytes(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
function decodeBase64(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Bytes` twin of {@link sealForRestore}. `sealed` is the base64 a
|
||||
* disaster-recovery (or pre-v1.40 portable) file carries; it is written back
|
||||
* only when this host opens it. A readable value is sealed under this host's
|
||||
* active key.
|
||||
*/
|
||||
export function sealBytesForRestore(
|
||||
sealed: string | null | undefined,
|
||||
readable: string | null | undefined,
|
||||
path: string,
|
||||
unopened: string[],
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (sealed) {
|
||||
const bytes = decodeBase64(sealed);
|
||||
try {
|
||||
decryptFromBytes(bytes);
|
||||
return bytes;
|
||||
} catch {
|
||||
unopened.push(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return readable ? encryptToBytes(readable) : null;
|
||||
}
|
||||
@@ -35,7 +35,11 @@
|
||||
* guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`
|
||||
* holds.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
encodeSealedBytes,
|
||||
openSealedBytesForExport,
|
||||
sealBytesForRestore,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { VaccinationSite } from "@/generated/prisma/client";
|
||||
|
||||
@@ -72,8 +76,10 @@ export interface VaccinationBackupEntry {
|
||||
* lacks the reminder.
|
||||
*/
|
||||
reminderId: string | null;
|
||||
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
|
||||
noteEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
noteEncrypted?: string | null;
|
||||
/** The note readable; portable payloads only. */
|
||||
note?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
@@ -96,12 +102,6 @@ export interface VaccinationsBackupCounts {
|
||||
vaccinationLinks: number;
|
||||
}
|
||||
|
||||
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
|
||||
function encodeCiphertext(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Named select constants, one per model — a structural matcher binds a model
|
||||
* to the literal beside its delegate call, and an inline object inside a
|
||||
@@ -172,7 +172,14 @@ export async function buildVaccinationsBackupSection(
|
||||
practitionerId: row.practitionerId,
|
||||
encounterId: row.encounterId,
|
||||
reminderId: row.reminderId,
|
||||
noteEncrypted: encodeCiphertext(row.noteEncrypted),
|
||||
...(disasterRecovery
|
||||
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
|
||||
: {
|
||||
note: openSealedBytesForExport(
|
||||
row.noteEncrypted,
|
||||
"vaccination note",
|
||||
),
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
...(disasterRecovery
|
||||
@@ -240,16 +247,10 @@ export type RestoredVaccination = Pick<
|
||||
| "encounterId"
|
||||
| "reminderId"
|
||||
| "noteEncrypted"
|
||||
| "note"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's immunization log and its document links.
|
||||
*
|
||||
@@ -307,6 +308,8 @@ export async function restoreVaccinationsData(
|
||||
const droppedPractitioners: string[] = [];
|
||||
const droppedEncounters: string[] = [];
|
||||
const droppedReminders: string[] = [];
|
||||
// Sealed notes this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
if (payload.vaccinations.length > 0) {
|
||||
await tx.vaccinationRecord.createMany({
|
||||
@@ -349,10 +352,12 @@ export async function restoreVaccinationsData(
|
||||
practitionerId,
|
||||
encounterId,
|
||||
reminderId,
|
||||
noteEncrypted:
|
||||
entry.noteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.noteEncrypted),
|
||||
noteEncrypted: sealBytesForRestore(
|
||||
entry.noteEncrypted,
|
||||
entry.note,
|
||||
`vaccinations.${entry.id}.noteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
createdAt: new Date(entry.createdAt),
|
||||
updatedAt: new Date(entry.updatedAt),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
@@ -379,6 +384,7 @@ export async function restoreVaccinationsData(
|
||||
[...new Set(droppedReminders)],
|
||||
droppedReminders,
|
||||
);
|
||||
recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened);
|
||||
|
||||
const restoredVaccinations = new Set(
|
||||
payload.vaccinations.map((entry) => entry.id),
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
* result ids, so a lab link in one cannot resolve; a disaster-recovery
|
||||
* payload carries every id and they all do.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type {
|
||||
EncounterKind,
|
||||
@@ -42,6 +41,11 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
encodeSealedBytes,
|
||||
openSealedBytesForExport,
|
||||
sealBytesForRestore,
|
||||
} from "@/lib/export/sealed-text";
|
||||
import { readPractitionerContact } from "@/lib/practitioners/dto";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
@@ -71,8 +75,10 @@ export interface PractitionerBackupEntry {
|
||||
locationEncrypted?: string | null;
|
||||
/** Base64 ciphertext of the phone number, disaster-recovery payloads only. */
|
||||
phoneEncrypted?: string | null;
|
||||
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
|
||||
noteEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
noteEncrypted?: string | null;
|
||||
/** The note readable; portable payloads only. */
|
||||
note?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
@@ -87,10 +93,15 @@ export interface EncounterBackupEntry {
|
||||
status: EncounterStatus;
|
||||
kind: EncounterKind;
|
||||
practitionerId: string | null;
|
||||
reasonEncrypted: string | null;
|
||||
outcomeEncrypted: string | null;
|
||||
/** Base64 ciphertext; disaster-recovery payloads only. */
|
||||
reasonEncrypted?: string | null;
|
||||
outcomeEncrypted?: string | null;
|
||||
/** The procedure's body site, ciphertext like the two above (v1.39.1). */
|
||||
bodySiteEncrypted: string | null;
|
||||
bodySiteEncrypted?: string | null;
|
||||
/** The three readable; portable payloads only. */
|
||||
reason?: string | null;
|
||||
outcome?: string | null;
|
||||
bodySite?: string | null;
|
||||
laterality: Laterality | null;
|
||||
/**
|
||||
* The appointment's reminder row, which restores in the same run since
|
||||
@@ -124,12 +135,6 @@ export interface VisitsBackupCounts {
|
||||
encounterLinks: number;
|
||||
}
|
||||
|
||||
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
|
||||
function encodeCiphertext(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
return Buffer.from(value).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Named select constants, one per model.
|
||||
*
|
||||
@@ -256,10 +261,10 @@ export async function buildVisitsBackupSection(
|
||||
? {
|
||||
location: null,
|
||||
phone: null,
|
||||
locationEncrypted: encodeCiphertext(
|
||||
locationEncrypted: encodeSealedBytes(
|
||||
row.locationEncrypted ?? encryptNote(row.location),
|
||||
),
|
||||
phoneEncrypted: encodeCiphertext(
|
||||
phoneEncrypted: encodeSealedBytes(
|
||||
row.phoneEncrypted ?? encryptNote(row.phone),
|
||||
),
|
||||
}
|
||||
@@ -275,7 +280,14 @@ export async function buildVisitsBackupSection(
|
||||
"phone",
|
||||
),
|
||||
}),
|
||||
noteEncrypted: encodeCiphertext(row.noteEncrypted),
|
||||
...(disasterRecovery
|
||||
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
|
||||
: {
|
||||
note: openSealedBytesForExport(
|
||||
row.noteEncrypted,
|
||||
"practitioner note",
|
||||
),
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
...(disasterRecovery
|
||||
@@ -288,9 +300,26 @@ export async function buildVisitsBackupSection(
|
||||
status: row.status,
|
||||
kind: row.kind,
|
||||
practitionerId: row.practitionerId,
|
||||
reasonEncrypted: encodeCiphertext(row.reasonEncrypted),
|
||||
outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted),
|
||||
bodySiteEncrypted: encodeCiphertext(row.bodySiteEncrypted),
|
||||
...(disasterRecovery
|
||||
? {
|
||||
reasonEncrypted: encodeSealedBytes(row.reasonEncrypted),
|
||||
outcomeEncrypted: encodeSealedBytes(row.outcomeEncrypted),
|
||||
bodySiteEncrypted: encodeSealedBytes(row.bodySiteEncrypted),
|
||||
}
|
||||
: {
|
||||
reason: openSealedBytesForExport(
|
||||
row.reasonEncrypted,
|
||||
"visit reason",
|
||||
),
|
||||
outcome: openSealedBytesForExport(
|
||||
row.outcomeEncrypted,
|
||||
"visit outcome",
|
||||
),
|
||||
bodySite: openSealedBytesForExport(
|
||||
row.bodySiteEncrypted,
|
||||
"visit body site",
|
||||
),
|
||||
}),
|
||||
laterality: row.laterality,
|
||||
reminderId: row.reminderId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -380,6 +409,7 @@ export type RestoredPractitioner = Pick<
|
||||
| "locationEncrypted"
|
||||
| "phoneEncrypted"
|
||||
| "noteEncrypted"
|
||||
| "note"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
@@ -394,18 +424,14 @@ export type RestoredEncounter = Pick<
|
||||
| "reasonEncrypted"
|
||||
| "outcomeEncrypted"
|
||||
| "bodySiteEncrypted"
|
||||
| "reason"
|
||||
| "outcome"
|
||||
| "bodySite"
|
||||
| "laterality"
|
||||
| "reminderId"
|
||||
>
|
||||
> & { deletedAt?: string | null };
|
||||
|
||||
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's visits, address book and link rows.
|
||||
*
|
||||
@@ -440,6 +466,9 @@ export async function restoreVisitsData(
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
// Sealed values this host's keys do not open, by file path.
|
||||
const unopened: string[] = [];
|
||||
|
||||
if (payload.practitioners.length > 0) {
|
||||
await tx.practitioner.createMany({
|
||||
data: payload.practitioners.map((entry) => ({
|
||||
@@ -451,18 +480,24 @@ export async function restoreVisitsData(
|
||||
// v1.39.4 — ciphertext wins when a disaster-recovery file carried it;
|
||||
// a portable file's (or an older file's) readable value is sealed
|
||||
// under this instance's key. The readable columns are never written.
|
||||
locationEncrypted:
|
||||
entry.locationEncrypted != null
|
||||
? decodeCiphertext(entry.locationEncrypted)
|
||||
: encryptNote(entry.location),
|
||||
phoneEncrypted:
|
||||
entry.phoneEncrypted != null
|
||||
? decodeCiphertext(entry.phoneEncrypted)
|
||||
: encryptNote(entry.phone),
|
||||
noteEncrypted:
|
||||
entry.noteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.noteEncrypted),
|
||||
locationEncrypted: sealBytesForRestore(
|
||||
entry.locationEncrypted,
|
||||
entry.location,
|
||||
`practitioners.${entry.id}.locationEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
phoneEncrypted: sealBytesForRestore(
|
||||
entry.phoneEncrypted,
|
||||
entry.phone,
|
||||
`practitioners.${entry.id}.phoneEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
noteEncrypted: sealBytesForRestore(
|
||||
entry.noteEncrypted,
|
||||
entry.note,
|
||||
`practitioners.${entry.id}.noteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
createdAt: new Date(entry.createdAt),
|
||||
updatedAt: new Date(entry.updatedAt),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
@@ -517,20 +552,26 @@ export async function restoreVisitsData(
|
||||
status: entry.status,
|
||||
kind: entry.kind,
|
||||
practitionerId,
|
||||
reasonEncrypted:
|
||||
entry.reasonEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.reasonEncrypted),
|
||||
outcomeEncrypted:
|
||||
entry.outcomeEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.outcomeEncrypted),
|
||||
reasonEncrypted: sealBytesForRestore(
|
||||
entry.reasonEncrypted,
|
||||
entry.reason,
|
||||
`encounters.${entry.id}.reasonEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
outcomeEncrypted: sealBytesForRestore(
|
||||
entry.outcomeEncrypted,
|
||||
entry.outcome,
|
||||
`encounters.${entry.id}.outcomeEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
// Absent in a file written before v1.39.1: the visit comes back
|
||||
// with no site, which is what it had.
|
||||
bodySiteEncrypted:
|
||||
entry.bodySiteEncrypted == null
|
||||
? null
|
||||
: decodeCiphertext(entry.bodySiteEncrypted),
|
||||
bodySiteEncrypted: sealBytesForRestore(
|
||||
entry.bodySiteEncrypted,
|
||||
entry.bodySite,
|
||||
`encounters.${entry.id}.bodySiteEncrypted`,
|
||||
unopened,
|
||||
),
|
||||
laterality: entry.laterality ?? null,
|
||||
reminderId,
|
||||
createdAt: new Date(entry.createdAt),
|
||||
@@ -553,6 +594,7 @@ export async function restoreVisitsData(
|
||||
[...new Set(droppedReminders)],
|
||||
droppedReminders,
|
||||
);
|
||||
recordUnknownKeys(skips, "visitCiphertext", unopened, unopened);
|
||||
|
||||
const restoredEncounters = new Set(payload.encounters.map((e) => e.id));
|
||||
|
||||
|
||||
+216
-1
@@ -11,6 +11,19 @@
|
||||
* the record the request claims is folded into `key` (see `cellKey`), so the
|
||||
* tuple identifies a request to one person's record rather than a request by
|
||||
* one person. A caller acting only as themselves is unaffected, byte for byte.
|
||||
*
|
||||
* A cell also remembers WHICH request it answered: a SHA-256 of the canonical
|
||||
* request body (`requestFingerprint`). A key names one request, not a slot, and
|
||||
* a client that reuses a key for a second, different request must not be
|
||||
* handed the first request's response. Native clients up to 1.1.0 sent the two
|
||||
* halves of a manual blood pressure as two POSTs under one key; the second half
|
||||
* replayed the first's 201 and was never written. A lookup therefore replays,
|
||||
* or reports in-flight, only for the same body. A different body — or a row
|
||||
* written before fingerprints existed — runs the handler normally without
|
||||
* claiming or caching anything, and the existing cell stays for its own
|
||||
* retries. This is deliberately not the IETF draft's 422: refusing would turn
|
||||
* every affected client's second write into a visible error with no way for
|
||||
* the person to recover the value.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
@@ -109,6 +122,155 @@ export interface IdempotencyContext {
|
||||
key: string;
|
||||
method: string;
|
||||
path: string;
|
||||
/** See `requestFingerprint`. Never the body itself. */
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a body the fingerprint reads before it stops. The wrapper runs
|
||||
* before the handler's own bounded readers and upload slots, so it must not
|
||||
* buffer an arbitrarily large body on their behalf: reading the clone holds
|
||||
* what it has read in the original's queue until the handler consumes it.
|
||||
* Every JSON write the cache fronts is far below this; the one multipart route
|
||||
* (document upload) is above it, and there the prefix plus the declared length
|
||||
* identifies a retry of the same upload, with the route's own content hash as
|
||||
* the duplicate check behind it.
|
||||
*/
|
||||
const FINGERPRINT_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* JSON with every object's keys sorted, recursively. Arrays keep their order —
|
||||
* it is part of the request. Exported for its unit tests.
|
||||
*/
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.keys(value)
|
||||
.sort()
|
||||
.map(
|
||||
(k) =>
|
||||
`${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`,
|
||||
);
|
||||
return `{${entries.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every occurrence of the multipart boundary named in `contentType`
|
||||
* with a fixed token. Any other body is returned as it is.
|
||||
*/
|
||||
function withoutMultipartBoundary(
|
||||
body: Uint8Array,
|
||||
contentType: string | null,
|
||||
): Uint8Array {
|
||||
if (!contentType || !/^multipart\//i.test(contentType)) return body;
|
||||
const match = /;\s*boundary="?([^";]+)"?/i.exec(contentType);
|
||||
if (!match) return body;
|
||||
const boundary = Buffer.from(match[1], "utf8");
|
||||
const token = Buffer.from("boundary", "utf8");
|
||||
const source = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||
const parts: Buffer[] = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const at = source.indexOf(boundary, from);
|
||||
if (at === -1) break;
|
||||
parts.push(source.subarray(from, at), token);
|
||||
from = at + boundary.length;
|
||||
}
|
||||
if (parts.length === 0) return body;
|
||||
parts.push(source.subarray(from));
|
||||
return new Uint8Array(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprint of a request: SHA-256 hex over method, path and the body in
|
||||
* canonical form. A body that parses as JSON is hashed as `canonicalJson`, so
|
||||
* the same object serialised with a different key order is the same request;
|
||||
* anything else (multipart, plain text, an empty body) is hashed as its raw
|
||||
* bytes. `truncatedAt` marks a body cut at `FINGERPRINT_MAX_BYTES`, hashed as
|
||||
* that raw prefix together with the declared length. A multipart body has its
|
||||
* boundary replaced by a fixed token first: a client that rebuilds the form
|
||||
* for a retry picks a fresh random boundary, and the same upload must still
|
||||
* replay. Exported for unit tests.
|
||||
*/
|
||||
export function requestFingerprint(
|
||||
method: string,
|
||||
path: string,
|
||||
rawBody: Uint8Array,
|
||||
truncatedAt: string | null = null,
|
||||
contentType: string | null = null,
|
||||
): string {
|
||||
const body = withoutMultipartBoundary(rawBody, contentType);
|
||||
const hash = createHash("sha256")
|
||||
.update(method)
|
||||
.update("\0")
|
||||
.update(path)
|
||||
.update("\0");
|
||||
if (truncatedAt !== null) {
|
||||
return hash.update(`partial:${truncatedAt}\0`).update(body).digest("hex");
|
||||
}
|
||||
if (body.length > 0) {
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
||||
return hash
|
||||
.update("json:")
|
||||
.update(canonicalJson(JSON.parse(text)))
|
||||
.digest("hex");
|
||||
} catch {
|
||||
// Not UTF-8 or not JSON — fall through to the raw bytes.
|
||||
}
|
||||
}
|
||||
return hash.update("raw:").update(body).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the request's body from a clone, at most `FINGERPRINT_MAX_BYTES`, and
|
||||
* fingerprint it. The original request keeps its body for the handler.
|
||||
*/
|
||||
async function fingerprintRequest(
|
||||
request: Request | NextRequest,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
const stream = request.clone().body;
|
||||
if (!stream)
|
||||
return requestFingerprint(request.method, path, new Uint8Array());
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let truncated = false;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (total + value.byteLength > FINGERPRINT_MAX_BYTES) {
|
||||
chunks.push(value.subarray(0, FINGERPRINT_MAX_BYTES - total));
|
||||
total = FINGERPRINT_MAX_BYTES;
|
||||
truncated = true;
|
||||
// Cancelling this branch of the tee stops it from queueing the rest of
|
||||
// the body, and leaves the handler's branch intact. Not awaited: a tee
|
||||
// branch's cancel settles only once BOTH branches are done, which is
|
||||
// after the handler has run.
|
||||
void reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return requestFingerprint(
|
||||
request.method,
|
||||
path,
|
||||
body,
|
||||
truncated ? (request.headers.get("content-length") ?? "unknown") : null,
|
||||
request.headers.get("content-type"),
|
||||
);
|
||||
}
|
||||
|
||||
function getIdempotencyKey(request: Request | NextRequest): string | null {
|
||||
@@ -125,10 +287,16 @@ function getIdempotencyKey(request: Request | NextRequest): string | null {
|
||||
* - `{ kind: "pending" }` — another request holds an in-flight claim
|
||||
* for this key; the caller must NOT run the
|
||||
* handler again.
|
||||
* - `{ kind: "mismatch" }` — the live row answered a different body (or
|
||||
* predates fingerprints); the caller runs the
|
||||
* handler without claiming or caching.
|
||||
* - `null` — no live row; the caller may claim + run.
|
||||
*/
|
||||
type CacheLookup =
|
||||
{ kind: "replay"; response: NextResponse } | { kind: "pending" } | null;
|
||||
| { kind: "replay"; response: NextResponse }
|
||||
| { kind: "pending" }
|
||||
| { kind: "mismatch" }
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Look up the state of a (userId, key, method, path) tuple. Distinguishes
|
||||
@@ -157,6 +325,13 @@ async function findCached(ctx: IdempotencyContext): Promise<CacheLookup> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Checked before both the pending and the replay arm: a different request
|
||||
// under the same key is neither in flight nor answered. A legacy row's null
|
||||
// never equals a fingerprint.
|
||||
if (row.requestFingerprint !== ctx.fingerprint) {
|
||||
return { kind: "mismatch" };
|
||||
}
|
||||
|
||||
if (row.responseStatus === PENDING_STATUS) {
|
||||
// A concurrent request claimed this key and is still running its
|
||||
// side-effect. Signal the caller to refuse rather than double-execute.
|
||||
@@ -208,6 +383,7 @@ async function claimKey(ctx: IdempotencyContext): Promise<boolean> {
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
requestFingerprint: ctx.fingerprint,
|
||||
responseStatus: PENDING_STATUS,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + PENDING_TTL_MS),
|
||||
@@ -233,6 +409,7 @@ async function releaseClaim(ctx: IdempotencyContext): Promise<void> {
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
requestFingerprint: ctx.fingerprint,
|
||||
responseStatus: PENDING_STATUS,
|
||||
},
|
||||
})
|
||||
@@ -312,6 +489,7 @@ async function persistCached(
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
requestFingerprint: ctx.fingerprint,
|
||||
},
|
||||
data: {
|
||||
responseStatus: response.status,
|
||||
@@ -502,6 +680,23 @@ async function narrowTokenFacet(): Promise<string | null> {
|
||||
return apiToken.permissions.includes("*") ? null : `t:${apiToken.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a request whose key is already taken by a different body: the handler
|
||||
* runs exactly as it would without a key, and nothing is claimed or cached, so
|
||||
* the existing cell keeps answering its own retries.
|
||||
*/
|
||||
function runUncached<Args extends [Request | NextRequest, ...unknown[]]>(
|
||||
ctx: IdempotencyContext,
|
||||
handler: (...args: Args) => Promise<Response>,
|
||||
args: Args,
|
||||
): Promise<Response> {
|
||||
annotate({
|
||||
action: { name: "idempotency.key.body_mismatch" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
});
|
||||
return handler(...args);
|
||||
}
|
||||
|
||||
export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
@@ -589,14 +784,27 @@ export function withIdempotency<
|
||||
if (tokenFacet !== null) facets.push(tokenFacet);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const fingerprint = await fingerprintRequest(request, url.pathname).catch(
|
||||
() => null,
|
||||
);
|
||||
if (fingerprint === null) {
|
||||
// The body could not be read for the fingerprint (already consumed, or
|
||||
// the stream failed). Without knowing which request this is, no cell
|
||||
// may answer for it; the handler meets the body problem itself.
|
||||
return handler(...args);
|
||||
}
|
||||
const ctx: IdempotencyContext = {
|
||||
userId,
|
||||
key: cellKey(key, claimedRecord, facets),
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
fingerprint,
|
||||
};
|
||||
|
||||
const cached = await findCached(ctx);
|
||||
if (cached?.kind === "mismatch") {
|
||||
return runUncached(ctx, handler, args);
|
||||
}
|
||||
if (cached?.kind === "replay") {
|
||||
return cached.response;
|
||||
}
|
||||
@@ -617,6 +825,13 @@ export function withIdempotency<
|
||||
// one caller may execute the side-effect for a given key.
|
||||
const won = await claimKey(ctx);
|
||||
if (!won) {
|
||||
// The racing claim may be a different request under the same key —
|
||||
// the two halves of one reading posted together. That one is not a
|
||||
// duplicate of this one and must still run.
|
||||
const raced = await findCached(ctx);
|
||||
if (raced?.kind === "mismatch") {
|
||||
return runUncached(ctx, handler, args);
|
||||
}
|
||||
annotate({
|
||||
action: { name: "idempotency.inflight_conflict" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* GitHub #1124 — the thumbnail job ran 355 times on one instance, left
|
||||
* sixteen PDFs without a preview, and the log held not one line about it:
|
||||
* the job opened no wide event, so every annotation went nowhere. Pins: each
|
||||
* run is a `job.document_thumbnail` background event; a run that should have
|
||||
* made a preview and did not is raised to `warn` with the reason; a type that
|
||||
* never has a preview stays at info.
|
||||
*/
|
||||
|
||||
const { evt, findFirst, upsert, generateThumbnail, withBackgroundEvent } =
|
||||
vi.hoisted(() => {
|
||||
const evt = { addMeta: vi.fn(), elevateLevel: vi.fn() };
|
||||
return {
|
||||
evt,
|
||||
findFirst: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
generateThumbnail: vi.fn(),
|
||||
withBackgroundEvent: vi.fn(
|
||||
async (_name: string, fn: (e: typeof evt) => Promise<unknown>) =>
|
||||
fn(evt),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst },
|
||||
documentThumbnail: { upsert },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from("%PDF-1.7")),
|
||||
encryptThumbnail: vi.fn(() => new Uint8Array([1])),
|
||||
}));
|
||||
vi.mock("@/lib/documents/thumbnail", () => ({ generateThumbnail }));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/background", () => ({ withBackgroundEvent }));
|
||||
|
||||
import { runDocumentThumbnail } from "../document-thumbnail";
|
||||
|
||||
const DOC = {
|
||||
id: "doc-1",
|
||||
mimeType: "application/pdf",
|
||||
contentEncrypted: new Uint8Array([1]),
|
||||
contentCodec: "binary2",
|
||||
thumbnail: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findFirst.mockResolvedValue(DOC);
|
||||
});
|
||||
|
||||
describe("runDocumentThumbnail wide event", () => {
|
||||
it("runs inside a background event named for the job", async () => {
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "raster-failed" });
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(withBackgroundEvent).toHaveBeenCalledWith(
|
||||
"job.document_thumbnail",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(evt.addMeta).toHaveBeenCalledWith("document_id", "doc-1");
|
||||
});
|
||||
|
||||
it("warns with the reason when a PDF gets no preview", async () => {
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "error" });
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(evt.elevateLevel).toHaveBeenCalledWith("warn");
|
||||
expect(evt.addMeta).toHaveBeenCalledWith(
|
||||
"thumbnail_missing_reason",
|
||||
"error",
|
||||
);
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays at info for a type that never has a preview", async () => {
|
||||
findFirst.mockResolvedValue({ ...DOC, mimeType: "text/plain" });
|
||||
generateThumbnail.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "unsupported-type",
|
||||
});
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(evt.elevateLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the preview and stays at info when one is made", async () => {
|
||||
generateThumbnail.mockResolvedValue({
|
||||
ok: true,
|
||||
thumbnail: { jpeg: Buffer.from([0xff, 0xd8]), width: 2, height: 3 },
|
||||
});
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(upsert).toHaveBeenCalledOnce();
|
||||
expect(evt.elevateLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,12 @@
|
||||
* kind icon), and `runDocumentThumbnail` returns cleanly so pg-boss does not
|
||||
* retry it. Only an unexpected throw (a DB hiccup) propagates for retry.
|
||||
*
|
||||
* Each run is one background wide event (`job.document_thumbnail`). A run
|
||||
* that should have produced a preview and did not is raised to `warn` with
|
||||
* the reason, so a document left with its kind icon is explained in the log.
|
||||
* Without the event every annotation below went nowhere: a whole class of
|
||||
* PDFs went without previews for weeks and the log never said so.
|
||||
*
|
||||
* The queue MUST be registered in the maintenance registrar
|
||||
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
|
||||
*/
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
} from "@/lib/documents/store";
|
||||
import { generateThumbnail } from "@/lib/documents/thumbnail";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const DOCUMENT_THUMBNAIL_QUEUE = "document-thumbnail";
|
||||
@@ -49,7 +56,20 @@ export async function runDocumentThumbnail(
|
||||
): Promise<void> {
|
||||
const { userId, documentId } = payload;
|
||||
if (!userId || !documentId) return;
|
||||
await withBackgroundEvent("job.document_thumbnail", async (evt) => {
|
||||
evt.addMeta("document_id", documentId);
|
||||
await renderThumbnail(userId, documentId, (reason) => {
|
||||
evt.addMeta("thumbnail_missing_reason", reason);
|
||||
evt.elevateLevel("warn");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderThumbnail(
|
||||
userId: string,
|
||||
documentId: string,
|
||||
warn: (reason: string) => void,
|
||||
): Promise<void> {
|
||||
// Owner-scoped load; the blob column is selected ONLY here (the job that
|
||||
// renders the preview), never in the list/detail queries. Skip a document
|
||||
// that already has a thumbnail so a re-enqueue is a cheap no-op.
|
||||
@@ -84,6 +104,7 @@ export async function runDocumentThumbnail(
|
||||
action: { name: "documents.thumbnail.decryptFailed" },
|
||||
meta: { documentId },
|
||||
});
|
||||
warn("decrypt-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,8 +112,11 @@ export async function runDocumentThumbnail(
|
||||
if (!result.ok) {
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.run" },
|
||||
meta: { documentId, generated: false },
|
||||
meta: { documentId, generated: false, mimeType: document.mimeType },
|
||||
});
|
||||
// An Office or text document has no preview by design; anything else
|
||||
// was meant to get one.
|
||||
if (result.reason !== "unsupported-type") warn(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -323,57 +323,69 @@ function windowAsCourse(
|
||||
* Clearing both on a medication with one course deletes it (chronic again,
|
||||
* the pre-course meaning of no dates); on one with several it is refused,
|
||||
* because it would silently drop the history.
|
||||
*
|
||||
* `tx` runs the write inside the caller's transaction, so the PUT's row
|
||||
* update and this course write commit or roll back together: a refusal here
|
||||
* then leaves nothing of the PUT behind.
|
||||
*/
|
||||
export async function setCurrentWindow(
|
||||
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
||||
tx?: Tx,
|
||||
): Promise<ProjectedWindow> {
|
||||
return tx
|
||||
? writeCurrentWindow(tx, args)
|
||||
: prisma.$transaction((own) => writeCurrentWindow(own, args));
|
||||
}
|
||||
|
||||
async function writeCurrentWindow(
|
||||
tx: Tx,
|
||||
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
||||
): Promise<ProjectedWindow> {
|
||||
const todayKey = userDayKey(args.now ?? new Date(), args.timeZone);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
|
||||
if (args.startsOn === null && args.endsOn === null) {
|
||||
if (!latest) return project(tx, args.medicationId);
|
||||
if (courses.length > 1) throw new CourseWriteError("windowRequired");
|
||||
await tx.medicationCourse.delete({ where: { id: latest.id } });
|
||||
return project(tx, args.medicationId);
|
||||
}
|
||||
|
||||
const next = windowAsCourse(
|
||||
args.startsOn,
|
||||
args.endsOn,
|
||||
latest,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
|
||||
if (latest) {
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? next : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: next,
|
||||
});
|
||||
} else {
|
||||
refuseIfInvalid([next], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...next,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (args.startsOn === null && args.endsOn === null) {
|
||||
if (!latest) return project(tx, args.medicationId);
|
||||
if (courses.length > 1) throw new CourseWriteError("windowRequired");
|
||||
await tx.medicationCourse.delete({ where: { id: latest.id } });
|
||||
return project(tx, args.medicationId);
|
||||
});
|
||||
}
|
||||
|
||||
const next = windowAsCourse(
|
||||
args.startsOn,
|
||||
args.endsOn,
|
||||
latest,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
|
||||
if (latest) {
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? next : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: next,
|
||||
});
|
||||
} else {
|
||||
refuseIfInvalid([next], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...next,
|
||||
},
|
||||
});
|
||||
}
|
||||
return project(tx, args.medicationId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SPARKLINE_MAX_POINTS,
|
||||
} from "@/lib/insights/derived/types";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain";
|
||||
import { providerChainPutSchema } from "@/lib/validations/ai-provider";
|
||||
import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative";
|
||||
import { aiCapabilityState } from "../profile";
|
||||
|
||||
@@ -2531,36 +2531,11 @@ export const providerChainResponse = z
|
||||
"The account's AI-provider chain as the settings surface reads it. Provider TYPES and boolean state only — no key, token or account id is ever on this wire.",
|
||||
});
|
||||
|
||||
export const providerChainPutRequest = z
|
||||
.object({
|
||||
chain: z
|
||||
.array(
|
||||
z.object({
|
||||
providerType: z
|
||||
.enum(PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]])
|
||||
.describe(
|
||||
"Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.",
|
||||
),
|
||||
priority: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
"ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe(
|
||||
"The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ProviderChainPutRequest",
|
||||
description:
|
||||
"Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, and which are enabled.",
|
||||
});
|
||||
export const providerChainPutRequest = providerChainPutSchema.meta({
|
||||
id: "ProviderChainPutRequest",
|
||||
description:
|
||||
"Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, which are enabled, and the reasoning setting of the Local and OpenAI-compatible entries.",
|
||||
});
|
||||
|
||||
// ── Read-only advisor + generation (`/api/insights/generate`) ─────────
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@/lib/validations/user-prefs";
|
||||
import {
|
||||
aiProviderPatchSchema,
|
||||
reasoningEffortSchema,
|
||||
aiTestOverrideSchema,
|
||||
} from "@/lib/validations/ai-provider";
|
||||
import {
|
||||
@@ -674,6 +675,12 @@ const aiProviderResponse = z
|
||||
.describe(
|
||||
"v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
|
||||
),
|
||||
localReasoningEffort: reasoningEffortSchema.describe(
|
||||
"#1126 — reasoning setting of the Local provider entry, sent as `reasoning_effort` (`none` = Off). Null = Default: the key is not sent.",
|
||||
),
|
||||
compatReasoningEffort: reasoningEffortSchema.describe(
|
||||
"#1126 — the same for the OpenAI-compatible gateway entry.",
|
||||
),
|
||||
// v1.38.19 — the setup flow's last screen used to promise
|
||||
// that insights "work for you right away" as soon as `managedBy` read
|
||||
// `server`. That is a presence read; it never knew whether the key
|
||||
|
||||
@@ -391,7 +391,8 @@ export const profileEmailRateLimitResponse = {
|
||||
*
|
||||
* Scope, so nobody reads more into it than is there: only POST, PUT, PATCH and
|
||||
* DELETE are covered (`SUPPORTED_METHODS`), the key is scoped per user and per
|
||||
* `(method, path)`, and `isCachableStatus` refuses to cache 5xx or the
|
||||
* `(method, path)`, a replay needs the same body (a different body under the
|
||||
* same key runs as a new write and is neither replayed nor refused), and `isCachableStatus` refuses to cache 5xx or the
|
||||
* transient 401 / 403 / 408 / 429 — a retry after one of those runs for real.
|
||||
*
|
||||
* `src/__tests__/idempotency-contract-publication.test.ts` holds this to the
|
||||
@@ -410,7 +411,7 @@ export const idempotencyKeyParameter = {
|
||||
maxLength: 128,
|
||||
},
|
||||
description:
|
||||
"Makes this write safe to retry. Send the same key again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second is refused with 409 rather than queued.",
|
||||
"Makes this write safe to retry. Send the same key with the same body again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. The body is compared as JSON with key order ignored; a different body under the same key is not a retry and runs as a new write, while the first response stays stored for retries of the first request. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second with the same body is refused with 409 rather than queued.",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain";
|
||||
import {
|
||||
isReasoningProviderType,
|
||||
REASONING_EFFORTS,
|
||||
} from "@/lib/ai/reasoning-effort";
|
||||
|
||||
/** The five provider kinds a user may select. */
|
||||
export const AI_PROVIDER_KINDS = [
|
||||
"OPENAI",
|
||||
@@ -38,6 +44,17 @@ export const aiProviderKindSchema = z.enum(AI_PROVIDER_KINDS);
|
||||
*/
|
||||
const clearableString = z.string().nullable();
|
||||
|
||||
/**
|
||||
* #1126 — a provider entry's reasoning setting. `null` is "Default": nothing
|
||||
* is sent and the model decides. `none` is "Off".
|
||||
*/
|
||||
export const reasoningEffortSchema = z
|
||||
.enum(REASONING_EFFORTS)
|
||||
.nullable()
|
||||
.describe(
|
||||
"Reasoning setting for a Local or OpenAI-compatible provider, sent as `reasoning_effort` in the `/chat/completions` body. `none` switches reasoning off, which stops a thinking model (Gemma, Qwen and the like) spending the answer budget on reasoning. `null` is Default: the key is not sent at all and the model decides.",
|
||||
);
|
||||
|
||||
/**
|
||||
* `PATCH /api/user/ai-provider` — a partial update of the provider config.
|
||||
*
|
||||
@@ -93,6 +110,22 @@ export const aiProviderPatchSchema = z.object({
|
||||
.max(600, "Response timeout must be between 10 and 600 seconds")
|
||||
.nullable()
|
||||
.optional(),
|
||||
/**
|
||||
* #1126 — the reasoning setting of the Local entry. Stored on the entry in
|
||||
* the provider chain; a chain never customised is materialised from the
|
||||
* default to hold it. `null` restores Default.
|
||||
*/
|
||||
localReasoningEffort: reasoningEffortSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Reasoning setting of the Local provider entry, stored on that entry of the provider chain. `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched.",
|
||||
),
|
||||
/** #1126 — the same for the OpenAI-compatible gateway entry. */
|
||||
compatReasoningEffort: reasoningEffortSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Reasoning setting of the OpenAI-compatible gateway entry, stored on that entry of the provider chain. `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched.",
|
||||
),
|
||||
});
|
||||
|
||||
export type AiProviderPatchInput = z.infer<typeof aiProviderPatchSchema>;
|
||||
@@ -115,3 +148,61 @@ export const aiTestOverrideSchema = z
|
||||
.strict();
|
||||
|
||||
export type AiTestOverrideInput = z.infer<typeof aiTestOverrideSchema>;
|
||||
|
||||
/**
|
||||
* One entry of `PUT /api/insights/provider-chain`. Shared by the route and the
|
||||
* OpenAPI registry, so the published contract and the parser are one object.
|
||||
*/
|
||||
export const providerChainEntrySchema = z
|
||||
.object({
|
||||
providerType: z
|
||||
.enum(PROVIDER_CHAIN_TYPES)
|
||||
.describe(
|
||||
"Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.",
|
||||
),
|
||||
// Priority is recomputed server-side from insertion order so a stale
|
||||
// client cannot persist a chain whose displayed order disagrees with
|
||||
// its priority field. The number is accepted (and may be present) but
|
||||
// ignored on the wire.
|
||||
priority: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
"ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
reasoningEffort: reasoningEffortSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Only on a `local` or `openai-compatible` entry; refused on any other. Omitted keeps the value already stored for that provider, `null` restores Default, `none` / `low` / `medium` / `high` are sent as `reasoning_effort`.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((entry, ctx) => {
|
||||
if (
|
||||
entry.reasoningEffort !== undefined &&
|
||||
!isReasoningProviderType(entry.providerType)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["reasoningEffort"],
|
||||
message:
|
||||
"A reasoning setting applies to the local and openai-compatible providers only",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const providerChainPutSchema = z
|
||||
.object({
|
||||
chain: z
|
||||
.array(providerChainEntrySchema)
|
||||
.min(1, "Chain must contain at least one provider")
|
||||
.max(PROVIDER_CHAIN_TYPES.length, "Too many providers")
|
||||
.describe(
|
||||
"The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ProviderChainPutInput = z.infer<typeof providerChainPutSchema>;
|
||||
@@ -625,6 +625,15 @@ const cycleSymptomSeveritySchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** The sensitive-category envelope's five fields, as a portable file carries them. */
|
||||
const cycleSensitiveFieldsSchema = z.object({
|
||||
sexualActivity: z.boolean().optional(),
|
||||
protectedSex: z.boolean().nullable().optional(),
|
||||
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
|
||||
});
|
||||
|
||||
const cycleDayLogSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
@@ -644,8 +653,20 @@ const cycleDayLogSchema = z
|
||||
pregnancyTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
progesteroneTest: z.enum(HomeTestResult).nullable().optional(),
|
||||
contraceptive: z.enum(ContraceptiveKind).nullable().optional(),
|
||||
// Free text travels like every other note: a disaster-recovery file
|
||||
// carries the ciphertext (`*Encrypted`), a portable file the readable
|
||||
// value (`note`, `sensitive`), which the restore seals under the
|
||||
// receiving host's key. A portable file written before v1.40 carries the
|
||||
// ciphertext too; the restore keeps only what this host can open.
|
||||
sensitiveEncrypted: z.string().nullable().optional(),
|
||||
notesEncrypted: z.string().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
// The five intent fields the sensitive-category envelope holds, or the
|
||||
// unreadable marker when the writing host could not open the envelope.
|
||||
sensitive: z
|
||||
.union([cycleSensitiveFieldsSchema, z.string()])
|
||||
.nullable()
|
||||
.optional(),
|
||||
source: z.enum(MeasurementSource).optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
tz: z.string().nullable().optional(),
|
||||
@@ -696,10 +717,12 @@ const customMoodTagSchema = z
|
||||
isActive: z.boolean().default(true),
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
// The user's own words for the tag, encrypted at rest and carried
|
||||
// verbatim. Without it the tag comes back as a bare key and the person
|
||||
// who named it "Migräne" gets `custom:cm3x9…` instead.
|
||||
// The user's own words for the tag. Without it the tag comes back as a
|
||||
// bare key and the person who named it "Migräne" gets `custom:cm3x9…`
|
||||
// instead. Ciphertext on a disaster-recovery file, readable `label` on a
|
||||
// portable one; the restore seals the readable value under its own key.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
// The scale a RATED factor was recorded on. `inverse` marks a factor
|
||||
// where a HIGH value is bad (stress, conflict). Dropping it does not lose
|
||||
// a label, it silently reverses the meaning of every rating already
|
||||
@@ -752,7 +775,10 @@ const customCycleSymptomSchema = z
|
||||
sortOrder: z.number().int().default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
// Same reasoning as the mood tag's: the user's own words live here.
|
||||
// Ciphertext on a disaster-recovery file, readable `label` on a portable
|
||||
// one (the day-log note's split).
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -1181,8 +1207,9 @@ const symptomDefinitionBackupSchema = z
|
||||
*
|
||||
* `id` is required on both records, unlike the lab result above: a link
|
||||
* addresses an encounter and its far side by id and nothing else on either row
|
||||
* is unique enough to rebuild the reference from. The ciphertext columns ride
|
||||
* verbatim as base64 — a visit note is never decrypted into the file.
|
||||
* is unique enough to rebuild the reference from. Free text follows the note
|
||||
* contract: ciphertext as base64 on a disaster-recovery file, the readable
|
||||
* value on a portable one (sealed under the receiving host's key on restore).
|
||||
*/
|
||||
const practitionerBackupSchema = z
|
||||
.object({
|
||||
@@ -1196,6 +1223,7 @@ const practitionerBackupSchema = z
|
||||
locationEncrypted: base64BytesSchema.nullable().optional(),
|
||||
phoneEncrypted: base64BytesSchema.nullable().optional(),
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
@@ -1214,6 +1242,10 @@ const encounterBackupSchema = z
|
||||
// v1.39.1 — the procedure's body site and side. Optional so a file written
|
||||
// before they existed still parses; the visit restores with neither.
|
||||
bodySiteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
// The readable reason, outcome and body site of a portable file.
|
||||
reason: z.string().nullable().optional(),
|
||||
outcome: z.string().nullable().optional(),
|
||||
bodySite: z.string().nullable().optional(),
|
||||
laterality: z.enum(Laterality).nullable().optional(),
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
@@ -1255,7 +1287,9 @@ const vaccinationBackupSchema = z
|
||||
// Remapped against the restored reminders (they travel since v1.37.20);
|
||||
// dropped to NULL, with the drop named, only when the file lacks the row.
|
||||
reminderId: z.string().nullable().optional(),
|
||||
// Ciphertext on a disaster-recovery file, readable on a portable one.
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
@@ -1618,7 +1652,9 @@ const customMoodTagCategorySchema = z
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
// Same split as the custom tag's label.
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
label: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ describe("buildManualWorkoutEntry — validation", () => {
|
||||
describe("the form's defaults", () => {
|
||||
it("opens with a blank start; the field caps at the current minute", () => {
|
||||
expect(wallClockNow(NOW, "Europe/Berlin")).toBe("2026-09-15T14:00");
|
||||
expect(emptyManualWorkoutDraft(NOW, "Asia/Seoul")).toMatchObject({
|
||||
expect(emptyManualWorkoutDraft()).toMatchObject({
|
||||
sportType: "",
|
||||
start: "",
|
||||
hours: "",
|
||||
|
||||
@@ -118,10 +118,7 @@ export function wallClockNow(now: Date, timezone: string): string {
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`;
|
||||
}
|
||||
|
||||
export function emptyManualWorkoutDraft(
|
||||
now: Date,
|
||||
timezone: string,
|
||||
): ManualWorkoutDraft {
|
||||
export function emptyManualWorkoutDraft(): ManualWorkoutDraft {
|
||||
return {
|
||||
sportType: "",
|
||||
// Blank on purpose (#1085): a blank start means "it just ended", so the
|
||||
|
||||
Reference in new issue
Block a user