Merge the release with the security review fixes into the data-safety review fixes

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 18:36:54 +01:00
commit bcb31c80db
98 files changed
+3166 -608

No files matched your search

@@ -77,6 +77,8 @@ describe("the Postgres 16 -> 18 move in docker-compose.yml", () => {
// The checks run before the restore, in this order.
const order = [
"PG_VERSION",
// Never touch the old volume while the database service runs on it.
'if [ "$$NEWVER" -lt 180000 ]',
"$$MARK",
"$$MOVING",
'elif [ "$$TABLES" != 0 ]',
+228 -45
View File
@@ -14,49 +14,197 @@
*
* ## What it proves and what it does not
*
* Every module under the paths that build model input (`src/lib/ai`,
* `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) that reads the lab
* table either asks the labs module itself (`isModuleEnabled(…, "labs")`,
* the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn` wrapper) or
* is listed below with the reason its read cannot reach a prompt. A new
* reader fails here until it does one or the other. The data routes are not
* in scope: a module decides what a model is given, not what the owner can
* read (`CLAUDE.md`, "An AI capability gates model work").
* Every lab read under the paths that build model input (`src/lib/ai`,
* `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) either sits in a
* function that asks the labs module itself (`isModuleEnabled(…, "labs")`,
* the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn(…, "labs")`
* wrapper), or sits in a private helper of its file whose every caller in
* that file does, or is listed below by file and function with the reason
* its rows cannot reach a prompt. A new reader fails here until it does one
* of those. The data routes are not in scope: a module decides what a model
* is given, not what the owner can read (`CLAUDE.md`, "An AI capability
* gates model work").
*
* It is grep-shaped: it sees a read, not where the rows go, and a reader in
* another directory is outside it. The behaviour is pinned per path by the
* module-off cases in the suites of the readers themselves.
* The check is per read, not per file. It used to ask whether a file that
* reads the table mentions the gate anywhere, so one ungated read in a file
* with a gated one passed, and `whenModuleOn` for any module counted as the
* labs gate. It still sees a read and the function around it, not where the
* rows go: a gate in the function is taken to cover the read, and a reader
* in another directory is outside it. The behaviour is pinned per path by
* the module-off cases in the suites of the readers themselves.
*/
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { readSourceFolder, stripComments } from "./helpers/source-files";
const ROOTS = ["src/lib/ai", "src/lib/insights", "src/lib/jobs", "src/lib/mcp"];
/** A read of the lab table: the Prisma delegate or raw SQL naming it. */
const LAB_READ =
/\.labResult\s*\.\s*(?:findMany|findFirst|findUnique|count|aggregate|groupBy)\s*\(|\blab_results\b/;
const READ_METHODS = new Set([
"findMany",
"findFirst",
"findUnique",
"count",
"aggregate",
"groupBy",
]);
/** Raw SQL naming the lab table. */
const LAB_TABLE = /\blab_results\b/;
/** What counts as asking the labs module. */
const LABS_GATE =
/isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(/;
/isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(\s*[\w.]+\s*,\s*"labs"/;
/** Readers whose rows never reach a model, each with the reason. */
/**
* Reads whose rows never reach a model, by `file#top-level name`, each with
* the reason.
*/
const NOT_MODEL_INPUT: Record<string, string> = {
"src/lib/jobs/lab-biomarker-backfill.ts":
"src/lib/jobs/lab-biomarker-backfill.ts#runLabBiomarkerBackfillForUser":
"links stored rows to their biomarker; no model is involved",
"src/lib/jobs/soft-delete-retention.ts":
"src/lib/jobs/lab-biomarker-backfill.ts#enqueueBootTimeLabBiomarkerBackfill":
"finds the accounts whose rows need linking; no model is involved",
"src/lib/jobs/soft-delete-retention.ts#SOFT_DELETE_RETENTION":
"purges rows past the trash window; no model is involved",
"src/lib/insights/correlation-channel-series.ts":
"`fetchLabDraws`: both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface",
"src/lib/mcp/rich-reads.ts":
"`getLabHistory`: reached only through the MCP get_labs tool, which runs behind whenModuleOn",
"src/lib/ai/coach/tools/availability.ts":
"src/lib/insights/correlation-channel-series.ts#fetchLabDraws":
"both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface",
"src/lib/mcp/rich-reads.ts#getLabHistory":
'reached only through the MCP get_labs tool, which runs behind whenModuleOn(ctx, "labs")',
"src/lib/ai/coach/tools/availability.ts#readTableBounds":
"counts lab rows for the Coach's empty-read answer; the only lab subject comes from get_labs, which answers module_disabled first",
"src/lib/ai/coach/tools/availability.ts#holdsRowsBefore":
"whether lab rows predate the lookback, for the same empty-read answer and the same get_labs subject",
};
function labReaders(): Array<{ path: string; gated: boolean }> {
const out: Array<{ path: string; gated: boolean }> = [];
type FunctionNode = ts.SignatureDeclaration & { body?: ts.Node };
function isReadCall(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) return false;
const callee = node.expression;
if (!ts.isPropertyAccessExpression(callee)) return false;
if (!READ_METHODS.has(callee.name.text)) return false;
const target = callee.expression;
return (
ts.isPropertyAccessExpression(target) && target.name.text === "labResult"
);
}
function isTableLiteral(node: ts.Node): boolean {
return (
(ts.isStringLiteral(node) ||
ts.isNoSubstitutionTemplateLiteral(node) ||
ts.isTemplateHead(node) ||
ts.isTemplateMiddle(node) ||
ts.isTemplateTail(node)) &&
LAB_TABLE.test(node.text)
);
}
function enclosingFunctions(node: ts.Node): FunctionNode[] {
const out: FunctionNode[] = [];
for (let at = node.parent; at; at = at.parent) {
if (ts.isFunctionLike(at)) out.push(at as FunctionNode);
}
return out;
}
/** The statement directly under the file that holds `node`. */
function topLevelStatement(node: ts.Node): ts.Statement | null {
let at: ts.Node = node;
while (at.parent && !ts.isSourceFile(at.parent)) at = at.parent;
return at.parent ? (at as ts.Statement) : null;
}
function declaredName(statement: ts.Statement): string | null {
if (ts.isFunctionDeclaration(statement)) return statement.name?.text ?? null;
if (ts.isVariableStatement(statement)) {
const [first] = statement.declarationList.declarations;
return first && ts.isIdentifier(first.name) ? first.name.text : null;
}
return null;
}
function isExported(statement: ts.Statement): boolean {
return (
ts.canHaveModifiers(statement) &&
(ts.getModifiers(statement) ?? []).some(
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
)
);
}
/**
* Whether the read at `node` is behind the labs gate: a function around it
* asks, or it sits in a private top-level helper every one of whose
* references in the file is itself behind the gate.
*/
function gated(
file: ts.SourceFile,
node: ts.Node,
seen: Set<string> = new Set(),
): boolean {
for (const fn of enclosingFunctions(node)) {
if (LABS_GATE.test(stripComments(fn.getText(file)))) return true;
}
const statement = topLevelStatement(node);
const name = statement ? declaredName(statement) : null;
if (!statement || !name || isExported(statement) || seen.has(name)) {
return false;
}
seen.add(name);
const references: ts.Node[] = [];
const visit = (at: ts.Node) => {
if (
ts.isIdentifier(at) &&
at.text === name &&
topLevelStatement(at) !== statement
) {
references.push(at);
}
ts.forEachChild(at, visit);
};
visit(file);
return (
references.length > 0 &&
references.every((ref) => gated(file, ref, new Set(seen)))
);
}
interface LabRead {
/** `file#top-level name`. */
site: string;
line: number;
gated: boolean;
}
function readsIn(path: string, source: string): LabRead[] {
const file = ts.createSourceFile(
path,
source,
ts.ScriptTarget.Latest,
true,
path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const out: LabRead[] = [];
const visit = (node: ts.Node) => {
if (isReadCall(node) || isTableLiteral(node)) {
const statement = topLevelStatement(node);
const name = (statement && declaredName(statement)) ?? "(top level)";
out.push({
site: `${path}#${name}`,
line: file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1,
gated: gated(file, node),
});
}
ts.forEachChild(node, visit);
};
visit(file);
return out;
}
function labReads(): LabRead[] {
const out: LabRead[] = [];
for (const root of ROOTS) {
for (const [rel, source] of readSourceFolder(root, {
floor: 20,
@@ -64,49 +212,84 @@ function labReaders(): Array<{ path: string; gated: boolean }> {
!r.split("/").includes("__tests__") &&
!/\.(?:test|spec)\.tsx?$/.test(r),
})) {
const code = stripComments(source);
if (!LAB_READ.test(code)) continue;
out.push({ path: `${root}/${rel}`, gated: LABS_GATE.test(code) });
out.push(...readsIn(`${root}/${rel}`, source));
}
}
return out.sort((a, b) => a.path.localeCompare(b.path));
return out.sort((a, b) => a.site.localeCompare(b.site) || a.line - b.line);
}
describe("labs reach a model only with the labs module on", () => {
const readers = labReaders();
const reads = labReads();
it("finds the readers it exists to check", () => {
it("finds the reads it exists to check", () => {
// An empty match set would pass every assertion below vacuously.
expect(readers.length).toBeGreaterThanOrEqual(8);
expect(readers.filter((r) => r.gated).length).toBeGreaterThanOrEqual(6);
expect(reads.length).toBeGreaterThanOrEqual(14);
expect(reads.filter((r) => r.gated).length).toBeGreaterThanOrEqual(8);
});
it("every reader asks the labs module or is listed as not model input", () => {
const open = readers
.filter((r) => !r.gated && !(r.path in NOT_MODEL_INPUT))
.map((r) => r.path);
it("every read asks the labs module or is listed as not model input", () => {
const open = reads
.filter((r) => !r.gated && !(r.site in NOT_MODEL_INPUT))
.map((r) => `${r.site}:${r.line}`);
expect(open).toEqual([]);
});
it("lists no reader that has since gained the gate or gone", () => {
const paths = new Map(readers.map((r) => [r.path, r.gated]));
it("lists no site that has since gained the gate or gone", () => {
const stale = Object.keys(NOT_MODEL_INPUT).filter(
(path) => paths.get(path) !== false,
(site) => !reads.some((r) => r.site === site && !r.gated),
);
expect(stale).toEqual([]);
});
it("the matchers catch what they exist to catch", () => {
expect(LAB_READ.test("await prisma.labResult.findMany({")).toBe(true);
expect(LAB_READ.test("tx.labResult\n .count({")).toBe(true);
expect(LAB_READ.test("SELECT 1 FROM lab_results")).toBe(true);
expect(LAB_READ.test("prisma.labResultDocumentLink.findMany(")).toBe(false);
it("judges each read on its own, not the file it is in", () => {
const source = `
export async function gatedRead(userId: string) {
if (!(await isModuleEnabled(userId, "labs"))) return null;
return prisma.labResult.findMany({ where: { userId } });
}
export async function openRead(userId: string) {
return prisma.labResult.count({ where: { userId } });
}
async function helper(userId: string) {
return prisma.labResult.findFirst({ where: { userId } });
}
export async function callsHelperGated(userId: string) {
if (!(await isModuleEnabled(userId, "labs"))) return null;
return helper(userId);
}
async function helperTwo(userId: string) {
return prisma.$queryRaw\`SELECT 1 FROM lab_results\`;
}
export async function callsHelperTwo(userId: string) {
return helperTwo(userId);
}
export function medsOnly(ctx: Ctx) {
return whenModuleOn(ctx, "medications", "t", () =>
prisma.labResult.findMany({}),
);
}
`;
const verdicts = readsIn("fixture.ts", source).map(
(r) => `${r.site}:${r.gated}`,
);
expect(verdicts).toEqual([
"fixture.ts#gatedRead:true",
"fixture.ts#openRead:false",
"fixture.ts#helper:true",
"fixture.ts#helperTwo:false",
"fixture.ts#medsOnly:false",
]);
});
it("the gate matcher takes the labs module only", () => {
expect(LABS_GATE.test('await isModuleEnabled(userId, "labs")')).toBe(true);
expect(LABS_GATE.test('await isModuleEnabled(ctx.userId, "labs")')).toBe(
true,
);
expect(LABS_GATE.test('whenModuleOn(ctx, "labs", "get_labs"')).toBe(true);
expect(LABS_GATE.test('isModuleEnabled(userId, "medications")')).toBe(
false,
);
expect(LABS_GATE.test('whenModuleOn(ctx, "environment", "x"')).toBe(false);
});
});
@@ -75,6 +75,16 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
expect(res.headers.get("location")).toBeNull();
});
it("counts the __Host- session cookie as a session at the page gate", () => {
const res = proxy(
makeRequest("/insights", { "__Host-healthlog_session": "sess-1" }),
);
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
const anonymous = proxy(makeRequest("/insights"));
expect(anonymous.headers.get("location")).toMatch(/\/auth\/login$/);
});
it("passes through when hl_onboarding cookie is absent (completed users)", () => {
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
expect(res.status).toBe(200);
+6 -3
View File
@@ -190,10 +190,13 @@ describe("S1 — the direct session-resolution set is frozen", () => {
/\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/;
/**
* Reaching for the cookie by name is the way around the helpers. Both the
* exported constant and the raw literal count.
* Reaching for the cookie by name is the way around the helpers. The
* reader (`readSessionCookie`, which tries both names), the exported names
* and either raw literal all count; the `__Host-` name joined the old one
* when a `Secure` session cookie started being issued under it.
*/
const RAW_SESSION_COOKIE = /\bSESSION_COOKIE_NAME\b|"healthlog_session"/;
const RAW_SESSION_COOKIE =
/\b(readSessionCookie|SESSION_COOKIE_NAMES?|HOST_SESSION_COOKIE|LEGACY_SESSION_COOKIE)\b|"(__Host-)?healthlog_session"/;
it("no file under src/app derives a session outside requireAuth unlisted", () => {
const resolvers = appFilesMatching(SESSION_DERIVING_HELPERS);
@@ -20,7 +20,6 @@ vi.mock("@/lib/rate-limit", () => ({
const validateSessionWithCreatedAt = vi.fn();
vi.mock("@/lib/auth/session", () => ({
validateSessionWithCreatedAt: (v: unknown) => validateSessionWithCreatedAt(v),
SESSION_COOKIE_NAME: "healthlog_session",
}));
// Keep the real shared core (buildHandoffCallbackUrl is used by
+6 -12
View File
@@ -29,11 +29,11 @@ import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { validateSessionWithCreatedAt } from "@/lib/auth/session";
import {
validateSessionWithCreatedAt,
SESSION_COOKIE_NAME,
} from "@/lib/auth/session";
expireSessionCookies,
readSessionCookie,
} from "@/lib/auth/session-cookie";
import { mintNativeHandoff } from "@/lib/auth/native-handoff";
import {
NATIVE_HANDOFF_STATE_COOKIE,
@@ -95,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
// (row + expiry), never "a cookie is present". Projects `createdAt` so the
// freshness comparison below is structurally always available.
const authed = await validateSessionWithCreatedAt(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
readSessionCookie(req.cookies)?.value,
);
if (!authed) {
annotate({ meta: { reason: "no_session" } });
@@ -140,13 +140,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
await prisma.session
.delete({ where: { id: authed.session.id } })
.catch(() => {});
response.cookies.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: 0,
path: "/",
});
expireSessionCookies(response.cookies);
return response;
});
@@ -42,7 +42,6 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn(),
validateSessionFromCookieValue: vi.fn(),
SESSION_COOKIE_NAME: "healthlog_session",
}));
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
vi.mock("@/lib/auth/mfa/challenge", () => ({
+4 -6
View File
@@ -3,10 +3,8 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import {
validateSessionFromCookieValue,
SESSION_COOKIE_NAME,
} from "@/lib/auth/session";
import { validateSessionFromCookieValue } from "@/lib/auth/session";
import { readSessionCookie } from "@/lib/auth/session-cookie";
import {
discoverOidcMetadata,
exchangeCodeForTokens,
@@ -225,7 +223,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
@@ -258,7 +256,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
: null;
if (isClaim) {
const liveSession = await validateSessionFromCookieValue(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
readSessionCookie(req.cookies)?.value,
);
if (liveSession) {
annotate({ meta: { reason: "claim_signed_in" } });
@@ -444,12 +444,18 @@ describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () =
*/
describe("POST /api/internal/deploy-webhook (signed requests)", () => {
const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
const ORIGINAL_SIGNATURE = process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE;
afterEach(() => {
if (ORIGINAL_REQUIRE === undefined) {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
} else {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE;
}
if (ORIGINAL_SIGNATURE === undefined) {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE;
} else {
process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = ORIGINAL_SIGNATURE;
}
});
async function signed(
@@ -491,8 +497,8 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => {
expect(auditLog).not.toHaveBeenCalled();
});
it("in strict mode accepts only a signed request, not the bare secret with a fresh timestamp", async () => {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
it("with DEPLOY_WEBHOOK_REQUIRE_SIGNATURE accepts only a signed request, not the bare secret with a fresh timestamp", async () => {
process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = "true";
const unsigned = jsonRequest(
{ status: "success" },
{
@@ -503,4 +509,24 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => {
expect((await POST(unsigned)).status).toBe(401);
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
});
it("keeps DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP meaning the secret plus a fresh timestamp", async () => {
// What the flag meant before 1.46, and what an operator who set it with
// a sender that timestamps but cannot sign still gets.
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
const fresh = jsonRequest(
{ status: "success" },
{
"x-deploy-webhook-secret": "test-secret",
"x-deploy-webhook-timestamp": String(Math.floor(Date.now() / 1000)),
},
);
expect((await POST(fresh)).status).toBe(200);
const bare = jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "test-secret" },
);
expect((await POST(bare)).status).toBe(401);
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
});
});
+23 -10
View File
@@ -110,9 +110,15 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
* - Default: a signed request is verified as above; an unsigned one
* authenticates with the secret header, as Coolify's stock sender does,
* and a timestamp it carries must still be fresh.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: only a signed request is
* accepted. The name predates the signature; requiring a timestamp
* without one would not make a replay any harder.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: what it has always meant, the
* secret header plus a fresh timestamp; an unsigned request without one
* is refused. It keeps a stale capture out, not a fresh forgery by
* someone who saw the secret. A signed request is accepted as above.
* - `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true`: only a signed request is
* accepted; the secret header alone is refused. This is the setting
* that stops a replay. It has its own name so that an operator who set
* the timestamp flag, with a sender that adds a timestamp but cannot
* sign, keeps the behaviour they asked for.
*
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
* guidance.
@@ -132,7 +138,11 @@ function checkTimestampFreshness(
now: number = Date.now(),
): { ok: boolean; reason?: string } {
const raw = request.headers.get("x-deploy-webhook-timestamp");
if (raw === null || raw === "") return { ok: true };
if (raw === null || raw === "") {
return flagSet("DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP")
? { ok: false, reason: "timestamp_missing" }
: { ok: true };
}
const ts = parseTimestamp(raw);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
@@ -141,11 +151,11 @@ function checkTimestampFreshness(
return { ok: true };
}
function strictMode(): boolean {
return (
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1"
);
function flagSet(
name: "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP" | "DEPLOY_WEBHOOK_REQUIRE_SIGNATURE",
): boolean {
const value = process.env[name]?.trim().toLowerCase();
return value === "true" || value === "1";
}
/**
@@ -296,7 +306,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
getEvent()?.setAuth({ auth_method: "webhook_secret" });
annotate({ meta: { deploy_webhook_auth: "signature" } });
} else {
if (strictMode() || !hasValidSecret(request)) {
if (
flagSet("DEPLOY_WEBHOOK_REQUIRE_SIGNATURE") ||
!hasValidSecret(request)
) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
// Freshness AFTER the secret check: an unauthenticated caller learns
@@ -228,6 +228,8 @@ export function documentAiErrorKey(err: unknown): string {
return "aiRuns.errorWorkerUnavailable";
case "aiRuns.timedOut":
return "aiRuns.errorTimedOut";
case "aiRuns.readerBusy":
return "aiRuns.errorReaderBusy";
case "consent.ai.required":
return "documents.assist.errorConsent";
case "ai.provider.none":
@@ -117,6 +117,8 @@ export function extractErrorMessage(
return t("aiRuns.errorWorkerUnavailable");
case "aiRuns.timedOut":
return t("aiRuns.errorTimedOut");
case "aiRuns.readerBusy":
return t("aiRuns.errorReaderBusy");
default:
break;
}
+13
View File
@@ -15,6 +15,18 @@ async function registerOfflineGeoNoticeAtBoot(): Promise<void> {
}
}
/**
* Which connections may report a client address (TRUSTED_PROXY_CIDRS,
* TRUST_PROXY_HOPS), said once, with a bad value named.
*/
async function logProxyTrustAtBootSafely(): Promise<void> {
try {
(await import("@/lib/http/proxy-trust")).logProxyTrustAtBoot();
} catch {
// A log line must never break boot.
}
}
export async function register() {
// Only start the worker on the Node.js server runtime (not Edge, not build)
if (process.env.NEXT_RUNTIME === "nodejs") {
@@ -54,6 +66,7 @@ export async function register() {
}
await registerOfflineGeoNoticeAtBoot();
await logProxyTrustAtBootSafely();
// Does the configured encryption key open this database? A process whose
// key does not refuses every API request with one clear 503 and starts
@@ -114,6 +114,65 @@ describe("apiHandler and a state change from another site", () => {
);
});
it("refuses a browser-attached Basic or Negotiate header riding a session cookie", async () => {
// The browser adds these on its own once a proxy or the host asked for
// them, and the caller is resolved from the cookie first: the header is
// not what the request is authenticated by.
for (const authorization of ["Basic dXNlcjpwYXNz", "Negotiate YIIGhg"]) {
const response = await post({
"content-type": "application/json",
authorization,
"sec-fetch-site": "same-site",
origin: "https://evil.example.com",
cookie: "healthlog_session=s",
});
expect(response.status).toBe(403);
}
expect(handler).not.toHaveBeenCalled();
});
it("checks a Bearer request that also carries a session cookie as the cookie request it resolves to", async () => {
for (const cookie of [
"healthlog_session=s",
"theme=dark; __Host-healthlog_session=s",
]) {
const response = await post({
"content-type": "application/json",
authorization: "Bearer hlk_x",
"sec-fetch-site": "cross-site",
cookie,
});
expect(response.status).toBe(403);
}
expect(handler).not.toHaveBeenCalled();
// From the app's own page the same pair passes.
const own = await post({
"content-type": "application/json",
authorization: "Bearer hlk_x",
"sec-fetch-site": "same-origin",
cookie: "healthlog_session=s",
});
expect(own.status).toBe(200);
});
it("lets the share-link passphrase POST through from a no-referrer page", async () => {
// The shared view is served with `Referrer-Policy: no-referrer`; a
// browser without Sec-Fetch-Site then sends `Origin: null`. The token in
// the path and the passphrase in the body are the credential.
const response = await route(
new NextRequest(`${ORIGIN}/api/c/hls_token/unlock`, {
method: "POST",
headers: {
host: "health.example.com",
"content-type": "application/json",
origin: "null",
},
body: '{"value":1}',
}),
);
expect(response.status).toBe(200);
});
it("leaves reads alone", async () => {
const response = await route(
new NextRequest(`${ORIGIN}/api/measurements`, {
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { getClientIp } from "@/lib/api-response";
/**
* A typo in `TRUST_PROXY_HOPS` used to make every request that asked for the
* client address throw, and `apiHandler` asks for it on every request to
* record it: every route answered 500, the health probe included, so the
* container looked dead for a configuration slip. It now resolves no address
* and the boot log names the value.
*/
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("apiHandler with an invalid TRUST_PROXY_HOPS", () => {
it("answers the route instead of 500, with no client address", async () => {
vi.stubEnv("TRUST_PROXY_HOPS", "two");
vi.spyOn(console, "warn").mockImplementation(() => {});
const seen: Array<string | null> = [];
const route = apiHandler(async (request: NextRequest) => {
seen.push(getClientIp(request));
return Response.json({ data: { status: "ok" }, error: null });
});
const response = await route(
new NextRequest("https://health.example.com/api/health", {
headers: { "x-forwarded-for": "203.0.113.4" },
}),
);
expect(response.status).toBe(200);
expect(seen).toEqual([null]);
});
});
+114 -4
View File
@@ -10,16 +10,22 @@ import {
import {
getClientIp,
getClientIpOrTrustWarning,
_resetInvalidHopsWarningForTests,
_resetPublicPeerForwardingWarningForTests,
_resetTrustViolationWarningForTests,
_resetUnreadProxyHeadersWarningForTests,
} from "../api-response";
import { describeProxyTrust } from "../http/proxy-trust";
const ORIGINAL_HOPS = process.env.TRUST_PROXY_HOPS;
const ORIGINAL_CF_FLAG = process.env.TRUST_CF_CONNECTING_IP;
const ORIGINAL_CIDRS = process.env.TRUSTED_PROXY_CIDRS;
beforeEach(() => {
delete process.env.TRUST_PROXY_HOPS;
delete process.env.TRUST_CF_CONNECTING_IP;
delete process.env.TRUSTED_PROXY_CIDRS;
_resetPublicPeerForwardingWarningForTests();
_resetTrustViolationWarningForTests();
_resetUnreadProxyHeadersWarningForTests();
});
@@ -29,6 +35,8 @@ afterEach(() => {
else process.env.TRUST_PROXY_HOPS = ORIGINAL_HOPS;
if (ORIGINAL_CF_FLAG === undefined) delete process.env.TRUST_CF_CONNECTING_IP;
else process.env.TRUST_CF_CONNECTING_IP = ORIGINAL_CF_FLAG;
if (ORIGINAL_CIDRS === undefined) delete process.env.TRUSTED_PROXY_CIDRS;
else process.env.TRUSTED_PROXY_CIDRS = ORIGINAL_CIDRS;
});
function makeRequest(headers: Record<string, string>): Request {
@@ -64,11 +72,23 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
expect(ip).toBeNull();
});
it("throws at boot when TRUST_PROXY_HOPS is unparseable (fixes review L-1)", () => {
it("resolves no address for an unparseable TRUST_PROXY_HOPS instead of failing the request", () => {
// It used to throw on every request that asked for the client address,
// so every route answered 500, the health probe included. Reading the
// headers anyway would silently trust a chain the operator never
// described (review L-1), so: no address, a trust violation, one warning.
process.env.TRUST_PROXY_HOPS = "garbage";
expect(() =>
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })),
).toThrow(/TRUST_PROXY_HOPS/);
_resetInvalidHopsWarningForTests();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
onTestFinished(() => warn.mockRestore());
const request = makeRequest({ "x-forwarded-for": "5.6.7.8" });
expect(getClientIpOrTrustWarning(request)).toEqual({
ip: null,
trustViolation: true,
});
expect(getClientIp(request)).toBeNull();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]![0]).toMatch(/TRUST_PROXY_HOPS="garbage"/);
});
it.each(["", " "])(
@@ -480,3 +500,93 @@ describe("getClientIp against the connection's own peer", () => {
).toBeNull();
});
});
/**
* Behind Docker Desktop, OrbStack or `docker-proxy`, every connection to a
* published port arrives from the gateway, a private address, so the
* private-range default believed a direct caller's forwarding headers again.
* `TRUSTED_PROXY_CIDRS` names the proxies; no other peer may forward.
*/
describe("getClientIp with TRUSTED_PROXY_CIDRS", () => {
const stamped = (peer: string, headers: Record<string, string> = {}) =>
makeRequest({ ...headers, "x-healthlog-peer-address": peer });
it("reads forwarding headers only from a peer inside the list", () => {
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, 127.0.0.1";
expect(
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
).toBe("203.0.113.5");
expect(
getClientIp(stamped("127.0.0.1", { "x-real-ip": "203.0.113.6" })),
).toBe("203.0.113.6");
});
it("uses the gateway's own address for a direct caller from a private network outside the list", () => {
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16";
vi.spyOn(console, "warn").mockImplementation(() => {});
const forged = ["203.0.113.1", "203.0.113.2"].map((ip) =>
getClientIp(
stamped("192.168.65.1", { "x-forwarded-for": ip, "x-real-ip": ip }),
),
);
expect(forged).toEqual(["192.168.65.1", "192.168.65.1"]);
vi.restoreAllMocks();
});
it("believes a proxy on a public address once it is listed", () => {
process.env.TRUSTED_PROXY_CIDRS = "198.51.100.7/32";
expect(
getClientIp(
stamped("198.51.100.7", { "x-forwarded-for": "203.0.113.9" }),
),
).toBe("203.0.113.9");
});
it("takes IPv6 ranges and bare addresses", () => {
process.env.TRUSTED_PROXY_CIDRS = "fd00::/8,2001:db8::1";
expect(
getClientIp(stamped("fd12::3", { "x-forwarded-for": "203.0.113.1" })),
).toBe("203.0.113.1");
expect(
getClientIp(stamped("2001:db8::1", { "x-forwarded-for": "203.0.113.2" })),
).toBe("203.0.113.2");
expect(
getClientIp(stamped("2001:db8::2", { "x-forwarded-for": "203.0.113.3" })),
).toBe("2001:db8::2");
});
it("lets no peer forward when an entry is not a CIDR", () => {
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, not-a-range";
vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
).toBe("172.18.0.5");
vi.restoreAllMocks();
});
});
describe("the boot line naming the proxy rule", () => {
it("names the private-range default, and what it lets in behind a port forwarder", () => {
expect(describeProxyTrust()).toMatchObject({
level: "info",
message: expect.stringMatching(/TRUSTED_PROXY_CIDRS unset.*docker-proxy/),
});
});
it("names the list when one is set", () => {
process.env.TRUSTED_PROXY_CIDRS = "127.0.0.1/32";
expect(describeProxyTrust()).toMatchObject({
level: "info",
message: expect.stringContaining("only connections from 127.0.0.1/32"),
});
});
it("names a bad TRUST_PROXY_HOPS and a bad CIDR as errors", () => {
process.env.TRUST_PROXY_HOPS = "one";
process.env.TRUSTED_PROXY_CIDRS = "10.0.0.0/33";
const line = describeProxyTrust();
expect(line.level).toBe("error");
expect(line.message).toContain('TRUST_PROXY_HOPS="one"');
expect(line.message).toContain('"10.0.0.0/33"');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Buffer } from "node:buffer";
const u16 = (n: number) => [n & 0xff, (n >> 8) & 0xff];
/**
* A GIF with the given logical screen and frames. Each frame carries one
* byte of image data, which is enough for a header walk and for a decoder.
*/
export function gif(
screen: { width: number; height: number; colourTable?: boolean },
frames: Array<{
width: number;
height: number;
left?: number;
top?: number;
extension?: boolean;
}>,
): Buffer {
const out: number[] = [...Buffer.from("GIF89a", "latin1")];
out.push(...u16(screen.width), ...u16(screen.height));
out.push(screen.colourTable ? 0x80 : 0x00, 0, 0);
if (screen.colourTable) out.push(0, 0, 0, 255, 255, 255);
for (const frame of frames) {
if (frame.extension) {
// A graphic control extension.
out.push(0x21, 0xf9, 4, 0, 0, 0, 0, 0);
}
out.push(0x2c, ...u16(frame.left ?? 0), ...u16(frame.top ?? 0));
out.push(...u16(frame.width), ...u16(frame.height), 0x80);
out.push(0, 0, 0, 255, 255, 255); // local colour table, two entries
out.push(2, 2, 0x44, 0x01, 0); // LZW size, one sub-block, terminator
}
out.push(0x3b);
return Buffer.from(out);
}
+53 -3
View File
@@ -4,6 +4,7 @@ import { createCanvas } from "@napi-rs/canvas";
import { describe, expect, it } from "vitest";
import { readImageDimensions } from "../image-dimensions";
import { gif } from "./gif-fixture";
/**
* The header walk both the avatar upload and the document thumbnail trust to
@@ -36,9 +37,58 @@ describe("readImageDimensions", () => {
);
});
it("reads a GIF's logical screen", () => {
const gif = Buffer.from("GIF89a\x25\x00\x17\x00\x00\x00\x00", "latin1");
expect(readImageDimensions(gif, "image/gif")).toEqual(SIZE);
it("reads a GIF's logical screen when its frames fit inside it", () => {
expect(
readImageDimensions(
gif({ width: 37, height: 23 }, [{ width: 37, height: 23 }]),
"image/gif",
),
).toEqual(SIZE);
});
it("sizes a GIF by its largest frame, not only its logical screen", () => {
// The decoder allocates for the frame: 10 x 10 on the screen, 20000 x
// 20000 in the descriptor was a 1.6 GB decode behind a 100-pixel check.
expect(
readImageDimensions(
gif({ width: 10, height: 10 }, [{ width: 20_000, height: 20_000 }]),
"image/gif",
),
).toEqual({ width: 20_000, height: 20_000 });
// A later frame, an offset, a global colour table and an extension in
// between are all walked.
expect(
readImageDimensions(
gif({ width: 10, height: 10, colourTable: true }, [
{ width: 4, height: 4 },
{ left: 60_000, top: 5, width: 5_000, height: 3, extension: true },
]),
"image/gif",
),
).toEqual({ width: 65_000, height: 10 });
});
it("refuses a GIF it cannot walk to the end of a frame descriptor", () => {
const whole = gif({ width: 10, height: 10 }, [{ width: 10, height: 10 }]);
// Logical screen only: no frame, no size.
expect(readImageDimensions(whole.subarray(0, 13), "image/gif")).toBeNull();
// Cut inside the image descriptor.
expect(readImageDimensions(whole.subarray(0, 18), "image/gif")).toBeNull();
// A block no decoder knows.
const unknown = Buffer.from(whole);
unknown[13] = 0x99;
expect(readImageDimensions(unknown, "image/gif")).toBeNull();
// A zero-sized frame.
expect(
readImageDimensions(
gif({ width: 10, height: 10 }, [{ width: 0, height: 10 }]),
"image/gif",
),
).toBeNull();
// Not a GIF signature a decoder accepts.
const signature = Buffer.from(whole);
signature.write("GIF80a", 0, "latin1");
expect(readImageDimensions(signature, "image/gif")).toBeNull();
});
it("skips fill bytes, RSTn and TEM before the frame header", () => {
+54 -29
View File
@@ -7,7 +7,11 @@ import {
peerAddressStampInstalled,
readPeerAddress,
} from "@/lib/http/peer-address";
import { isPublicIp } from "@/lib/validations/notifications";
import {
parseTrustProxyHops,
parseTrustedProxyCidrs,
peerMayForward,
} from "@/lib/http/proxy-trust";
export function apiSuccess<T>(data: T, status = 200) {
return NextResponse.json({ data, error: null }, { status });
@@ -508,13 +512,16 @@ export async function safeJson<T = unknown>(
* observed when the request arrived.
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
*
* Forwarding headers are read only from a connection whose peer could be a
* proxy: loopback, a private or unique-local network, CGNAT (Tailscale). A
* connection from a public address reached the server directly, so whatever
* forwarding headers it sends are its own, and its own address is the client
* IP. The peer comes from `@/lib/http/peer-address`. Without that check, the
* default of one trusted hop let any direct caller name a fresh address per
* request and step around every per-IP limit.
* Forwarding headers are read only from a connection whose peer may be a
* proxy (`peerMayForward` in `@/lib/http/proxy-trust`): one inside
* `TRUSTED_PROXY_CIDRS` when that is set, otherwise any loopback, private,
* unique-local or CGNAT (Tailscale) address. Any other connection reached the
* server directly, so whatever forwarding headers it sends are its own, and
* its own address is the client IP. The peer comes from
* `@/lib/http/peer-address`. Without that check, the default of one trusted
* hop let any direct caller name a fresh address per request and step around
* every per-IP limit. An invalid `TRUST_PROXY_HOPS` resolves no address
* (and is named at boot) instead of failing the request.
*
* Cloudflare opt-in (`TRUST_CF_CONNECTING_IP=1`): when the env flag is
* set, the helper prefers the `cf-connecting-ip` header before walking
@@ -561,22 +568,24 @@ function readCfConnectingIp(request: Request): string | null {
return looksLikeIp(trimmed) ? trimmed : null;
}
function parseTrustProxyHops(raw: string | undefined): number {
// Empty is unset. The shipped docker-compose.yml passes
// `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"`, which is an empty string for
// every operator who never set it; reading that as invalid threw on every
// request that asked for the client IP.
const trimmed = raw?.trim() ?? "";
if (trimmed === "") return 1;
if (!/^\d+$/.test(trimmed)) {
// Reject explicitly-invalid values so an operator typo doesn't silently
// switch a real-proxy deployment to "no XFF trust" mode (review
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
throw new Error(
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
);
}
return parseInt(trimmed, 10);
/**
* Once per process: `TRUST_PROXY_HOPS` is not a number. Every request that
* asks for the client address used to throw on it, so every route answered
* 500, `/api/health` included. It resolves no address instead, and says so.
*/
let invalidHopsWarned = false;
function warnInvalidHopsOnce(raw: string | undefined): void {
if (invalidHopsWarned) return;
invalidHopsWarned = true;
console.warn(
`[getClientIp] TRUST_PROXY_HOPS=${JSON.stringify(raw)} is not a non-negative integer; no client address is read from forwarding headers until it is fixed, and every anonymous caller shares one rate-limit bucket.`,
);
}
/** Test-only reset for {@link warnInvalidHopsOnce}. */
export function _resetInvalidHopsWarningForTests(): void {
invalidHopsWarned = false;
}
/**
@@ -646,12 +655,23 @@ let publicPeerForwardingWarned = false;
function warnPublicPeerForwardingOnce(): void {
if (publicPeerForwardingWarned) return;
publicPeerForwardingWarned = true;
const listed =
parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS).kind !== "unset";
console.warn(
"[getClientIp] a connection from a public address sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
"If HealthLog is meant to be reached only through a reverse proxy, the published port is open beyond it.",
listed
? "[getClientIp] a connection from outside TRUSTED_PROXY_CIDRS sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
"If it was your proxy, add its address to TRUSTED_PROXY_CIDRS; otherwise the published port is reachable beyond the proxy."
: "[getClientIp] a connection from a public address sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
"If HealthLog is meant to be reached only through a reverse proxy, the published port is open beyond it. " +
"If the proxy connects from a public address, set TRUSTED_PROXY_CIDRS to it.",
);
}
/** Test-only reset for {@link warnPublicPeerForwardingOnce}. */
export function _resetPublicPeerForwardingWarningForTests(): void {
publicPeerForwardingWarned = false;
}
/**
* Tagged return shape so a caller can apply a tighter universal
* rate-limit when the trust chain is misconfigured. F-6 (mobile security
@@ -681,6 +701,10 @@ export function getClientIpOrTrustWarning(request: Request): {
if (cfIp) return { ip: cfIp, trustViolation: false };
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
if (hops === null) {
warnInvalidHopsOnce(process.env.TRUST_PROXY_HOPS);
return { ip: null, trustViolation: true };
}
// The connection's own peer, stamped by the server for every request it
// receives. `undefined` only for a request the server did not receive (a
@@ -693,9 +717,10 @@ export function getClientIpOrTrustWarning(request: Request): {
if (peer !== undefined) {
// No proxy declared: the peer is the client.
if (hops === 0) return { ip: peer, trustViolation: false };
// A peer on a public address reached the server directly; its
// forwarding headers are its own choice, not a proxy's report.
if (isPublicIp(peer)) {
// A peer that is not a proxy (outside TRUSTED_PROXY_CIDRS when it is
// set, on a public address when it is not) reached the server directly;
// its forwarding headers are its own choice, not a proxy's report.
if (!peerMayForward(peer)) {
if (
request.headers.has("x-real-ip") ||
request.headers.get("x-forwarded-for")?.trim() !== peer
@@ -124,6 +124,17 @@ describe("isCookielessNativeCaller (M-3 refresh-token issuance gate)", () => {
).toBe(false);
});
it("false when the session cookie arrives under its __Host- name", () => {
expect(
isCookielessNativeCaller(
h({
"x-client-type": "native",
cookie: "foo=bar; __Host-healthlog_session=abc123",
}),
),
).toBe(false);
});
it("true for a native caller whose cookie header lacks the session cookie", () => {
expect(
isCookielessNativeCaller(
+119 -10
View File
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const cookieState = vi.hoisted(() => ({
sessionId: undefined as string | undefined,
/** The session cookie under the `__Host-` name, when the browser holds one. */
hostSessionId: undefined as string | undefined,
delete: vi.fn(),
set: vi.fn(),
}));
@@ -71,10 +73,15 @@ vi.mock("@/lib/auth/hmac", () => ({
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => ({
get: (name: string) =>
name === "healthlog_session" && cookieState.sessionId
? { value: cookieState.sessionId }
: undefined,
get: (name: string) => {
if (name === "healthlog_session" && cookieState.sessionId) {
return { value: cookieState.sessionId };
}
if (name === "__Host-healthlog_session" && cookieState.hostSessionId) {
return { value: cookieState.hostSessionId };
}
return undefined;
},
set: cookieState.set,
delete: cookieState.delete,
})),
@@ -98,6 +105,16 @@ const UNKNOWN_SECRET = `hls_${"c".repeat(64)}`;
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/** Both session cookie names were expired, the `__Host-` one as `Secure`. */
function expectSessionCookiesExpired() {
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expect(cookieState.set).toHaveBeenCalledWith(
"__Host-healthlog_session",
"",
expect.objectContaining({ maxAge: 0, path: "/", secure: true }),
);
}
function seed(row: Partial<SessionRow> & { id: string }): SessionRow {
const full: SessionRow = {
userId: "user-1",
@@ -115,6 +132,7 @@ beforeEach(() => {
vi.clearAllMocks();
store.sessions = [];
cookieState.sessionId = "sess-expired";
cookieState.hostSessionId = undefined;
// `clearAllMocks` drops recorded calls but keeps implementations, so a
// rejection staged by one test would otherwise leak into the next. Re-arm
// the write mocks with a benign default each time.
@@ -203,6 +221,97 @@ describe("getSession — cookie resolution", () => {
});
});
describe("the session cookie's name on a Secure install", () => {
// A cookie named `healthlog_session` can be planted by any host under the
// same registrable domain; one named `__Host-…` only by this host, and only
// when Secure, on Path=/, without Domain.
beforeEach(() => {
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("issues a new session as __Host-healthlog_session, Secure, on /, without Domain", async () => {
cookieState.sessionId = undefined;
await createSession("user-1", false);
const issued = cookieState.set.mock.calls.find(
(c) => c[0] === "__Host-healthlog_session",
);
expect(issued?.[1]).toMatch(/^hls_[0-9a-f]{64}$/);
expect(issued?.[2]).toMatchObject({ secure: true, path: "/" });
expect(issued?.[2]).not.toHaveProperty("domain");
expect(
cookieState.set.mock.calls.some((c) => c[0] === "healthlog_session"),
).toBe(false);
});
it("keeps a session found under the old name, and re-issues it under the new one", async () => {
seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` });
cookieState.sessionId = SECRET;
const result = await getSession();
expect(result?.session.id).toBe("sess_cuid_1");
expect(cookieState.set).toHaveBeenCalledWith(
"__Host-healthlog_session",
SECRET,
expect.objectContaining({ secure: true, path: "/" }),
);
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
});
it("answers from the __Host- cookie when a planted old-name cookie rides along", async () => {
seed({ id: "sess_mine", tokenHash: `hash:${SECRET}` });
seed({
id: "sess_planted",
userId: "attacker",
tokenHash: `hash:${FORGED_SECRET}`,
});
cookieState.hostSessionId = SECRET;
cookieState.sessionId = FORGED_SECRET;
const result = await getSession();
expect(result?.session.id).toBe("sess_mine");
});
it("logs out both names", async () => {
seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` });
cookieState.hostSessionId = SECRET;
cookieState.sessionId = undefined;
await destroySession();
expect(prisma.session.delete).toHaveBeenCalledWith({
where: { id: "sess_cuid_1" },
});
expectSessionCookiesExpired();
});
});
describe("the session cookie's name on a plain-HTTP install", () => {
beforeEach(() => {
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("keeps the old name, which a browser accepts without Secure", async () => {
cookieState.sessionId = undefined;
await createSession("user-1", false);
expect(
cookieState.set.mock.calls.some(
(c) => c[0] === "healthlog_session" && c[2]?.secure === false,
),
).toBe(true);
expect(
cookieState.set.mock.calls.some(
(c) => c[0] === "__Host-healthlog_session",
),
).toBe(false);
});
});
describe("getSession — sliding expiry", () => {
it("extends a modern session and re-emits the same secret", async () => {
seed({
@@ -257,7 +366,7 @@ describe("getSession", () => {
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
where: { id: "sess-expired" },
});
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expectSessionCookiesExpired();
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
});
});
@@ -385,7 +494,7 @@ describe("a session lookup that fails is not a missing session", () => {
cookieState.sessionId = SECRET;
failLookup();
await expect(destroySession()).resolves.toBeUndefined();
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expectSessionCookiesExpired();
});
});
@@ -401,7 +510,7 @@ describe("destroySession", () => {
expect(prisma.session.delete).toHaveBeenCalledWith({
where: { id: "sess_cuid_1" },
});
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expectSessionCookiesExpired();
});
it("logs a legacy cookie out too", async () => {
@@ -427,7 +536,7 @@ describe("destroySession", () => {
expect(prisma.session.delete).toHaveBeenCalledWith({
where: { id: "sess-gone" },
});
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expectSessionCookiesExpired();
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
});
@@ -441,7 +550,7 @@ describe("destroySession", () => {
// A non-P2025 delete failure is recorded on the wide event, not thrown,
// so logout never leaves the client authenticated with the cookie intact.
await expect(destroySession()).resolves.toBeUndefined();
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expectSessionCookiesExpired();
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
});
});
+3 -11
View File
@@ -16,6 +16,7 @@
* - `X-Client-Type: native` always forces native policy.
* - `X-Client-Type: web` always forces web policy.
*/
import { cookieHeaderCarriesSession } from "./session-cookie";
export type ClientPolicy = "web" | "native";
@@ -95,13 +96,6 @@ export function resolveTokenPolicy(headers: Headers): TokenPolicyDecision {
};
}
/**
* Name of the browser session cookie (mirrors `SESSION_COOKIE` in
* `src/lib/auth/session.ts`). A request that round-trips this cookie is a
* browser, not a genuine cookie-less native caller.
*/
const SESSION_COOKIE_NAME = "healthlog_session";
/**
* True only for a genuinely cookie-less native caller — the gate for
* *issuing* a 60-day refresh token (M-3 hardening).
@@ -110,7 +104,7 @@ const SESSION_COOKIE_NAME = "healthlog_session";
* a browser context (DOM/XSS-reachable), even when that browser spoofs
* `X-Client-Type: native`. Two browser tells suppress issuance:
* - a `Mozilla/`-bearing User-Agent (every real browser sends one), and
* - an inbound `healthlog_session` cookie (a browser round-trips its
* - an inbound session cookie, under either of its names (a browser round-trips its
* session cookie; the native client authenticates by Bearer and never
* holds one).
*
@@ -127,9 +121,7 @@ export function isCookielessNativeCaller(headers: Headers): boolean {
// Belt-and-braces: a request that presents the session cookie is a
// browser round-trip regardless of UA. Native callers never hold one.
const cookie = headers.get("cookie") ?? "";
if (cookie.split(/;\s*/).some((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`)))
return false;
if (cookieHeaderCarriesSession(headers.get("cookie"))) return false;
return true;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* The browser session cookie's name, and the one way to read and clear it.
*
* A cookie named `healthlog_session` can be set by any host under the same
* registrable domain: a page on `other.example.com` may write one with
* `Domain=example.com`, and the browser sends it to `health.example.com`
* alongside, or instead of, the server's own. That lets a sibling subdomain
* plant a session of its choosing in a visitor's browser. A cookie whose name
* starts with `__Host-` is accepted by the browser only when it is `Secure`,
* has `Path=/` and carries no `Domain`, so only this exact host can have set
* it.
*
* So wherever the session cookie is `Secure` (`shouldEmitSecureCookie()`),
* it is issued as `__Host-healthlog_session`. A plain-HTTP install cannot
* hold a `__Host-` cookie and keeps the old name.
*
* The transition signs nobody out: the old name is still read, after the new
* one, and `getSession()` re-issues a session found under the old name under
* the new one and expires the old cookie. A browser that holds the new
* cookie is never answered from the old one, so once a person has been seen
* once after the upgrade, a planted old-name cookie no longer reaches them.
*
* Nothing here imports the database, so the proxy can use it.
*/
import { shouldEmitSecureCookie } from "./secure-cookie";
/** The name every release before this one used, and plain-HTTP installs keep. */
export const LEGACY_SESSION_COOKIE = "healthlog_session";
/** The name a `Secure` session cookie is issued under. */
export const HOST_SESSION_COOKIE = "__Host-healthlog_session";
/** The names a session cookie is read under, in the order they are tried. */
export const SESSION_COOKIE_NAMES = [
HOST_SESSION_COOKIE,
LEGACY_SESSION_COOKIE,
] as const;
export type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
/** The name a session cookie issued now is set under. */
export function sessionCookieName(): SessionCookieName {
return shouldEmitSecureCookie() ? HOST_SESSION_COOKIE : LEGACY_SESSION_COOKIE;
}
interface CookieReader {
get(name: string): { value: string } | undefined;
}
interface CookieStore extends CookieReader, CookieWriter {
delete(name: string): unknown;
}
interface CookieWriter {
set(
name: string,
value: string,
options: {
httpOnly: boolean;
secure: boolean;
sameSite: "lax";
maxAge: number;
path: string;
},
): unknown;
}
/** The session cookie a request carries, under whichever name it came. */
export function readSessionCookie(
jar: CookieReader,
): { name: SessionCookieName; value: string } | null {
for (const name of SESSION_COOKIE_NAMES) {
const value = jar.get(name)?.value;
if (value) return { name, value };
}
return null;
}
/** Whether a raw `Cookie` header carries a session cookie under either name. */
export function cookieHeaderCarriesSession(header: string | null): boolean {
if (!header) return false;
return header
.split(/;\s*/)
.some((pair) =>
SESSION_COOKIE_NAMES.some((name) => pair.startsWith(`${name}=`)),
);
}
/** The attributes the session cookie is issued with. */
export function sessionCookieOptions(maxAgeSeconds: number) {
return {
httpOnly: true,
// A `__Host-` cookie is refused by the browser without `Secure`; the
// name and the flag come from the same switch.
secure: shouldEmitSecureCookie(),
// OAuth callbacks (e.g. Withings) arrive via top-level cross-site
// redirect. Lax keeps CSRF protection for unsafe methods while allowing
// this flow.
sameSite: "lax" as const,
maxAge: maxAgeSeconds,
path: "/",
};
}
/**
* Set the session cookie under the name this install issues, and expire a
* cookie under the old name the request still carried, so a browser holds
* one session cookie, not two.
*/
export function issueSessionCookie(
store: CookieStore,
value: string,
maxAgeSeconds: number,
): void {
const name = sessionCookieName();
store.set(name, value, sessionCookieOptions(maxAgeSeconds));
if (name !== LEGACY_SESSION_COOKIE && store.get(LEGACY_SESSION_COOKIE)) {
store.delete(LEGACY_SESSION_COOKIE);
}
}
/**
* Expire the session cookie under both names. The `__Host-` one is
* overwritten with `Secure` and `Path=/`, without which the browser ignores
* the overwrite and the cookie stays; the old name is deleted as it always
* was.
*/
export function expireSessionCookies(
jar: CookieWriter & { delete(name: string): unknown },
): void {
jar.set(HOST_SESSION_COOKIE, "", {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 0,
path: "/",
});
jar.delete(LEGACY_SESSION_COOKIE);
}
+25 -28
View File
@@ -11,14 +11,15 @@ import { SessionStoreUnavailableError } from "@/lib/api-errors";
import { credentialRevocations } from "@/lib/auth/credential-revocations";
import { locales, type Locale } from "@/lib/i18n/config";
import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie";
import {
expireSessionCookies,
issueSessionCookie,
readSessionCookie,
sessionCookieName,
} from "@/lib/auth/session-cookie";
const SESSION_COOKIE = "healthlog_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/** Public name of the browser session cookie, for routes that read it off a
* `NextRequest` directly (rather than through `getSession()`'s `cookies()`). */
export const SESSION_COOKIE_NAME = SESSION_COOKIE;
/**
* Marks a cookie value as a v1.30.32 session secret. Doubles as the branch
* between the two resolution paths below, so a cookie is only ever looked up
@@ -244,7 +245,7 @@ export async function setOnboardingPendingCookie(
secure: shouldEmitSecureCookie(),
// v1.4.22 — Strict (not Lax) because
// no cross-site redirect flow ever depends on this cookie. The
// sibling `healthlog_session` cookie stays Lax because the
// sibling session cookie stays Lax because the
// Withings OAuth callback arrives via top-level cross-site
// redirect; the onboarding hint has no equivalent flow.
sameSite: "strict",
@@ -291,15 +292,7 @@ export async function createSession(
});
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, secret, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
// OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect.
// Lax keeps CSRF protection for unsafe methods while allowing this flow.
sameSite: "lax",
maxAge: SESSION_MAX_AGE_MS / 1000,
path: "/",
});
issueSessionCookie(cookieStore, secret, SESSION_MAX_AGE_MS / 1000);
// Anchor the onboarding cookie alongside the session cookie so a
// future auth surface added without remembering the helper can never
@@ -329,8 +322,9 @@ export async function getSession(): Promise<{
}
const cookieStore = await cookies();
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
if (!cookieValue) return null;
const carried = readSessionCookie(cookieStore);
if (!carried) return null;
const cookieValue = carried.value;
const session = await findSessionByCookie(cookieValue);
@@ -340,7 +334,7 @@ export async function getSession(): Promise<{
.deleteMany({ where: { id: session.id } })
.catch(() => {});
}
cookieStore.delete(SESSION_COOKIE);
expireSessionCookies(cookieStore);
cookieStore.delete(ONBOARDING_COOKIE);
return null;
}
@@ -364,13 +358,16 @@ export async function getSession(): Promise<{
where: { id: session.id },
data: { expiresAt: newExpiry },
});
cookieStore.set(SESSION_COOKIE, cookieValue, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: SESSION_MAX_AGE_MS / 1000,
path: "/",
});
issueSessionCookie(cookieStore, cookieValue, SESSION_MAX_AGE_MS / 1000);
} else if (carried.name !== sessionCookieName()) {
// Issued before the `__Host-` name: re-issued under it, so the upgrade
// signs nobody out. A server-component render cannot write cookies; the
// next route request does.
try {
issueSessionCookie(cookieStore, cookieValue, SESSION_MAX_AGE_MS / 1000);
} catch {
// Read-only cookie context.
}
}
// v1.23 — sliding "last seen" stamp for the active-session list. Throttled
@@ -501,7 +498,7 @@ export async function getSession(): Promise<{
*/
export async function getSessionUserLocale(): Promise<string | null> {
const cookieStore = await cookies();
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
const cookieValue = readSessionCookie(cookieStore)?.value;
if (!cookieValue) return null;
// Same two-path resolution as `getSession`, read-only: this runs in the root
@@ -514,7 +511,7 @@ export async function getSessionUserLocale(): Promise<string | null> {
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
const cookieValue = readSessionCookie(cookieStore)?.value;
if (cookieValue) {
// Resolve the cookie to its row before deleting — the cookie is a secret,
// not the primary key, so `delete({ where: { id: cookieValue } })` would
@@ -547,7 +544,7 @@ export async function destroySession(): Promise<void> {
});
}
}
cookieStore.delete(SESSION_COOKIE);
expireSessionCookies(cookieStore);
cookieStore.delete(ONBOARDING_COOKIE);
}
@@ -0,0 +1,54 @@
import { Buffer } from "node:buffer";
import { createCanvas } from "@napi-rs/canvas";
import { describe, expect, it, vi } from "vitest";
import { renderImageThumbnail } from "../image-engine.mjs";
/**
* What runs in the reader's child for an image upload: decode, check the
* decoded size once more, draw onto a fresh JPEG. The header walk in the
* server is a reading of the file; the decoder has the final word, so the
* decoded size is checked before a canvas is drawn from it.
*/
const OPTIONS = { longEdge: 320, quality: 70, maxPixels: 60_000_000 };
function png(width: number, height: number): Buffer {
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#3366cc";
ctx.fillRect(0, 0, width, height);
return canvas.toBuffer("image/png");
}
describe("renderImageThumbnail", () => {
it("draws a decoded image onto a JPEG no longer than the long edge", async () => {
const outcome = await renderImageThumbnail(png(1200, 800), OPTIONS);
expect(outcome).toMatchObject({ ok: true, width: 320, height: 213 });
if (!outcome.ok) return;
expect(Buffer.from(outcome.jpeg).subarray(0, 2).toString("hex")).toBe(
"ffd8",
);
});
it("refuses a decoded image larger than the header said, before drawing it", async () => {
const createCanvasSpy = vi.fn();
const outcome = await renderImageThumbnail(
Buffer.from("anything"),
OPTIONS,
{
loadImage: async () => ({ width: 30_000, height: 30_000 }),
createCanvas: createCanvasSpy,
},
);
expect(outcome).toEqual({ ok: false, reason: "pixel-cap" });
expect(createCanvasSpy).not.toHaveBeenCalled();
});
it("throws what the decoder threw", async () => {
await expect(
renderImageThumbnail(Buffer.from("not an image"), OPTIONS),
).rejects.toThrow();
});
});
@@ -19,11 +19,11 @@ vi.mock("../pdfjs", async (importOriginal) => ({
readPdfText,
}));
import { PdfResourceLimitError } from "../pdfjs";
import { PdfReaderUnavailableError, PdfResourceLimitError } from "../pdfjs";
import { extractPdfText, localExtractText } from "../local-extract";
describe("a PDF past the reader's limits", () => {
it.each(["memory", "time", "exit"] as const)(
it.each(["memory", "time"] as const)(
"answers resource-limit for the %s bound, without the error log a broken file gets",
async (limit) => {
readPdfText.mockRejectedValueOnce(new PdfResourceLimitError(limit));
@@ -42,6 +42,18 @@ describe("a PDF past the reader's limits", () => {
},
);
it.each(["busy", "interrupted"] as const)(
"passes a reader that was %s up to the job instead of recording an outcome",
async (kind) => {
readPdfText.mockRejectedValueOnce(new PdfReaderUnavailableError(kind));
logCaught.mockClear();
await expect(
extractPdfText(Buffer.from("%PDF-1.7 large")),
).rejects.toBeInstanceOf(PdfReaderUnavailableError);
expect(logCaught).not.toHaveBeenCalled();
},
);
it("still answers error for anything else the reader throws", async () => {
const broken = new Error("Invalid PDF structure");
broken.name = "InvalidPDFException";
@@ -1,5 +1,6 @@
import { Buffer } from "node:buffer";
import { readFileSync } from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDeflate } from "node:zlib";
@@ -8,7 +9,16 @@ import { describe, expect, it, vi } from "vitest";
const { annotate } = vi.hoisted(() => ({ annotate: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate }));
import { runPdfChild } from "../pdf-isolation";
import { gif } from "../../__tests__/gif-fixture";
import {
MAX_QUEUED_PER_OWNER,
READER_WAIT_MS,
runOnce,
runPdfChild,
withPdfWork,
type PdfChildRequest,
type PdfChildResult,
} from "../pdf-isolation";
import { readPdfText } from "../pdfjs";
import { rasterizePdf } from "../rasterize-pdf";
import { generateThumbnail } from "../thumbnail";
@@ -187,3 +197,181 @@ describe("PDF reading runs in a bounded child process", () => {
expect(result).toMatchObject({ ok: false, reason: "error" });
});
});
describe("an image upload is decoded in the bounded child", () => {
it("ends the child, not the server, when a decoder allocates past the ceiling", async () => {
// The header walk refuses this file; here it goes to the decoder
// directly, as a decoder surprise the walk did not foresee would.
const bomb = gif({ width: 10, height: 10 }, [
{ width: 20_000, height: 20_000 },
]);
const before = process.memoryUsage().rss;
const result = await runPdfChild(
{
op: "image-thumbnail",
data: bomb,
options: { longEdge: 320, quality: 70, maxPixels: 1e12 },
},
{ timeoutMs: 60_000, memoryMb: 300 },
);
expect(result).toEqual({ ok: false, reason: "limit", limit: "memory" });
expect(process.memoryUsage().rss - before).toBeLessThan(200 * 1024 ** 2);
}, 90_000);
});
describe("a child that ends without an answer", () => {
const dir = mkdtempSync(join(tmpdir(), "reader-child-"));
const script = (name: string, body: string) => {
const path = join(dir, name);
writeFileSync(path, body);
return path;
};
const request = { op: "text", data: Buffer.from("x"), maxPages: 1 } as const;
it("is not a final verdict when nothing this module measured ended it", async () => {
// A kill from outside: the kernel's out-of-memory killer acting for the
// whole container looks like this to the parent.
const killed = script(
"killed-child",
'process.kill(process.pid, "SIGKILL");\n',
);
await expect(
runOnce(killed, request, { timeoutMs: 10_000 }),
).resolves.toEqual({ ok: false, reason: "interrupted" });
});
it("is the document's doing when V8 reached the heap cap the child was given", async () => {
const heap = script(
"heap-child",
'process.stderr.write("FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\\n", () => process.exit(134));\n',
);
await expect(
runOnce(heap, request, { timeoutMs: 10_000 }),
).resolves.toEqual({ ok: false, reason: "limit", limit: "memory" });
});
it("is run once more, and answered interrupted when it ends that way again", async () => {
const interrupted = vi.fn(
async (): Promise<PdfChildResult<PdfChildRequest>> => ({
ok: false,
reason: "interrupted",
}),
);
await expect(
runPdfChild(request, { timeoutMs: 1_000, run: interrupted }),
).resolves.toEqual({ ok: false, reason: "interrupted" });
expect(interrupted).toHaveBeenCalledTimes(2);
const recovers = vi
.fn<() => Promise<PdfChildResult<PdfChildRequest>>>()
.mockResolvedValueOnce({ ok: false, reason: "interrupted" })
.mockResolvedValueOnce({ ok: true, value: "text" });
await expect(
runPdfChild(request, { timeoutMs: 1_000, run: recovers }),
).resolves.toEqual({ ok: true, value: "text" });
expect(recovers).toHaveBeenCalledTimes(2);
});
});
describe("the reader's queue", () => {
it("answers one account's work past its waiting cap busy, at once", async () => {
const request = {
op: "text",
data: Buffer.from("x"),
maxPages: 1,
} as const;
let open: () => void = () => {};
const gate = new Promise<void>((resolve) => (open = resolve));
const run = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => {
await gate;
return { ok: true, value: "text" };
});
const asA = () =>
withPdfWork({ owner: "account-a", lane: "interactive" }, () =>
runPdfChild(request, { timeoutMs: 1_000, run }),
);
const running = asA();
const waiting = Array.from({ length: MAX_QUEUED_PER_OWNER }, asA);
await expect(asA()).resolves.toMatchObject({ ok: false, reason: "busy" });
open();
await expect(running).resolves.toMatchObject({ ok: true });
for (const item of waiting) {
await expect(item).resolves.toMatchObject({ ok: true });
}
});
});
describe("the caller's clock while it waits for the reader", () => {
const request = { op: "text", data: Buffer.from("x"), maxPages: 1 } as const;
it("moves by the wait plus the read's allowance when the slot is granted, then gives back what the read did not use", async () => {
let open: () => void = () => {};
const gate = new Promise<void>((resolve) => (open = resolve));
const holder = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => {
await gate;
return { ok: true, value: "first" };
});
const quick = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => ({
ok: true,
value: "second",
}));
const moves: number[] = [];
const first = withPdfWork({ owner: "a", lane: "interactive" }, () =>
runPdfChild(request, { timeoutMs: 1_000, run: holder }),
);
const second = withPdfWork(
{
owner: "b",
lane: "interactive",
extendDeadline: async (ms) => {
moves.push(ms);
},
},
() => runPdfChild(request, { timeoutMs: 1_000, run: quick }),
);
await new Promise((resolve) => setTimeout(resolve, 150));
open();
await expect(first).resolves.toMatchObject({ ok: true });
await expect(second).resolves.toMatchObject({ ok: true, value: "second" });
// Granted after about 150 ms of waiting, with two attempts' allowance.
expect(moves).toHaveLength(2);
expect(moves[0]!).toBeGreaterThanOrEqual(2_000 + 140);
expect(moves[0]!).toBeLessThan(2_000 + 1_000);
// The quick read gives back nearly all of its allowance.
expect(moves[1]!).toBeLessThan(0);
expect(moves[1]!).toBeGreaterThan(-2_000 - 1);
// Net: the wait and the read's own time, nothing more.
expect(moves[0]! + moves[1]!).toBeGreaterThanOrEqual(140);
expect(moves[0]! + moves[1]!).toBeLessThan(1_000);
});
it("reads on when moving the clock fails", async () => {
const run = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => ({
ok: true,
value: "text",
}));
await expect(
withPdfWork(
{
owner: "c",
lane: "interactive",
extendDeadline: async () => {
throw new Error("database gone");
},
},
() => runPdfChild(request, { timeoutMs: 1_000, run }),
),
).resolves.toEqual({ ok: true, value: "text" });
});
it("waits no longer for the reader than a run's margin can absorb before the slot moves its deadline", async () => {
// The deadline moves when the slot is granted; until then the run's
// armed margin is all that stands between a waiting read and the
// reaper.
const { AI_RUN_MARGIN_MS } = await import("../ai-runs/types");
expect(READER_WAIT_MS.interactive).toBeLessThanOrEqual(AI_RUN_MARGIN_MS);
});
});
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createReaderQueue, type ReaderTicket } from "../reader-queue";
/**
* The document reader has one slot. It used to go to whoever asked first,
* and a waiting item waited without end: a few heavy PDFs from one account
* held every other account's lab scan behind them. These pin the order the
* slot is handed out in, the cap on what one account may queue, and the
* deadline a waiting item gets.
*/
const WAIT = { interactive: 1_000, background: 5_000 };
function queue(maxQueuedPerOwner = 10) {
return createReaderQueue({ slots: 1, maxQueuedPerOwner, waitMs: WAIT });
}
/** Acquire, and note the order in which tickets are granted. */
function recorder() {
const order: string[] = [];
const tickets = new Map<string, ReaderTicket>();
const track = (label: string, pending: Promise<ReaderTicket>) =>
pending.then((ticket) => {
if (ticket.ok) order.push(label);
tickets.set(label, ticket);
return ticket;
});
const release = (label: string) => {
const ticket = tickets.get(label);
if (ticket?.ok) ticket.release();
};
return { order, track, release };
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
describe("the reader queue", () => {
it("takes accounts in turn instead of first come, first served", async () => {
const q = queue();
const r = recorder();
void r.track("a1", q.acquire("a", "background"));
void r.track("a2", q.acquire("a", "background"));
void r.track("a3", q.acquire("a", "background"));
void r.track("b1", q.acquire("b", "background"));
void r.track("c1", q.acquire("c", "background"));
await flush();
for (const label of ["a1", "a2", "b1", "c1", "a3"]) {
r.release(label);
await flush();
}
// a1 held the slot. Of the waiting items a2 came first, then b and c
// each get their turn before a's third item comes round.
expect(r.order).toEqual(["a1", "a2", "b1", "c1", "a3"]);
});
it("serves a read a person is waiting on before background work", async () => {
const q = queue();
const r = recorder();
void r.track("bg-running", q.acquire("a", "background"));
void r.track("bg-a", q.acquire("a", "background"));
void r.track("bg-b", q.acquire("b", "background"));
void r.track("lab-scan", q.acquire("c", "interactive"));
await flush();
r.release("bg-running");
await flush();
expect(r.order).toEqual(["bg-running", "lab-scan"]);
});
it("refuses an account's item past its waiting cap at once, without touching others", async () => {
const q = queue(2);
const r = recorder();
await r.track("a-running", q.acquire("a", "interactive"));
void r.track("a-1", q.acquire("a", "interactive"));
void r.track("a-2", q.acquire("a", "background"));
await expect(q.acquire("a", "interactive")).resolves.toMatchObject({
ok: false,
reason: "queue-full",
});
// Another account still gets in line, and its turn comes before the
// capped account's second waiting item.
void r.track("b-1", q.acquire("b", "interactive"));
expect(q.snapshot().waiting).toEqual({ interactive: 2, background: 1 });
for (const label of ["a-running", "a-1"]) {
r.release(label);
await flush();
}
expect(r.order).toEqual(["a-running", "a-1", "b-1"]);
r.release("b-1");
await flush();
expect(r.order.at(-1)).toBe("a-2");
r.release("a-2");
});
describe("with a deadline", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("answers a waiting item busy when its lane's deadline passes, and keeps the slot for the next", async () => {
const q = queue();
const held = await q.acquire("a", "background");
const waiting = q.acquire("b", "interactive");
const later = q.acquire("c", "background");
await vi.advanceTimersByTimeAsync(WAIT.interactive + 1);
await expect(waiting).resolves.toEqual({
ok: false,
reason: "wait-expired",
retryAfterMs: WAIT.interactive,
});
expect(q.snapshot()).toEqual({
running: 1,
waiting: { interactive: 0, background: 1 },
});
// The expired item left the queue: releasing hands the slot to the
// next one in line, not to nobody.
if (held.ok) held.release();
await expect(later).resolves.toMatchObject({ ok: true });
});
it("releases a slot once, however often release is called", async () => {
const q = queue();
const first = await q.acquire("a", "background");
if (!first.ok) throw new Error("expected the slot");
first.release();
first.release();
expect(q.snapshot().running).toBe(0);
const a = await q.acquire("a", "background");
expect(a.ok).toBe(true);
expect(q.snapshot().running).toBe(1);
});
});
});
+40 -12
View File
@@ -5,10 +5,9 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
// Partial mock of the native canvas module: `loadImage` delegates to the real
// decoder by default (so every real-fixture test below is unaffected) and can
// be overridden per-test via `loadImageMock` to simulate a decoded image whose
// dimensions the header sniff could not have known.
// Partial mock of the native canvas module in this process: `loadImage`
// delegates to the real decoder and records every call, so a test can tell
// whether an upload was decoded here rather than in the reader's child.
const { loadImageMock } = vi.hoisted(() => ({ loadImageMock: vi.fn() }));
vi.mock("@napi-rs/canvas", async (importActual) => {
const actual = await importActual<typeof import("@napi-rs/canvas")>();
@@ -19,6 +18,7 @@ vi.mock("@napi-rs/canvas", async (importActual) => {
};
});
import { gif } from "../../__tests__/gif-fixture";
import { generateThumbnail, THUMB_LONG_EDGE } from "../thumbnail";
/**
@@ -165,14 +165,16 @@ describe("generateThumbnail", () => {
});
});
it("refuses an oversize image the header sniff missed (post-decode cap)", async () => {
// A readable header declaring a small image whose decoder is forced to
// return 30000×30000 = 900 MP: only the post-decode backstop can catch
// it.
loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 });
await expect(
generateThumbnail(sourceImage(16, 16, "image/jpeg"), "image/jpeg"),
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
it("decodes an uploaded image in the reader's child process, not in this one", async () => {
// The decoder allocates what the file makes it allocate; in the child
// that ends the child. Nothing of an image upload is decoded here.
loadImageMock.mockClear();
const result = await generateThumbnail(
sourceImage(640, 480, "image/png"),
"image/png",
);
expect(result.ok).toBe(true);
expect(loadImageMock).not.toHaveBeenCalled();
});
/**
@@ -214,6 +216,32 @@ describe("generateThumbnail", () => {
expect(result.ok).toBe(true);
});
it("refuses a GIF whose frame is far larger than its logical screen, before decoding", async () => {
loadImageMock.mockClear();
// A 10 x 10 screen around a 20000 x 20000 frame: the decoder sizes its
// buffer from the frame.
const bomb = gif({ width: 10, height: 10 }, [
{ width: 20_000, height: 20_000 },
]);
expect(bomb.length).toBeLessThan(64);
await expect(generateThumbnail(bomb, "image/gif")).resolves.toEqual({
ok: false,
reason: "pixel-cap",
});
expect(loadImageMock).not.toHaveBeenCalled();
});
it("still previews an ordinary GIF", async () => {
const result = await generateThumbnail(
gif({ width: 1, height: 1 }, [{ width: 1, height: 1 }]),
"image/gif",
);
expect(result).toMatchObject({
ok: true,
thumbnail: { width: 1, height: 1 },
});
});
it("refuses an image whose size cannot be read instead of decoding it", async () => {
loadImageMock.mockClear();
// Start of image, then straight into a scan: no frame header to read.
+10 -2
View File
@@ -154,9 +154,14 @@ export type VisionInput =
// Refs #776 — `rasterFailed` (this PDF would not render on a raster-
// capable build) is distinct from `pdfNeedsAnthropic` (this build /
// provider cannot take a PDF at all): they need different fixes, so
// they must not share a label.
// they must not share a label. `readerBusy` is neither: the reader
// could not take the document now, and the work is tried again.
reason:
"decryptFailed" | "fileType" | "pdfNeedsAnthropic" | "rasterFailed";
| "decryptFailed"
| "fileType"
| "pdfNeedsAnthropic"
| "rasterFailed"
| "readerBusy";
};
/**
@@ -211,6 +216,9 @@ export async function prepareVisionInput(
if (raster.ok) {
return { ok: true, images: raster.images, documents: [] };
}
if (raster.reason === "busy" || raster.reason === "interrupted") {
return { ok: false, reason: "readerBusy" };
}
// Refs #776 — a build that cannot rasterize at all is a capability gap a
// PDF-native (Anthropic) provider would cover; a render failure on a
// capable build is a property of THIS document. Report them apart.
+4 -1
View File
@@ -52,6 +52,7 @@ import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import { refusalAsFailure } from "./http";
import { meteredReadBudget } from "./metered-budget";
import {
readerBusyFailure,
runFailure,
unreadableDocument,
type DocumentTextPick,
@@ -306,7 +307,9 @@ export async function executeDocumentExtract(args: {
source = { mode: "text", text: input.text };
}
const plan = await planInboundReads(source, schedule);
if (!plan.ok) return refused(pdfUnreadable());
if (!plan.ok) {
return refused(plan.busy ? readerBusyFailure() : pdfUnreadable());
}
await schedule.arm(plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS);
let result: InboundExtractionResult;
+3
View File
@@ -32,6 +32,7 @@ import { recordIndexAttempt } from "@/lib/documents/index-document";
import type { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { readerBusyFailure } from "./run-support";
import { settleAiRunBudget } from "./store";
import type {
AiRunBudget,
@@ -157,6 +158,8 @@ export async function executeDocumentIndex(args: {
// gone.
await settleAiRunBudget(userId, budget, 0, null);
await refundDocumentAiSlot(userId);
// A busy reader says nothing about the document: no attempt is recorded.
if (vision.reason === "readerBusy") return readerBusyFailure();
if (vision.reason === "pdfNeedsAnthropic") {
await recordIndexAttempt(userId, document.id, {
indexed: false,
+15 -1
View File
@@ -13,7 +13,7 @@ import type {
} from "@/lib/documents/provider-order";
import type { VisionInput } from "@/lib/documents/ai-route-support";
import type { AiRunOutcome } from "./types";
import { AI_RUN_ERROR_CODES, type AiRunOutcome } from "./types";
export type DocumentVisionPick = Awaited<
ReturnType<typeof requireDocumentVisionProvider>
@@ -44,6 +44,19 @@ export function unreadableDocument(parseError: boolean): AiRunOutcome<never> {
});
}
/**
* The document reader was busy with other work for longer than a person's
* read waits, or its process ended for no reason it measured. Nothing was
* read and nothing is charged; asking again in a minute is the answer.
*/
export function readerBusyFailure(): AiRunOutcome<never> {
return runFailure({
status: 503,
message: "The document reader is busy. Try again in a minute.",
errorCode: AI_RUN_ERROR_CODES.readerBusy,
});
}
/**
* A stored original that could not become a provider input, as the summary
* and suggest routes have always answered it.
@@ -59,6 +72,7 @@ export function visionPreparationFailure(
errorCode: "documents.inbound.pdfNeedsAnthropic",
});
}
if (reason === "readerBusy") return readerBusyFailure();
if (reason === "fileType") {
return runFailure({
status: 422,
+1
View File
@@ -74,6 +74,7 @@ export const AI_RUN_ERROR_CODES = {
workerUnavailable: "aiRuns.workerUnavailable",
timedOut: "aiRuns.timedOut",
failed: "aiRuns.failed",
readerBusy: "aiRuns.readerBusy",
} as const;
/** How long a queued run waits for a worker before it is failed. */
+2 -2
View File
@@ -484,7 +484,7 @@ export function planInboundTextReads(
export async function planInboundReads(
source: InboundReadSource,
schedule: OcrSchedule,
): Promise<({ ok: true } & InboundReads) | { ok: false }> {
): Promise<({ ok: true } & InboundReads) | { ok: false; busy?: true }> {
if (source.mode === "text") {
return { ok: true, ...planInboundTextReads(source.text, schedule) };
}
@@ -494,7 +494,7 @@ export async function planInboundReads(
source.pdfSupported,
schedule,
);
if (!plan.ok) return { ok: false };
if (!plan.ok) return plan;
return {
ok: true,
args: { mode: "vision", chunks: plan.chunks, totalPages: plan.totalPages },
+81
View File
@@ -0,0 +1,81 @@
// @ts-check
/**
* Decoding an uploaded image into the vault's preview thumbnail.
*
* The server never runs this in its own process. An image decoder allocates
* the whole bitmap before anything can look at it, and the size it allocates
* is whatever the file declares in a place the decoder chooses: a GIF frame
* descriptor once reached a 1.6 GB decode behind a header check that only
* read the logical screen. The header check (`../image-dimensions.ts`) still
* refuses what it can see before a child is started; this module is what
* runs in that child (`./pdf-child.mjs`, under the memory ceiling and time
* limit of `./pdf-isolation.ts`), so a decoder surprise ends the child and
* not the server.
*
* Plain JavaScript for the same reason as `./pdf-engine.mjs`: the child runs
* it with plain Node from the checkout and the standalone image alike.
*/
import { Buffer } from "node:buffer";
/**
* @typedef {object} ImageThumbnailOptions
* @property {number} longEdge The preview's longest side; never upscaled.
* @property {number} quality JPEG quality, 0 to 100.
* @property {number} maxPixels The most pixels a decoded source may have.
*/
/**
* @typedef {{ ok: true; jpeg: Uint8Array; width: number; height: number }
* | { ok: false; reason: "pixel-cap" | "empty-render" }} ImageThumbnailOutcome
*/
/**
* The slice of `@napi-rs/canvas` used here; the suites pass a stand-in.
*
* @typedef {object} CanvasModule
* @property {(source: Buffer) => Promise<{ width: number; height: number }>} loadImage
* @property {(width: number, height: number) => {
* getContext(type: "2d"): { drawImage(image: unknown, dx: number, dy: number, dw: number, dh: number): void };
* toBuffer(mime: "image/jpeg", quality?: number): Buffer;
* }} createCanvas
*/
/**
* Decode an image and draw it onto a fresh JPEG no longer than
* `options.longEdge` on its long side. The re-encode carries none of the
* source's metadata. Throws what the decoder threw.
*
* @param {Uint8Array} data
* @param {ImageThumbnailOptions} options
* @param {CanvasModule} [canvasModule]
* @returns {Promise<ImageThumbnailOutcome>}
*/
export async function renderImageThumbnail(data, options, canvasModule) {
const napi =
canvasModule ??
/** @type {CanvasModule} */ (
/** @type {unknown} */ (await import("@napi-rs/canvas"))
);
const image = await napi.loadImage(
Buffer.from(data.buffer, data.byteOffset, data.byteLength),
);
// The decoded size again, before a canvas of that size is drawn from: the
// header check is a reading of the file, the decoder had the final word.
if (image.width * image.height > options.maxPixels) {
return { ok: false, reason: "pixel-cap" };
}
if (image.width <= 0 || image.height <= 0) {
return { ok: false, reason: "empty-render" };
}
const scale = Math.min(
1,
options.longEdge / Math.max(image.width, image.height),
);
const width = Math.max(1, Math.round(image.width * scale));
const height = Math.max(1, Math.round(image.height * scale));
const canvas = napi.createCanvas(width, height);
canvas.getContext("2d").drawImage(image, 0, 0, width, height);
const jpeg = canvas.toBuffer("image/jpeg", options.quality);
if (jpeg.byteLength === 0) return { ok: false, reason: "empty-render" };
return { ok: true, jpeg: new Uint8Array(jpeg), width, height };
}
+7
View File
@@ -58,6 +58,7 @@ import {
import { transcribeDocument } from "@/lib/documents/describe";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import { localExtractText } from "@/lib/documents/local-extract";
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { decryptDocumentContent } from "@/lib/documents/store";
import { prisma } from "@/lib/db";
@@ -238,6 +239,12 @@ async function tryProviderIndex(
if (vision.reason === "rasterFailed") {
return { outcome: null, note: "raster-failed" };
}
if (vision.reason === "readerBusy") {
// The reader could not take the document now. Falling back to the
// local text layer would settle for the lesser read for good; the job
// runs again instead.
throw new PdfReaderUnavailableError("busy");
}
if (vision.reason === "pdfNeedsAnthropic") {
return { outcome: null, note: "pdf-needs-anthropic" };
}
+11 -4
View File
@@ -32,7 +32,11 @@ import { Buffer } from "node:buffer";
import { annotate } from "@/lib/logging/context";
import { logCaught } from "@/lib/logging/signal";
import { PdfResourceLimitError, readPdfText } from "./pdfjs";
import {
PdfReaderUnavailableError,
PdfResourceLimitError,
readPdfText,
} from "./pdfjs";
/**
* Minimum joined text length for a PDF to count as a real text-layer document.
@@ -70,9 +74,11 @@ export type LocalExtractResult =
/**
* Extract a text-layer PDF's embedded text server-side. Returns `empty` for a
* scanned/no-text-layer PDF, `resource-limit` for one past the reader's memory
* or time bound, and `error` for a malformed/encrypted one — never
* throws, so a bad document can never abort the caller (the upload/backfill job
* just leaves it un-indexed). An `error` leaves a `warn` line naming what
* or time bound, and `error` for a malformed/encrypted one, so a bad document
* can never abort the caller (the upload/backfill job just leaves it
* un-indexed). The one throw is `PdfReaderUnavailableError`: the reader was
* busy or its process ended for no measured reason, which says nothing about
* the document, so the job runs again instead of recording an outcome. An `error` leaves a `warn` line naming what
* pdf.js threw, because the outcome alone does not say why a document stayed
* out of the index.
*/
@@ -84,6 +90,7 @@ export async function extractPdfText(
// Bound the page walk so a pathological many-page PDF costs no more.
text = (await readPdfText(buffer, LOCAL_MAX_PAGES)).trim();
} catch (err) {
if (err instanceof PdfReaderUnavailableError) throw err;
if (err instanceof PdfResourceLimitError) {
// Not a fault of the file's bytes being unreadable: they are readable,
// only not within the bounds this server gives one document.
+12 -2
View File
@@ -170,7 +170,12 @@ export type OcrVisionPlan =
/** Set when the PDF is longer than the page cap: only the first pages are read. */
pageCoverage?: { read: number; total: number };
}
| { ok: false };
/**
* `busy`: the reader could not take the PDF now (busy past the wait a read
* gets, or its process ended for no measured reason). Not a verdict on the
* file; the person asks again.
*/
| { ok: false; busy?: true };
/**
* Plan the reads of an image or a PDF. Every PDF is rendered first: a report
@@ -226,7 +231,12 @@ export async function planVisionReads(
: {}),
};
}
if (!pdfSupported) return { ok: false };
if (!pdfSupported) {
return !raster.ok &&
(raster.reason === "busy" || raster.reason === "interrupted")
? { ok: false, busy: true }
: { ok: false };
}
return {
ok: true,
chunks: [
+14 -5
View File
@@ -1,9 +1,10 @@
// @ts-check
/**
* The process a PDF is read and rendered in. `./pdf-isolation.ts` starts it
* for one request, hands it the bytes over the IPC channel, and takes back
* either the result or the fact that it died. It holds no secrets: it is
* started with an almost empty environment and never opens the database.
* The process a PDF is read and rendered in, and an uploaded image is decoded
* into its thumbnail in. `./pdf-isolation.ts` starts it for one request,
* hands it the bytes over the IPC channel, and takes back either the result
* or the fact that it died. It holds no secrets: it is started with an almost
* empty environment and never opens the database.
*
* On Linux it volunteers itself to the kernel's out-of-memory killer first,
* so when a document outruns the memory check between two samples and the
@@ -12,6 +13,7 @@
*/
import { writeFileSync } from "node:fs";
import { renderImageThumbnail } from "./image-engine.mjs";
import { readTextLayer, renderPdfPages } from "./pdf-engine.mjs";
try {
@@ -22,7 +24,8 @@ try {
/**
* @typedef {{ op: "text"; data: Uint8Array; maxPages: number }
* | { op: "render"; data: Uint8Array; options: import("./pdf-engine.mjs").RenderOptions }} PdfChildRequest
* | { op: "render"; data: Uint8Array; options: import("./pdf-engine.mjs").RenderOptions }
* | { op: "image-thumbnail"; data: Uint8Array; options: import("./image-engine.mjs").ImageThumbnailOptions }} PdfChildRequest
*/
/**
@@ -52,6 +55,12 @@ async function handle(request) {
document: await renderPdfPages(data, request.options),
};
}
if (request.op === "image-thumbnail") {
return {
ok: true,
thumbnail: await renderImageThumbnail(data, request.options),
};
}
return failure(new Error("unknown request"));
} catch (err) {
return failure(err);
+183 -52
View File
@@ -1,5 +1,6 @@
/**
* Runs pdf.js in a child process with a memory ceiling and a time limit.
* Runs pdf.js, and the image decoder behind the vault's thumbnails, in a child
* process with a memory ceiling and a time limit.
*
* A PDF's cost is set by whoever wrote it. A 400 KB file can declare an image
* of twenty thousand pixels square, and pdf.js allocated about 7 GB decoding
@@ -20,20 +21,34 @@
* - it starts with an almost empty environment, so it holds no key, token
* or database address;
* - one runs at a time (`MAX_PARALLEL_CHILDREN`), so a burst of uploads
* cannot multiply the ceiling.
* cannot multiply the ceiling. Who runs next is decided by
* `./reader-queue.ts`: a read a person is waiting on before background
* work, accounts in turn, a few waiting items per account, and a
* deadline after which a waiting item is answered `busy`.
*
* A document that crosses a bound fails with `limit` and is not something a
* retry will fix; callers record it as a final failure.
* A document that crosses a bound this module measured (the sampled memory,
* the time limit, or the child's own heap cap) fails with `limit` and is not
* something a retry will fix; callers record it as a final failure. A child
* that is gone without an answer and without a measured cause is not that:
* the kernel may have taken it because the container as a whole ran short,
* the server's own memory included. It is run once more, and a second such
* exit answers `interrupted`, which callers treat as retryable.
*
* This module logs nothing and imports nothing from the app, so the
* end-to-end suite can read report text through `./pdfjs.ts` as well.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { execFile, fork, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type {
ImageThumbnailOptions,
ImageThumbnailOutcome,
} from "./image-engine.mjs";
import type { RenderOptions, RenderedDocument } from "./pdf-engine.mjs";
import { createReaderQueue, type ReaderLane } from "./reader-queue";
/** Resident memory a child may reach before it is killed. */
export const PDF_CHILD_MEMORY_MB = 640;
@@ -61,18 +76,31 @@ const CHILD_PATH = ["src", "lib", "documents", "pdf-child.mjs"];
export type PdfChildRequest =
| { op: "text"; data: Uint8Array; maxPages: number }
| { op: "render"; data: Uint8Array; options: RenderOptions };
| { op: "render"; data: Uint8Array; options: RenderOptions }
| { op: "image-thumbnail"; data: Uint8Array; options: ImageThumbnailOptions };
type PdfChildValue<R extends PdfChildRequest> = R extends { op: "text" }
? string
: RenderedDocument;
: R extends { op: "render" }
? RenderedDocument
: ImageThumbnailOutcome;
/** Why a child gave no result. */
export type PdfChildFailure =
/** The document crossed the memory ceiling or the time limit. */
| { ok: false; reason: "limit"; limit: "memory" | "time" | "exit" }
/** pdf.js refused the document: malformed, encrypted, unreadable. */
| { ok: false; reason: "error"; name: string; message: string };
| { ok: false; reason: "limit"; limit: "memory" | "time" }
/** pdf.js or the decoder refused the input: malformed, encrypted, unreadable. */
| { ok: false; reason: "error"; name: string; message: string }
/**
* The child ended without an answer and without a cause this module
* measured, twice. Not the document's verdict; try again later.
*/
| { ok: false; reason: "interrupted" }
/**
* The reader was taken for longer than the wait this work may spend, or
* this account already has as much waiting as it may. Try again later.
*/
| { ok: false; reason: "busy"; retryAfterMs: number };
export type PdfChildResult<R extends PdfChildRequest> =
{ ok: true; value: PdfChildValue<R> } | PdfChildFailure;
@@ -81,27 +109,77 @@ export interface PdfChildLimits {
timeoutMs: number;
/** Overrides `PDF_CHILD_MEMORY_MB`; the suites use a small one. */
memoryMb?: number;
/** Replaces the child start; the suites use it to stand in for a child. */
run?: (
script: string,
request: PdfChildRequest,
limits: PdfChildLimits,
) => Promise<PdfChildResult<PdfChildRequest>>;
}
// ── Concurrency ────────────────────────────────────────────────────────────
// ── Who runs next ──────────────────────────────────────────────────────────
let running = 0;
const waiting: Array<() => void> = [];
/** Items one account may have waiting for the reader, across both lanes. */
export const MAX_QUEUED_PER_OWNER = 4;
async function acquireSlot(): Promise<void> {
if (running < MAX_PARALLEL_CHILDREN) {
running++;
return;
}
await new Promise<void>((resolve) => waiting.push(resolve));
/**
* How long an item may wait for the reader. A person waiting on a read gets
* an answer within a minute; background work can wait longer, and its job is
* retried when it does not get a turn.
*/
export const READER_WAIT_MS: Record<ReaderLane, number> = {
interactive: 60_000,
background: 5 * 60_000,
};
const queue = createReaderQueue({
slots: MAX_PARALLEL_CHILDREN,
maxQueuedPerOwner: MAX_QUEUED_PER_OWNER,
waitMs: READER_WAIT_MS,
});
/** Whose work this is and whether a person is waiting on it. */
export interface PdfWork {
/** The account the work is done for; the queue takes accounts in turn. */
owner: string;
lane: ReaderLane;
/**
* Moves the caller's own clock by `ms`. A background AI run has a deadline
* measured in model calls, and time spent waiting for the reader or
* reading is not model time: when the slot is granted the clock moves by
* the wait plus the most the read may take, and when the read ends it moves
* back by the part of that allowance the read did not use. So the run's
* model-time budget starts once the reader is done with it, however long
* the queue was. Failures are the caller's to swallow; the read goes on.
*/
extendDeadline?: (ms: number) => Promise<void>;
}
function releaseSlot(): void {
const next = waiting.shift();
if (next) next();
else running--;
const workContext = new AsyncLocalStorage<PdfWork>();
/**
* Run `fn` with every reader call inside it counted as `work`. The entry
* points set it: the background AI run worker (interactive) and the document
* jobs (background). A call outside any of them is background work of an
* unattributed owner.
*/
export function withPdfWork<T>(
work: PdfWork,
fn: () => Promise<T>,
): Promise<T> {
return workContext.run(work, fn);
}
const UNATTRIBUTED: PdfWork = { owner: "unattributed", lane: "background" };
/** The work the reader would count a call made here as. */
export function currentPdfWork(): PdfWork {
return workContext.getStore() ?? UNATTRIBUTED;
}
/** How many times a child that vanished without a measured cause is run. */
const MAX_ATTEMPTS = 2;
// ── Resident memory of another process ─────────────────────────────────────
/** The child's resident set in MB, or null where it cannot be read. */
@@ -132,6 +210,7 @@ interface ChildReply {
ok: boolean;
text?: string;
document?: RenderedDocument;
thumbnail?: ImageThumbnailOutcome;
error?: { name: string; message: string };
}
@@ -150,9 +229,36 @@ function decodeDocument(document: RenderedDocument): RenderedDocument {
};
}
function decodeValue<R extends PdfChildRequest>(
request: R,
reply: ChildReply,
): PdfChildValue<R> {
if (request.op === "text") return reply.text as PdfChildValue<R>;
if (request.op === "render") {
return decodeDocument(
reply.document as RenderedDocument,
) as PdfChildValue<R>;
}
const thumbnail = reply.thumbnail as ImageThumbnailOutcome;
return (
thumbnail.ok
? {
...thumbnail,
jpeg: Buffer.from(
thumbnail.jpeg.buffer,
thumbnail.jpeg.byteOffset,
thumbnail.jpeg.byteLength,
),
}
: thumbnail
) as PdfChildValue<R>;
}
/**
* Read or render one PDF in a child process. Never throws: a document that
* crosses a bound answers `limit`, one pdf.js refuses answers `error`.
* Read or render one PDF, or decode one image, in a child process. Never
* throws: a document that crosses a bound answers `limit`, one the reader
* refuses answers `error`, a reader that is taken answers `busy`, and a
* child that twice ended for no reason it could see answers `interrupted`.
*/
export async function runPdfChild<R extends PdfChildRequest>(
request: R,
@@ -168,15 +274,44 @@ export async function runPdfChild<R extends PdfChildRequest>(
};
}
await acquireSlot();
const work = currentPdfWork();
const queuedAt = Date.now();
const ticket = await queue.acquire(work.owner, work.lane);
if (!ticket.ok) {
return { ok: false, reason: "busy", retryAfterMs: ticket.retryAfterMs };
}
const grantedAt = Date.now();
// The most this read may take: every attempt to its time limit.
const allowance = limits.timeoutMs * MAX_ATTEMPTS;
await moveCallerClock(work, grantedAt - queuedAt + allowance);
try {
return await runOnce(script, request, limits);
for (let attempt = 1; ; attempt++) {
const result = limits.run
? ((await limits.run(script, request, limits)) as PdfChildResult<R>)
: await runOnce(script, request, limits);
if (result.ok || result.reason !== "interrupted") return result;
if (attempt >= MAX_ATTEMPTS) return result;
}
} finally {
releaseSlot();
ticket.release();
await moveCallerClock(
work,
-Math.max(0, allowance - (Date.now() - grantedAt)),
);
}
}
function runOnce<R extends PdfChildRequest>(
async function moveCallerClock(work: PdfWork, ms: number): Promise<void> {
if (!work.extendDeadline || ms === 0) return;
try {
await work.extendDeadline(ms);
} catch {
// The caller's clock is the caller's; the read is not failed for it.
}
}
/** Start one child for `request` and wait for its answer or its end. */
export function runOnce<R extends PdfChildRequest>(
script: string,
request: R,
limits: PdfChildLimits,
@@ -244,11 +379,7 @@ function runOnce<R extends PdfChildRequest>(
child.on("message", (raw) => {
const reply = raw as ChildReply;
if (reply.ok) {
const value =
request.op === "text"
? reply.text
: decodeDocument(reply.document as RenderedDocument);
finish({ ok: true, value: value as PdfChildValue<R> });
finish({ ok: true, value: decodeValue(request, reply) });
} else {
finish({
ok: false,
@@ -259,13 +390,10 @@ function runOnce<R extends PdfChildRequest>(
}
});
child.on("error", (err) => {
finish({
ok: false,
reason: "error",
name: err.name,
message: err.message.slice(0, 300),
});
// The process could not be started or signalled. What pdf.js or the
// decoder thought of the document arrives as a reply, never here.
child.on("error", () => {
finish({ ok: false, reason: "interrupted" });
});
child.on("close", () => {
@@ -273,21 +401,24 @@ function runOnce<R extends PdfChildRequest>(
finish({ ok: false, reason: "limit", limit: killedFor });
return;
}
// Gone without an answer: V8 hit its heap ceiling, the kernel took it
// for memory, or it crashed in native code. Any of them is the
// document's doing.
const heap = /heap limit|heap out of memory/i.test(stderrTail);
finish({ ok: false, reason: "limit", limit: heap ? "memory" : "exit" });
// Gone without an answer. V8 reaching the heap cap this module gave
// the child is the document's doing, whatever else the host runs. Any
// other end (the kernel's out-of-memory killer, which also acts when
// the server itself grew, a signal from outside, a native crash) says
// nothing certain about the document, so it is not a final verdict.
if (/heap limit/i.test(stderrTail)) {
finish({ ok: false, reason: "limit", limit: "memory" });
return;
}
finish({ ok: false, reason: "interrupted" });
});
child.send(request, (err) => {
if (err) {
finish({
ok: false,
reason: "error",
name: err.name,
message: err.message.slice(0, 300),
});
// The bytes never reached the child, mostly because it was already
// gone. That says nothing about the document either: end the child and
// let its close answer `interrupted`.
if (err && child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
});
});
+23 -3
View File
@@ -21,16 +21,33 @@ export const PDF_TEXT_TIMEOUT_MS = 60_000;
/** A document that crossed the memory ceiling or the time limit. */
export class PdfResourceLimitError extends Error {
override name = "PdfResourceLimitError";
constructor(readonly limit: "memory" | "time" | "exit") {
constructor(readonly limit: "memory" | "time") {
super(`the PDF crossed the reader's ${limit} limit`);
}
}
/**
* The reader could not take the document now: it was busy past the wait this
* work may spend, or its process ended for a reason that says nothing about
* the document. Not a verdict on the file; the work is tried again later.
*/
export class PdfReaderUnavailableError extends Error {
override name = "PdfReaderUnavailableError";
constructor(readonly kind: "busy" | "interrupted") {
super(
kind === "busy"
? "the document reader is busy"
: "the document reader stopped before it answered",
);
}
}
/**
* The text layer of the first `maxPages` pages, one page after another.
* Throws what pdf.js threw (a malformed file, a password, a page that will
* not parse) under its own name, or `PdfResourceLimitError` when the
* document crossed a bound.
* not parse) under its own name, `PdfResourceLimitError` when the document
* crossed a bound, or `PdfReaderUnavailableError` when the reader could not
* take it now.
*/
export async function readPdfText(
data: Uint8Array,
@@ -43,6 +60,9 @@ export async function readPdfText(
);
if (result.ok) return result.value;
if (result.reason === "limit") throw new PdfResourceLimitError(result.limit);
if (result.reason === "busy" || result.reason === "interrupted") {
throw new PdfReaderUnavailableError(result.reason);
}
const err = new Error(result.message);
err.name = result.name;
throw err;
+21 -1
View File
@@ -107,7 +107,20 @@ export type RasterResult =
*/
texts?: string[];
}
| { ok: false; reason: "unsupported" | "render-failed" | "resource-limit" };
| {
ok: false;
reason:
| "unsupported"
| "render-failed"
| "resource-limit"
/**
* Not an answer about this document: the reader was busy past the
* wait this work may spend, or its process ended for no reason it
* measured. The caller tries again later.
*/
| "busy"
| "interrupted";
};
export {
pageText,
@@ -166,6 +179,13 @@ export async function rasterizePdf(
);
if (!result.ok) {
if (result.reason === "busy" || result.reason === "interrupted") {
annotate({
action: { name: "documents.rasterize.deferred" },
meta: { reason: result.reason },
});
return { ok: false, reason: result.reason };
}
if (result.reason === "limit") {
annotate({
action: { name: "documents.rasterize.failed" },
+163
View File
@@ -0,0 +1,163 @@
/**
* Who gets the document reader next.
*
* The reader (`./pdf-isolation.ts`) runs one child process at a time, because
* the server and one child at its memory ceiling are what fit the default
* container. It used to hand that one slot out first come, first served,
* with no limit on how long anyone waited: a few heavy PDFs from one account,
* each allowed up to a minute, put every other person's lab scan behind them
* for as long as the uploads kept coming.
*
* Now the slot goes:
*
* - to interactive work first: a read a person started and is waiting on
* (a lab scan, a medication label, a document read) is served before
* background work (thumbnails, indexing, summaries) that nobody watches;
* - round robin across accounts within each lane, so one account's queue
* takes one turn and then waits behind everyone else's next item;
* - to at most `maxQueuedPerOwner` waiting items per account; one more is
* refused at once rather than queued;
* - within a deadline per lane: an item still waiting when it passes leaves
* the queue with a retryable "busy" answer instead of waiting on.
*
* Nothing here knows what a PDF is, so the order can be tested on its own.
*/
export type ReaderLane = "interactive" | "background";
/** Interactive before background. */
const LANE_ORDER: readonly ReaderLane[] = ["interactive", "background"];
export interface ReaderQueueOptions {
/** Work running at once. */
slots: number;
/** Items one owner may have waiting, across both lanes. */
maxQueuedPerOwner: number;
/** How long an item may wait in each lane before it is answered busy. */
waitMs: Record<ReaderLane, number>;
}
/** The slot, or why there is none. `release` must be called exactly once. */
export type ReaderTicket =
| { ok: true; release: () => void }
| { ok: false; reason: "queue-full" | "wait-expired"; retryAfterMs: number };
interface Waiter {
owner: string;
grant: () => void;
timer: ReturnType<typeof setTimeout>;
}
export interface ReaderQueue {
acquire(owner: string, lane: ReaderLane): Promise<ReaderTicket>;
/** Items running and waiting, for the suites and diagnostics. */
snapshot(): { running: number; waiting: Record<ReaderLane, number> };
}
export function createReaderQueue(options: ReaderQueueOptions): ReaderQueue {
let running = 0;
// Per lane, one FIFO per owner. A Map iterates in insertion order, so the
// first key is the owner whose turn it is; after a turn the owner moves to
// the back.
const lanes: Record<ReaderLane, Map<string, Waiter[]>> = {
interactive: new Map(),
background: new Map(),
};
const waitingFor = (owner: string): number =>
LANE_ORDER.reduce(
(sum, lane) => sum + (lanes[lane].get(owner)?.length ?? 0),
0,
);
const remove = (lane: ReaderLane, waiter: Waiter): void => {
const queue = lanes[lane].get(waiter.owner);
if (!queue) return;
const at = queue.indexOf(waiter);
if (at !== -1) queue.splice(at, 1);
if (queue.length === 0) lanes[lane].delete(waiter.owner);
};
const next = (): Waiter | null => {
for (const lane of LANE_ORDER) {
const owners = lanes[lane];
const first = owners.keys().next();
if (first.done) continue;
const owner = first.value;
const queue = owners.get(owner)!;
const waiter = queue.shift()!;
owners.delete(owner);
if (queue.length > 0) owners.set(owner, queue);
return waiter;
}
return null;
};
const release = (): void => {
const waiter = next();
if (waiter) {
clearTimeout(waiter.timer);
waiter.grant();
} else {
running--;
}
};
const ticket = (): ReaderTicket => {
let released = false;
return {
ok: true,
release: () => {
if (released) return;
released = true;
release();
},
};
};
return {
acquire(owner, lane) {
if (running < options.slots) {
running++;
return Promise.resolve(ticket());
}
if (waitingFor(owner) >= options.maxQueuedPerOwner) {
return Promise.resolve({
ok: false,
reason: "queue-full",
retryAfterMs: options.waitMs[lane],
});
}
return new Promise<ReaderTicket>((resolve) => {
const waiter: Waiter = {
owner,
// The slot passes straight from the releasing item to this one;
// `running` stays as it is.
grant: () => resolve(ticket()),
timer: setTimeout(() => {
remove(lane, waiter);
resolve({
ok: false,
reason: "wait-expired",
retryAfterMs: options.waitMs[lane],
});
}, options.waitMs[lane]),
};
const queue = lanes[lane].get(owner);
if (queue) queue.push(waiter);
else lanes[lane].set(owner, [waiter]);
});
},
snapshot() {
const count = (lane: ReaderLane) =>
[...lanes[lane].values()].reduce((sum, q) => sum + q.length, 0);
return {
running,
waiting: {
interactive: count("interactive"),
background: count("background"),
},
};
},
};
}
+99 -31
View File
@@ -6,6 +6,12 @@
* rasterised (via `rasterizePdf`, bounded to one page) then downscaled; every
* other MIME type has no preview (the card keeps its kind icon).
*
* Both untrusted decodes happen in the reader's child process
* (`./pdf-isolation.ts`) under its memory ceiling and time limit: the image
* through `./image-engine.mjs`, the PDF page through `./pdf-engine.mjs`. An
* image's declared size is still checked here first, so a bomb the header
* shows is refused without starting a child.
*
* `@napi-rs/canvas` + pdfjs are pulled LAZILY via runtime `import()` — pdfjs's
* module top-level references the browser-only `DOMMatrix` global, so a STATIC
* import would make the Turbopack server chunk evaluate that reference at
@@ -24,12 +30,14 @@
* binary, or any render error resolves to `{ ok: false }`, and the caller
* leaves the document without a thumbnail (the card falls back to its kind
* icon). This preserves the "a bad document never aborts an upload/backfill"
* contract.
* contract. `reader-busy` and `interrupted` are the two answers that say
* nothing about the document; the job tries those again.
*/
import { Buffer } from "node:buffer";
import { nativeCanvasSupported } from "./native-canvas-support";
import { runPdfChild } from "@/lib/documents/pdf-isolation";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import {
readImageDimensions,
@@ -96,6 +104,10 @@ export type ThumbnailSkipReason =
| "raster-failed"
| "resource-limit"
| "empty-render"
/** The reader was busy; the job is retried. */
| "reader-busy"
/** The reader's process ended for no measured reason; tried again later. */
| "interrupted"
| "error";
/** Best-effort generation outcome — never an exception. */
@@ -152,6 +164,76 @@ function drawScaledJpeg(
return { jpeg, width, height };
}
/** How long decoding one image into its preview may take. */
export const IMAGE_THUMBNAIL_TIMEOUT_MS = 30_000;
/**
* Decode and downscale an image in the reader's child process, under its
* memory ceiling and time limit. The decoder allocates whatever the file
* makes it allocate; in the child that ends the child, not the server.
*/
async function imageThumbnail(bytes: Buffer): Promise<ThumbnailResult> {
const result = await runPdfChild(
{
op: "image-thumbnail",
data: bytes,
options: {
longEdge: THUMB_LONG_EDGE,
quality: THUMB_QUALITY,
maxPixels: MAX_SOURCE_PIXELS,
},
},
{ timeoutMs: IMAGE_THUMBNAIL_TIMEOUT_MS },
);
if (!result.ok) {
if (result.reason === "busy") return { ok: false, reason: "reader-busy" };
if (result.reason === "interrupted") {
return { ok: false, reason: "interrupted" };
}
if (result.reason === "limit") {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: {
source: "image",
reason: "resource_limit",
limit: result.limit,
},
});
return { ok: false, reason: "resource-limit" };
}
annotate({
action: { name: "documents.thumbnail.failed" },
meta: {
mimeType: "image",
reason: result.name,
message: result.message.slice(0, 300),
},
});
return { ok: false, reason: "error" };
}
const outcome = result.value;
if (!outcome.ok) {
if (outcome.reason === "pixel-cap") {
// The decoder found a larger image than the header walk did.
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: { source: "image", reason: "pixel_cap_post_decode" },
});
}
return { ok: false, reason: outcome.reason };
}
const thumb: Thumbnail = {
jpeg: Buffer.from(outcome.jpeg),
width: outcome.width,
height: outcome.height,
};
annotate({
action: { name: "documents.thumbnail.ok" },
meta: { source: "image", width: thumb.width, height: thumb.height },
});
return { ok: true, thumbnail: thumb };
}
/**
* Generate a small JPEG preview for a stored document, or `{ ok: false }` when
* the type is unsupported or rendering fails. Never throws.
@@ -173,15 +255,10 @@ export async function generateThumbnail(
});
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.
const napi =
(await import("@napi-rs/canvas")) as unknown as NapiCanvasModule;
if (THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) {
// Refuse a decompression bomb BEFORE `loadImage` allocates the bitmap,
// and refuse an image whose size cannot be read: that one would reach
// the decoder with no ceiling at all.
// Refuse a decompression bomb before a decoder is started at all, and
// refuse an image whose size cannot be read: that one would reach the
// decoder with no ceiling but the child's.
const pixels = sniffSourcePixels(bytes, mimeType);
if (pixels === null) {
annotate({
@@ -197,28 +274,7 @@ export async function generateThumbnail(
});
return { ok: false, reason: "pixel-cap" };
}
const image = await napi.loadImage(bytes);
// Post-decode backstop: should a decoder ever read a larger frame than
// the header walk did, the decoded size is checked again before the
// bitmap is drawn.
if (image.width * image.height > MAX_SOURCE_PIXELS) {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: {
source: "image",
reason: "pixel_cap_post_decode",
pixels: image.width * image.height,
},
});
return { ok: false, reason: "pixel-cap" };
}
const thumb = drawScaledJpeg(napi, image);
if (!thumb) return { ok: false, reason: "empty-render" };
annotate({
action: { name: "documents.thumbnail.ok" },
meta: { source: "image", width: thumb.width, height: thumb.height },
});
return { ok: true, thumbnail: thumb };
return await imageThumbnail(bytes);
}
if (mimeType === "application/pdf") {
@@ -229,9 +285,21 @@ export async function generateThumbnail(
// bytes will again, so the job records it as final.
return { ok: false, reason: "resource-limit" };
}
if (!raster.ok && raster.reason === "busy") {
return { ok: false, reason: "reader-busy" };
}
if (!raster.ok && raster.reason === "interrupted") {
return { ok: false, reason: "interrupted" };
}
if (!raster.ok || raster.images.length === 0) {
return { ok: false, reason: "raster-failed" };
}
// The page is a JPEG the child rendered, no longer than
// `RASTER_TARGET_LONG_EDGE` on its long side: the one decode left in
// this process is of the server's own output at a bounded size.
// Lazy import: keeps @napi-rs/canvas out of the eager server chunk.
const napi =
(await import("@napi-rs/canvas")) as unknown as NapiCanvasModule;
const pageJpeg = Buffer.from(raster.images[0]!.dataBase64, "base64");
const image = await napi.loadImage(pageJpeg);
const thumb = drawScaledJpeg(napi, image);
+24 -7
View File
@@ -25,17 +25,28 @@
* - A request with neither header is not from a browser page (the phone
* app, a script, a webhook sender) and passes; it carries no cookie a
* page could have lent it.
* - A request carrying `Authorization` passes: a page cannot attach that
* header to another origin without a CORS preflight, which this server
* never answers.
* - A request carrying `Authorization: Bearer` and no session cookie
* passes: a page cannot attach that header to another origin without a
* CORS preflight, which this server never answers, and without a session
* cookie the token is what the request is authenticated by. Any other
* scheme does not count: a browser attaches `Basic` or `Negotiate`
* credentials on its own when a proxy or the host asked for them, and
* the caller is resolved from the session cookie before any header
* (`src/lib/auth/guards/caller.ts`), so a request with both is a cookie
* request and is checked as one.
*
* Exempt by path: the surfaces that authenticate with a secret in a header
* or the URL rather than the session cookie, and that a third party's client
* may legitimately call from a browser (a push client's action button). The
* MCP OAuth endpoints, which must accept cross-origin requests by their
* specification, do not run through `apiHandler` at all.
* may legitimately call from a browser (a push client's action button), and
* the share-link passphrase check under `/api/c/`, whose credential is the
* token in the path plus the passphrase in the body. A shared-record page is
* served with `Referrer-Policy: no-referrer`, and a browser without
* `Sec-Fetch-Site` then sends `Origin: null` on its POST, which no host
* check can admit. The MCP OAuth endpoints, which must accept cross-origin
* requests by their specification, do not run through `apiHandler` at all.
*/
import { apiError } from "@/lib/api-response";
import { cookieHeaderCarriesSession } from "@/lib/auth/session-cookie";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -51,6 +62,7 @@ export const CROSS_SITE_EXEMPT_PREFIXES = [
"/api/internal/deploy-webhook",
"/api/notifications/action/",
"/api/monitoring/csp-report",
"/api/c/",
] as const;
function hostOf(value: string | null | undefined): string | null {
@@ -92,7 +104,12 @@ export function crossSiteWriteReason(
if (CROSS_SITE_EXEMPT_PREFIXES.some((p) => pathname.startsWith(p))) {
return null;
}
if (request.headers.has("authorization")) return null;
if (
request.headers.get("authorization")?.startsWith("Bearer ") &&
!cookieHeaderCarriesSession(request.headers.get("cookie"))
) {
return null;
}
const site = request.headers.get("sec-fetch-site")?.trim().toLowerCase();
if (site) {
+175
View File
@@ -0,0 +1,175 @@
/**
* Which connections may tell the server who the client is.
*
* A reverse proxy reports the client's address in `X-Forwarded-For` or
* `X-Real-IP`. Anyone else who sends those headers is choosing the address
* every per-IP rate limit, the audit log and the sign-in notices see, so the
* client-IP resolver (`getClientIpOrTrustWarning` in `@/lib/api-response`)
* reads them only from a peer this module says may forward.
*
* Two rules:
*
* - `TRUSTED_PROXY_CIDRS` set (comma-separated CIDRs, IPv4 or IPv6): only
* a peer inside one of them may forward. Every other connection is its
* own client, whatever address it comes from.
* - unset: any peer whose address is not public (loopback, private,
* CGNAT, unique-local, link-local) may forward. This is the long-standing
* default and stays for compatibility, but it is only as good as the
* network: behind Docker Desktop, OrbStack or `docker-proxy` every
* connection to a published port arrives from the gateway address, a
* private one, so a caller that reaches the port directly is believed
* again. Setting the list closes that.
*
* `TRUST_PROXY_HOPS` says how many proxies the `X-Forwarded-For` chain
* passed through. A value that is not a non-negative integer used to throw
* on every request that asked for the client address, `/api/health`
* included; it now resolves no address and is named once at boot.
*
* Both are read per call, so the suites can change them; the parsed CIDR
* list is cached by its raw value.
*/
import { BlockList, isIP } from "node:net";
import { isPublicIp } from "@/lib/validations/notifications";
// ── TRUST_PROXY_HOPS ──────────────────────────────────────────────────────
/**
* The configured hop count, `1` when unset or empty, or `null` when the
* value is not a non-negative integer.
*/
export function parseTrustProxyHops(raw: string | undefined): number | null {
// Empty is unset. The shipped docker-compose.yml passes
// `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"`, which is an empty string for
// every operator who never set it.
const trimmed = raw?.trim() ?? "";
if (trimmed === "") return 1;
if (!/^\d+$/.test(trimmed)) return null;
return parseInt(trimmed, 10);
}
// ── TRUSTED_PROXY_CIDRS ───────────────────────────────────────────────────
export type TrustedProxyList =
/** Unset: the private-address default applies. */
| { kind: "unset" }
| { kind: "list"; entries: string[]; blockList: BlockList }
/** Set, but an entry is not a CIDR. No peer may forward. */
| { kind: "invalid"; invalid: string[] };
/** `10.0.0.0/8`, `127.0.0.1`, `fd00::/8`; a bare address is a /32 or /128. */
function parseCidr(
entry: string,
): { address: string; prefix: number; family: "ipv4" | "ipv6" } | null {
const [address = "", prefixText, extra] = entry.split("/");
if (extra !== undefined) return null;
const version = isIP(address);
if (version === 0) return null;
const max = version === 4 ? 32 : 128;
if (prefixText !== undefined && !/^\d{1,3}$/.test(prefixText)) return null;
const prefix = prefixText === undefined ? max : Number(prefixText);
if (prefix > max) return null;
return { address, prefix, family: version === 4 ? "ipv4" : "ipv6" };
}
let cached: { raw: string; list: TrustedProxyList } | null = null;
export function parseTrustedProxyCidrs(
raw: string | undefined,
): TrustedProxyList {
const value = raw?.trim() ?? "";
if (cached && cached.raw === value) return cached.list;
let list: TrustedProxyList;
if (value === "") {
list = { kind: "unset" };
} else {
const entries = value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const blockList = new BlockList();
const invalid: string[] = [];
for (const entry of entries) {
const cidr = parseCidr(entry);
if (!cidr) {
invalid.push(entry);
continue;
}
blockList.addSubnet(cidr.address, cidr.prefix, cidr.family);
}
list =
invalid.length > 0 || entries.length === 0
? { kind: "invalid", invalid: invalid.length > 0 ? invalid : [value] }
: { kind: "list", entries, blockList };
}
cached = { raw: value, list };
return list;
}
/** Whether a connection from `peer` may report the client's address. */
export function peerMayForward(peer: string): boolean {
const list = parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS);
if (list.kind === "unset") return !isPublicIp(peer);
if (list.kind === "invalid") return false;
const family = isIP(peer) === 6 ? "ipv6" : "ipv4";
return list.blockList.check(peer, family);
}
// ── The boot line ─────────────────────────────────────────────────────────
/**
* One line naming the rule in force, for the boot log: which peers may
* forward, how many hops are read, and what is wrong with a value that
* cannot be used.
*/
export function describeProxyTrust(): {
level: "info" | "error";
message: string;
} {
const hopsRaw = process.env.TRUST_PROXY_HOPS;
const hops = parseTrustProxyHops(hopsRaw);
const list = parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS);
const problems: string[] = [];
if (hops === null) {
problems.push(
`TRUST_PROXY_HOPS=${JSON.stringify(hopsRaw)} is not a non-negative integer, so no client address is read from forwarding headers`,
);
}
if (list.kind === "invalid") {
problems.push(
`TRUSTED_PROXY_CIDRS has entries that are not CIDRs (${list.invalid.map((e) => JSON.stringify(e)).join(", ")}), so no connection may report a client address`,
);
}
const peers =
list.kind === "list"
? `only connections from ${list.entries.join(", ")} (TRUSTED_PROXY_CIDRS)`
: list.kind === "unset"
? "connections from any loopback, private, CGNAT, unique-local or link-local address (TRUSTED_PROXY_CIDRS unset; behind Docker Desktop, OrbStack or docker-proxy that includes every caller of a published port)"
: "no connection";
if (problems.length > 0) {
return {
level: "error",
message: `[proxy-trust] ${problems.join("; ")}. Fix the value; see docs/self-hosting/reverse-proxy.md.`,
};
}
return {
level: "info",
message:
hops === 0
? "[proxy-trust] TRUST_PROXY_HOPS=0: forwarding headers are never read; the client address is the connection's own."
: `[proxy-trust] forwarding headers are read from ${peers}, ${hops} hop${hops === 1 ? "" : "s"} deep (TRUST_PROXY_HOPS).`,
};
}
/**
* Log the rule in force once, at boot (`src/instrumentation.ts`). The
* logger is a parameter, as in the readiness summary: the production build
* strips direct `console.log` calls.
*/
export function logProxyTrustAtBoot(
log: Pick<Console, "log" | "error"> = console,
): void {
const { level, message } = describeProxyTrust();
if (level === "error") log.error(message);
else log.log(message);
}
+66 -4
View File
@@ -64,12 +64,74 @@ function readPng(bytes: Buffer): ImageDimensions | null {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
/**
* The area a GIF decoder has to allocate: the logical screen, grown to cover
* every frame's image descriptor.
*
* The logical screen alone is not that size. A decoder sizes its buffer from
* the frame it decodes, so a file declaring a 10 x 10 screen and a
* 20000 x 20000 frame passed a screen-only check and allocated 1.6 GB. Every
* block is walked (GIF89a, section 17 onwards): extensions and image data are
* skipped sub-block by sub-block, each image descriptor is read, and anything
* that is not a block a decoder accepts makes the file unreadable. The walk
* is linear in the file's length.
*
* A file that ends inside a frame's image data is still read up to there,
* since decoders draw what they got; one that ends inside a header or a
* descriptor, or has no frame at all, has no size.
*/
function readGif(bytes: Buffer): ImageDimensions | null {
// "GIF87a" / "GIF89a", then the logical screen: width, height (LE).
if (bytes.length < 10 || bytes.toString("ascii", 0, 4) !== "GIF8") {
return null;
// "GIF87a" / "GIF89a", then the logical screen descriptor (7 bytes).
if (bytes.length < 13) return null;
const signature = bytes.toString("ascii", 0, 6);
if (signature !== "GIF87a" && signature !== "GIF89a") return null;
let width = bytes.readUInt16LE(6);
let height = bytes.readUInt16LE(8);
const screenFlags = bytes[10]!;
let offset = 13;
if (screenFlags & 0x80) offset += 3 << ((screenFlags & 0x07) + 1);
/** Past a run of data sub-blocks; `bytes.length` when the file ends in it. */
const skipSubBlocks = (from: number): number => {
let at = from;
while (at < bytes.length) {
const size = bytes[at]!;
at += 1;
if (size === 0) return at;
at += size;
}
return bytes.length;
};
let frames = 0;
while (offset < bytes.length) {
const introducer = bytes[offset]!;
if (introducer === 0x3b) break; // trailer
if (introducer === 0x21) {
// Extension: introducer, label, then data sub-blocks.
if (offset + 2 > bytes.length) return null;
offset = skipSubBlocks(offset + 2);
continue;
}
if (introducer !== 0x2c) return null;
// Image descriptor: left, top, width, height (LE), flags.
if (offset + 10 > bytes.length) return null;
const left = bytes.readUInt16LE(offset + 1);
const top = bytes.readUInt16LE(offset + 3);
const frameWidth = bytes.readUInt16LE(offset + 5);
const frameHeight = bytes.readUInt16LE(offset + 7);
const frameFlags = bytes[offset + 9]!;
if (frameWidth === 0 || frameHeight === 0) return null;
width = Math.max(width, left + frameWidth);
height = Math.max(height, top + frameHeight);
frames += 1;
offset += 10;
if (frameFlags & 0x80) offset += 3 << ((frameFlags & 0x07) + 1);
// The LZW minimum code size, then the image data sub-blocks.
if (offset >= bytes.length) break;
offset = skipSubBlocks(offset + 1);
}
return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
return frames > 0 ? { width, height } : null;
}
/**
@@ -285,6 +285,32 @@ describe("runDocumentAiRun", () => {
);
});
it("reads as interactive work of the run's owner, so the document reader serves it first", async () => {
let seen: unknown = null;
executeDocumentIndex.mockImplementation(async () => {
const { currentPdfWork } = await import("@/lib/documents/pdf-isolation");
seen = currentPdfWork();
return { ok: true, data: {} };
});
await runDocumentAiRun("run1", NOW + 2_700_000, now);
expect(seen).toMatchObject({ owner: "u1", lane: "interactive" });
});
it("answers a busy document reader with a retryable code and hands the reservation back", async () => {
const { PdfReaderUnavailableError } = await import("@/lib/documents/pdfjs");
executeDocumentIndex.mockRejectedValue(
new PdfReaderUnavailableError("busy"),
);
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
expect(settleAiRunBudget).toHaveBeenCalledWith("u1", budget, 0, null);
expect(failAiRun).toHaveBeenCalledWith(
"run1",
expect.objectContaining({ status: 503, errorCode: "aiRuns.readerBusy" }),
"RUNNING",
new Date(NOW),
);
});
it("fails a run whose document is gone and hands the reservation back", async () => {
loadOwnedDocument.mockResolvedValue(null);
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
@@ -177,4 +177,47 @@ describe("thumbnailRetryDue", () => {
thumbnailRetryDue({ reason: "error", attempts: 3, failedAt: at }, before),
).toBe(false);
});
it("gives a renderer that ended for no measured reason the ordinary attempts, not a final verdict", async () => {
const { thumbnailRetryDue } =
await import("../document-thumbnail-backfill");
expect(
thumbnailRetryDue(
{ reason: "interrupted", attempts: 1, failedAt: at },
before,
),
).toBe(true);
expect(
thumbnailRetryDue(
{ reason: "interrupted", attempts: 3, failedAt: at },
before,
),
).toBe(false);
expect(
thumbnailRetryDue(
{ reason: "resource-limit", attempts: 1, failedAt: at },
before,
),
).toBe(false);
});
});
describe("runDocumentThumbnail when the reader cannot take the document now", () => {
it("throws for a busy reader so the job runs again, and records nothing", async () => {
generateThumbnail.mockResolvedValue({ ok: false, reason: "reader-busy" });
await expect(
runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" }),
).rejects.toMatchObject({ name: "PdfReaderUnavailableError" });
expect(failureUpsert).not.toHaveBeenCalled();
});
it("records an interrupted render as a failure the backfill tries again", async () => {
generateThumbnail.mockResolvedValue({ ok: false, reason: "interrupted" });
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
expect(failureUpsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ reason: "interrupted" }),
}),
);
});
});
+47 -1
View File
@@ -42,6 +42,8 @@ import { loadOwnedDocument } from "@/lib/documents/ai-route-support";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { refusalAsFailure } from "@/lib/documents/ai-runs/http";
import { executeDocumentExtract } from "@/lib/documents/ai-runs/extract-run";
import { withPdfWork } from "@/lib/documents/pdf-isolation";
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
import {
DOCUMENT_INDEX_MODEL_CALLS,
executeDocumentIndex,
@@ -185,6 +187,34 @@ async function armDeadline(
});
}
/**
* Move a running run's deadline by `ms`, never past the job's own expiry.
* The document reader calls it (`PdfWork.extendDeadline`): the deadline is
* model time, and a wait in the reader's queue or the read itself is not, so
* a person's read is not timed out because other work held the reader.
*/
export async function moveRunDeadline(
runId: string,
ms: number,
deadlineAt: number | undefined,
): Promise<void> {
const delta = Math.round(ms);
if (deadlineAt === undefined) {
await prisma.$executeRaw`
UPDATE document_ai_runs
SET expires_at = expires_at + (${delta}::double precision * interval '1 millisecond')
WHERE id = ${runId} AND status = 'RUNNING'`;
return;
}
await prisma.$executeRaw`
UPDATE document_ai_runs
SET expires_at = LEAST(
expires_at + (${delta}::double precision * interval '1 millisecond'),
${new Date(deadlineAt)}
)
WHERE id = ${runId} AND status = 'RUNNING'`;
}
async function runDocumentIndex(
run: ClaimedAiRun,
deadlineAt: number | undefined,
@@ -614,7 +644,16 @@ export async function runDocumentAiRun(
let outcome: AiRunOutcome<AiRunResult>;
try {
outcome = await RUN_DISPATCH[run.kind](run, deadlineAt, now);
// A person asked for this read and is waiting on it: the document
// reader serves it ahead of background work, in turn with other people.
outcome = await withPdfWork(
{
owner: run.userId,
lane: "interactive",
extendDeadline: (ms) => moveRunDeadline(run.id, ms, deadlineAt),
},
() => RUN_DISPATCH[run.kind](run, deadlineAt, now),
);
} catch (err) {
// Everything that throws here threw before the body took over the
// reservation, so it goes back here.
@@ -622,6 +661,13 @@ export async function runDocumentAiRun(
const failure: AiRunFailure = (err instanceof RunRefused
? err.failure
: null) ??
(err instanceof PdfReaderUnavailableError
? {
status: 503,
errorCode: AI_RUN_ERROR_CODES.readerBusy,
message: "The document reader is busy. Try again in a minute.",
}
: null) ??
refusalAsFailure(err) ?? {
status: 500,
errorCode: AI_RUN_ERROR_CODES.failed,
@@ -55,6 +55,7 @@ import {
upsertContentIndex,
} from "@/lib/documents/content-index";
import { transcribeDocument } from "@/lib/documents/describe";
import { withPdfWork } from "@/lib/documents/pdf-isolation";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
@@ -320,7 +321,10 @@ export async function runContentIndexBackfillForUser(
skipped += 1;
continue;
}
const vision = await prepareVisionInput(document, pick.pdfSupported);
const vision = await withPdfWork(
{ owner: userId, lane: "background" },
() => prepareVisionInput(document, pick.pdfSupported),
);
if (!vision.ok) {
// Not vision-indexable after all (undecryptable, unreadable bytes,
// or a PDF the rasteriser could not render) — refund and move on so
+7 -6
View File
@@ -15,6 +15,7 @@
import { STATUS_MODEL_CALLS } from "@/lib/ai/ai-budgets";
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
import { indexDocumentContent } from "@/lib/documents/index-document";
import { withPdfWork } from "@/lib/documents/pdf-isolation";
import type { OcrSchedule } from "@/lib/documents/ocr-plan";
import { ocrReadCallMs } from "@/lib/documents/ocr-read";
import { prisma } from "@/lib/db";
@@ -50,9 +51,9 @@ export async function runDocumentIndex(
const { userId, documentId } = payload;
if (!userId || !documentId) return;
const localOnly = payload.localOnly === true;
const outcome = await indexDocumentContent(userId, documentId, {
localOnly,
});
const outcome = await withPdfWork({ owner: userId, lane: "background" }, () =>
indexDocumentContent(userId, documentId, { localOnly }),
);
annotate({
action: { name: "documents.autoIndex.run" },
meta: outcome.indexed
@@ -72,9 +73,9 @@ export async function runDocumentIndex(
options.deadlineAt === undefined
? undefined
: await stagingSchedule(userId, options.deadlineAt, options.now);
await maybeAutoStageLabFacts(userId, documentId, { schedule }).catch(
caughtAs("documents.auto_index.lab_staging_failed"),
);
await withPdfWork({ owner: userId, lane: "background" }, () =>
maybeAutoStageLabFacts(userId, documentId, { schedule }),
).catch(caughtAs("documents.auto_index.lab_staging_failed"));
}
}
+22 -1
View File
@@ -43,6 +43,8 @@ import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { withPdfWork } from "@/lib/documents/pdf-isolation";
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
import { encryptDocumentSummary } from "@/lib/documents/store";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -81,6 +83,25 @@ export interface DocumentSummaryPayload {
enqueuedAt?: string;
}
/**
* The stored original as provider input, read as this account's background
* work. A busy reader is not a property of the document: the job throws, and
* pg-boss runs it again.
*/
async function prepareSummaryVision(
userId: string,
document: Parameters<typeof prepareVisionInput>[0],
pick: { pdfSupported: boolean },
): Promise<ReturnType<typeof prepareVisionInput>> {
const vision = await withPdfWork({ owner: userId, lane: "background" }, () =>
prepareVisionInput(document, pick.pdfSupported),
);
if (!vision.ok && vision.reason === "readerBusy") {
throw new PdfReaderUnavailableError("busy");
}
return vision;
}
/**
* Generate + persist ONE document's background summary (owner-scoped). Never
* throws for an expected outcome — every gate that fails resolves to a tagged
@@ -210,7 +231,7 @@ export async function runDocumentSummaryJob(
const document = await loadOwnedDocument(userId, documentId);
if (!document) return;
const vision = await prepareVisionInput(document, pick.pdfSupported);
const vision = await prepareSummaryVision(userId, document, pick);
if (!vision.ok) {
// Cannot read this file with the picked provider (decrypt miss, unsupported
// type, or a PDF needing an Anthropic provider). Graceful no-op.
+9 -3
View File
@@ -51,9 +51,15 @@ const THUMBNAILABLE_MIMES = [
/**
* Render refusals that the same bytes will always get again: the first page
* renders blank, the render comes out empty, the image is past the pixel cap
* or its size cannot be read, the PDF crossed the renderer's memory or time bound. One attempt settles
* them; queueing a document that exhausts the renderer again at every boot
* would only repeat the exhaustion.
* or its size cannot be read, the document crossed a bound the renderer
* measured (its memory ceiling, its time limit, its heap cap). One attempt
* settles them; queueing a document that exhausts the renderer again at
* every boot would only repeat the exhaustion.
*
* `interrupted` is deliberately not here: a renderer that ended without a
* measured cause may have been taken by the kernel because the whole
* container ran short, which says nothing about the document. It gets the
* ordinary bounded attempts.
*/
const FINAL_THUMBNAIL_FAILURES = [
"raster-failed",
+13 -4
View File
@@ -29,6 +29,8 @@ import {
decryptDocumentContent,
encryptThumbnail,
} from "@/lib/documents/store";
import { withPdfWork } from "@/lib/documents/pdf-isolation";
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
import { generateThumbnail } from "@/lib/documents/thumbnail";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { withBackgroundEvent } from "@/lib/logging/background";
@@ -55,10 +57,12 @@ export async function runDocumentThumbnail(
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");
});
await withPdfWork({ owner: userId, lane: "background" }, () =>
renderThumbnail(userId, documentId, (reason) => {
evt.addMeta("thumbnail_missing_reason", reason);
evt.elevateLevel("warn");
}),
);
});
}
@@ -107,6 +111,11 @@ async function renderThumbnail(
}
const result = await generateThumbnail(bytes, document.mimeType);
if (!result.ok && result.reason === "reader-busy") {
// The reader was taken by other work for longer than a background item
// waits. Nothing is wrong with the document: pg-boss runs the job again.
throw new PdfReaderUnavailableError("busy");
}
if (!result.ok) {
annotate({
action: { name: "documents.thumbnail.run" },
+5
View File
@@ -20,6 +20,7 @@ import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { refusalAsFailure } from "@/lib/documents/ai-runs/http";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import { meteredReadBudget } from "@/lib/documents/ai-runs/metered-budget";
import { readerBusyFailure } from "@/lib/documents/ai-runs/run-support";
import {
AI_RUN_ERROR_CODES,
type AiRunBudget,
@@ -221,6 +222,10 @@ export async function executeOcrExtraction(args: {
input.pick.pdfSupported,
schedule,
);
if (!plan.ok && plan.busy) {
await settle(false);
return readerBusyFailure();
}
if (!plan.ok) {
annotate({
action: { name: "labs.ocr.fileRejected" },
@@ -29,6 +29,7 @@ import {
type AIProvider,
type CompletionParams,
} from "@/lib/ai/types";
import { readerBusyFailure } from "@/lib/documents/ai-runs/run-support";
import { settleAiRunBudget } from "@/lib/documents/ai-runs/store";
import type {
AiRunBudget,
@@ -232,6 +233,13 @@ export async function executeSupplementLabelExtraction(args: {
];
} else {
const raster = await rasterizePdf(bytes);
if (
!raster.ok &&
(raster.reason === "busy" || raster.reason === "interrupted")
) {
await settleAiRunBudget(userId, budget, 0, null);
return readerBusyFailure();
}
if (!raster.ok) {
await settleAiRunBudget(userId, budget, 0, null);
return failure({
+1
View File
@@ -57,6 +57,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
aiRuns: [
"aiRuns.failed",
"aiRuns.notFound",
"aiRuns.readerBusy",
"aiRuns.timedOut",
"aiRuns.workerUnavailable",
],
+1 -1
View File
@@ -64,7 +64,7 @@ const aiRun = z
})
.nullable()
.describe(
"Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows), `aiRuns.failed` (anything else).",
"Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows), `aiRuns.readerBusy` (the server's document reader was busy past the wait a read gets, or stopped before it answered; nothing was read or charged, try again in a minute), `aiRuns.failed` (anything else).",
),
createdAt: z.string(),
startedAt: z.string().nullable(),
+2 -1
View File
@@ -288,7 +288,8 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
type: "apiKey",
in: "cookie",
name: "healthlog_session",
description: "Browser session cookie — set by /api/auth/login.",
description:
"Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is `Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps `healthlog_session`.",
},
},
};
+2 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { shouldRunWeb } from "@/lib/process-type";
import { readSessionCookie } from "@/lib/auth/session-cookie";
import {
findRetiredRoute,
retiredRouteEnvelope,
@@ -560,8 +561,7 @@ export function proxy(request: NextRequest) {
// can never be session-gated without also being uncacheable.
const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic;
if (isSessionGatedPage) {
const hasSession = request.cookies.has("healthlog_session");
if (!hasSession) {
if (!readSessionCookie(request.cookies)) {
return applyBaselineSecurityHeaders(
NextResponse.redirect(new URL("/auth/login", request.url)),
);