refactor(api): share cookie, signature and AI error handling instead of copying them

This commit is contained in:
Amruth Pillai
2026-09-29 10:15:56 +02:00
parent 8951f45a3a
commit a42cacc057
15 changed files with 63 additions and 186 deletions
-10
View File
@@ -1,13 +1,3 @@
export function getCookie(request: Request, name: string): string | undefined {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return;
for (const part of cookieHeader.split(";")) {
const [rawName, ...rawValue] = part.trim().split("=");
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
}
}
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
if ([...headers].length === 0) return response;
+1 -1
View File
@@ -15,7 +15,7 @@ export async function handlePublicResumePdf(
request: Request,
username: string,
slug: string,
trustedClient = "unknown",
trustedClient: string,
): Promise<Response> {
try {
const result = await createPublicResumePdf({
+1 -1
View File
@@ -25,7 +25,7 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
],
});
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
export async function handleOpenApi(request: Request, trustedClient: string) {
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
}
+1 -1
View File
@@ -14,7 +14,7 @@ const rpcHandler = new RPCHandler(router, {
],
});
export async function handleRpc(request: Request, trustedClient = "unknown") {
export async function handleRpc(request: Request, trustedClient: string) {
const resHeaders = new Headers();
const { response } = await rpcHandler.handle(request, {
prefix: "/api/rpc",
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { parse } from "hono/utils/cookie";
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
import { getCookie } from "../http/headers";
export function getRequestLocale(request: Request): Locale {
const locale = getCookie(request, "locale");
const locale = parse(request.headers.get("cookie") ?? "", "locale").locale;
return isLocale(locale) ? locale : defaultLocale;
}
+2 -2
View File
@@ -259,7 +259,7 @@ export const serveWebDistStatic = serveStatic({
});
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
if (pathname === "/" && env.ROOT_RESUME_ID) {
return {
"Content-Type": "text/html; charset=UTF-8",
"X-Robots-Tag": "noindex, follow",
@@ -308,7 +308,7 @@ export async function handleWebApp(request: Request) {
const html = await fs.readFile(indexHtmlPath, "utf-8");
const canonicalUrl = new URL("/", env.APP_URL).toString();
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
if (pathname === "/" && env.ROOT_RESUME_ID) {
// Root configuration never discloses a target in the HTML shell. The public API
// gates data and browser metadata; shell requests must not count extra views.
const shell = html
+28 -71
View File
@@ -9,39 +9,28 @@ import { atsReviewInputSchema, atsReviewOutputSchema, reviewResumeText } from ".
import { improveInputSchema, improveLine, improveOutputSchema } from "./improve";
import { aiService, fileInputSchema } from "./service";
function isInvalidAiBaseUrlError(error: unknown): boolean {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
/**
* Every AI procedure fails the same ways: no ENCRYPTION_SECRET, a bad base URL, the provider erroring (its cause kept
* for upstream error reporters), or the model returning a shape that can't be used, named per procedure.
*/
function rethrowAiError(error: unknown, invalidStructure: string): never {
if (error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE")
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
});
if (error instanceof Error && error.message === "INVALID_AI_BASE_URL")
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
if (error instanceof AISDKError)
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause: error });
if (error instanceof ZodError)
throw new ORPCError("BAD_REQUEST", { message: invalidStructure, cause: flattenError(error) });
throw error;
}
function isAiProviderGatewayError(error: unknown): boolean {
return error instanceof AISDKError;
}
function isCredentialEncryptionUnavailable(error: unknown): boolean {
return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE";
}
/** Throws a BAD_GATEWAY ORPCError, preserving the original cause for upstream error reporters. */
function throwAiProviderGatewayError(cause?: unknown): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause });
}
function throwAiProviderConfigError(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
function throwCredentialEncryptionUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
});
}
function throwResumeStructureError(error: ZodError): never {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
const aiErrors = {
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
} as const;
async function getRunnableProvider(userId: string, aiProviderId?: string) {
const provider = aiProviderId
@@ -67,10 +56,7 @@ export const aiRouter = {
})
.input(z.object({ aiProviderId: z.string().optional(), file: fileInputSchema }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.errors(aiErrors)
.handler(async ({ context, input }): Promise<ResumeData> => {
try {
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
@@ -82,11 +68,7 @@ export const aiRouter = {
file: input.file,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
rethrowAiError(error, "Invalid resume data structure");
}
}),
@@ -112,10 +94,7 @@ export const aiRouter = {
}),
)
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.errors(aiErrors)
.handler(async ({ context, input }) => {
try {
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
@@ -128,11 +107,7 @@ export const aiRouter = {
file: input.file,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
rethrowAiError(error, "Invalid resume data structure");
}
}),
@@ -150,10 +125,7 @@ export const aiRouter = {
.input(atsReviewInputSchema)
.use(aiRequestRateLimit)
.output(atsReviewOutputSchema)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.errors(aiErrors)
.handler(async ({ context, input }) => {
try {
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
@@ -166,13 +138,7 @@ export const aiRouter = {
baseURL: provider.baseURL ?? "",
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", { message: "Invalid ATS review structure", cause: flattenError(error) });
}
throw error;
rethrowAiError(error, "Invalid ATS review structure");
}
}),
@@ -190,10 +156,7 @@ export const aiRouter = {
.input(improveInputSchema)
.use(aiRequestRateLimit)
.output(improveOutputSchema)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.errors(aiErrors)
.handler(async ({ context, input }) => {
try {
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
@@ -206,13 +169,7 @@ export const aiRouter = {
baseURL: provider.baseURL ?? "",
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", { message: "Invalid suggestion structure", cause: flattenError(error) });
}
throw error;
rethrowAiError(error, "Invalid suggestion structure");
}
}),
};
@@ -6,7 +6,6 @@ const dbMock = vi.hoisted(() => ({
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
execute: vi.fn(),
transaction: vi.fn(),
}));
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
@@ -62,9 +61,10 @@ const existing = {
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
};
// Awaitable directly, or after `.for("update")` for the reads that lock their row.
const createSelectChain = (rows: unknown[]) => ({
from: () => ({
where: () => Promise.resolve(rows),
where: () => Object.assign(Promise.resolve(rows), { for: () => Promise.resolve(rows) }),
}),
});
@@ -80,7 +80,6 @@ beforeEach(() => {
dbMock.insert.mockReset();
dbMock.update.mockReset();
dbMock.delete.mockReset();
dbMock.execute.mockReset();
dbMock.transaction.mockReset();
dbMock.transaction.mockImplementation((callback) => callback(dbMock));
resumeGetByIdMock.mockReset();
@@ -287,7 +286,6 @@ describe("applicationService timeline entries", () => {
at: new Date("2026-07-10T15:45:00.000Z"),
});
expect(dbMock.transaction).toHaveBeenCalled();
expect(dbMock.execute).toHaveBeenCalled();
});
it("normalizes JSONB date strings when editing timeline dates", async () => {
@@ -396,7 +394,6 @@ describe("applicationService timeline entries", () => {
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(dbMock.transaction).toHaveBeenCalled();
expect(dbMock.execute).toHaveBeenCalled();
});
it("deletes older stage entries", async () => {
@@ -528,16 +528,11 @@ export const applicationService = {
const patch = Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined));
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
where ${schema.application.id} = ${id} and ${schema.application.userId} = ${userId}
for update
`);
const [existing] = await tx
.select()
.from(schema.application)
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)));
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)))
.for("update");
if (!existing) throw new ORPCError("NOT_FOUND");
const target = existing.activity.find((entry) => entry.id === entryId);
@@ -569,16 +564,11 @@ export const applicationService = {
text?: string | undefined;
}) => {
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
for update
`);
const [existing] = await tx
.select()
.from(schema.application)
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.for("update");
if (!existing) throw new ORPCError("NOT_FOUND");
const activity = existing.activity.map((entry) => {
@@ -621,16 +611,11 @@ export const applicationService = {
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => {
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
for update
`);
const [existing] = await tx
.select()
.from(schema.application)
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
.for("update");
if (!existing) throw new ORPCError("NOT_FOUND");
const entry = existing.activity.find((item) => item.id === input.entryId);
+9 -36
View File
@@ -1,4 +1,5 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { parseCookies } from "better-auth/cookies";
import { env } from "@reactive-resume/env/server";
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
@@ -9,55 +10,27 @@ const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
const safeEquals = (value: string, expected: string) => {
export const safeEquals = (value: string, expected: string) => {
const valueBuffer = Buffer.from(value);
const expectedBuffer = Buffer.from(expected);
if (valueBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(valueBuffer, expectedBuffer);
};
const parseCookieHeader = (cookieHeader: string | null): Map<string, string> => {
const cookies = new Map<string, string>();
if (!cookieHeader) return cookies;
for (const part of cookieHeader.split(";")) {
const [rawName, ...rawValue] = part.trim().split("=");
if (!rawName || rawValue.length === 0) continue;
cookies.set(rawName, rawValue.join("="));
}
return cookies;
};
const serializeCookie = (
name: string,
value: string,
options: { path: string; httpOnly: boolean; sameSite: "lax"; maxAge: number; secure: boolean },
) => {
const parts = [`${name}=${value}`, `Path=${options.path}`, `Max-Age=${options.maxAge}`, "SameSite=Lax"];
if (options.httpOnly) parts.push("HttpOnly");
if (options.secure) parts.push("Secure");
return parts.join("; ");
};
export const hasResumeAccess = (requestHeaders: Headers, resumeId: string, passwordHash: string | null) => {
if (!passwordHash) return false;
const cookieName = getResumeAccessCookieName(resumeId);
const cookieValue = parseCookieHeader(requestHeaders.get("cookie")).get(cookieName);
const cookieValue = parseCookies(requestHeaders.get("cookie") ?? "").get(cookieName);
if (!cookieValue) return false;
const expected = signResumeAccessToken(resumeId, passwordHash);
return safeEquals(cookieValue, expected);
};
export const grantResumeAccess = (responseHeaders: Headers, resumeId: string, passwordHash: string) => {
const cookie = serializeCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: RESUME_ACCESS_TTL_SECONDS,
secure: env.APP_URL.startsWith("https"),
});
responseHeaders.append("Set-Cookie", cookie);
const value = `${getResumeAccessCookieName(resumeId)}=${signResumeAccessToken(resumeId, passwordHash)}`;
const secure = env.APP_URL.startsWith("https") ? "; Secure" : "";
responseHeaders.append(
"Set-Cookie",
`${value}; Path=/; Max-Age=${RESUME_ACCESS_TTL_SECONDS}; SameSite=Lax; HttpOnly${secure}`,
);
};
+1 -5
View File
@@ -6,11 +6,7 @@ import { pdfExportRateLimit } from "../../middleware/rate-limit";
import { parseStoredResumeData } from "./resume-data-validation";
import { resumeService } from "./service";
export {
createResumePdfDownloadUrl,
MAX_PDF_DOWNLOAD_URL_TTL_SECONDS,
verifyResumePdfDownloadToken,
} from "./pdf-download-url";
export { createResumePdfDownloadUrl, verifyResumePdfDownloadToken } from "./pdf-download-url";
type CreateResumePdfDownloadInput = {
id: string;
@@ -8,25 +8,21 @@ vi.mock("@reactive-resume/env/server", () => ({
},
}));
const { MAX_PDF_DOWNLOAD_URL_TTL_SECONDS, createResumePdfDownloadUrl, verifyResumePdfDownloadToken } = await import(
"./pdf-download-url"
);
const { createResumePdfDownloadUrl, verifyResumePdfDownloadToken } = await import("./pdf-download-url");
describe("resume PDF signed download URLs", () => {
it("creates a URL with a token that is capped at 10 minutes", () => {
it("creates a URL with a token that lasts 10 minutes", () => {
const now = new Date("2026-06-01T10:00:00.000Z");
const result = createResumePdfDownloadUrl({
resumeId: "resume-1",
userId: "user-1",
now,
ttlSeconds: 60 * 60,
});
const url = new URL(result.url);
const token = url.searchParams.get("token");
expect(MAX_PDF_DOWNLOAD_URL_TTL_SECONDS).toBe(600);
expect(url.origin).toBe("https://example.com");
expect(url.pathname).toBe("/api/resumes/resume-1/pdf");
expect(token).toBeTruthy();
@@ -1,7 +1,9 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { createHmac } from "node:crypto";
import { env } from "@reactive-resume/env/server";
import { safeEquals } from "./access";
export const MAX_PDF_DOWNLOAD_URL_TTL_SECONDS = 10 * 60;
// Long enough to click, short enough that a leaked link is useless soon after.
const TTL_SECONDS = 10 * 60;
type PdfDownloadTokenPayload = {
v: 1;
@@ -15,7 +17,6 @@ type CreateResumePdfDownloadUrlInput = {
resumeId: string;
userId: string;
now?: Date;
ttlSeconds?: number;
};
type VerifyResumePdfDownloadTokenInput = {
@@ -36,11 +37,6 @@ type VerifyResumePdfDownloadTokenResult =
reason: "expired" | "invalid_signature" | "malformed" | "resume_mismatch";
};
function resolveTtlSeconds(ttlSeconds: number | undefined) {
if (ttlSeconds === undefined || !Number.isFinite(ttlSeconds)) return MAX_PDF_DOWNLOAD_URL_TTL_SECONDS;
return Math.min(Math.max(Math.floor(ttlSeconds), 1), MAX_PDF_DOWNLOAD_URL_TTL_SECONDS);
}
function encodeJson(value: unknown) {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}
@@ -53,13 +49,6 @@ function sign(payload: string) {
return createHmac("sha256", env.AUTH_SECRET).update(payload).digest("base64url");
}
function signaturesMatch(actual: string, expected: string) {
const actualBuffer = Buffer.from(actual);
const expectedBuffer = Buffer.from(expected);
return actualBuffer.byteLength === expectedBuffer.byteLength && timingSafeEqual(actualBuffer, expectedBuffer);
}
function parsePayload(value: unknown): PdfDownloadTokenPayload | null {
if (!value || typeof value !== "object") return null;
@@ -73,13 +62,8 @@ function parsePayload(value: unknown): PdfDownloadTokenPayload | null {
return payload as PdfDownloadTokenPayload;
}
export function createResumePdfDownloadUrl({
resumeId,
userId,
now = new Date(),
ttlSeconds,
}: CreateResumePdfDownloadUrlInput) {
const expiresInSeconds = resolveTtlSeconds(ttlSeconds);
export function createResumePdfDownloadUrl({ resumeId, userId, now = new Date() }: CreateResumePdfDownloadUrlInput) {
const expiresInSeconds = TTL_SECONDS;
const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000);
const payload = encodeJson({
v: 1,
@@ -106,7 +90,7 @@ export function verifyResumePdfDownloadToken({
}: VerifyResumePdfDownloadTokenInput): VerifyResumePdfDownloadTokenResult {
const [payload, signature, extra] = token.split(".");
if (!payload || !signature || extra !== undefined) return { ok: false, reason: "malformed" };
if (!signaturesMatch(signature, sign(payload))) return { ok: false, reason: "invalid_signature" };
if (!safeEquals(signature, sign(payload))) return { ok: false, reason: "invalid_signature" };
try {
const parsed = parsePayload(decodeJson(payload));
+1 -1
View File
@@ -4,7 +4,7 @@ import { env } from "@reactive-resume/env/server";
let redis: Redis | undefined;
export function getRedis(): Redis | null {
const url = env.REDIS_URL?.trim();
const url = env.REDIS_URL;
if (!url) return null;
if (!redis) {
redis = new Redis(url, {
-1
View File
@@ -13,7 +13,6 @@ vi.mock("@reactive-resume/api/context", () => ({
}));
vi.mock("@reactive-resume/api/features/resume/export", () => ({
MAX_PDF_DOWNLOAD_URL_TTL_SECONDS: 600,
createResumePdfDownloadUrl: mocks.createResumePdfDownloadUrl,
}));