Squashed commit of the following:

commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:13:38 2026 +0200

    chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies

commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:08:04 2026 +0200

    fix: remove timestamp conflict guard

commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 12:11:51 2026 +0200

    chore(release): v5.1.5

commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:35 2026 +0200

    revert: compose.yml

commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:08 2026 +0200

    refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086

commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:10:41 2026 +0200

    refactor(pdf): simplify sidebar section filtering and update summary feature logic

commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:53:37 2026 +0200

    chore: update pnpm-lock.yaml and turbo.json

commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:38:30 2026 +0200

    fix(polyfill): add tested polyfill for Map Upsert methods

commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:23:29 2026 +0200

    Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration

    # Conflicts:
    #	packages/api/src/services/agent-url.ts
    #	packages/runtime-externals/package.json

commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:22:12 2026 +0200

    chore: preserve branch changes before main sync

commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Mon May 18 07:50:28 2026 +0200

    chore: lot of fixes for monorepo migration

commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 13:57:17 2026 +0200

    chore: update knip version and refine web app routing with new SEO endpoints

commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 12:10:06 2026 +0200

    refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint

commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:54:29 2026 +0200

    chore: update dependencies and enhance PWA metadata in web app

commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:12:35 2026 +0200

    docs: revise manifest-only pwa testing scope

commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:11:33 2026 +0200

    docs: add manifest-only pwa design

commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:05:04 2026 +0200

    chore(dev): simplify server proxy config

commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:50 2026 +0200

    docs: add unsafe oauth redirect plan

commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:34 2026 +0200

    feat(auth): add unsafe oauth redirect flag

commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:55:02 2026 +0200

    docs: design unsafe oauth redirect flag

commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:22:04 2026 +0200

    chore: update translation source paths

commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:09:25 2026 +0200

    refactor(arch): react spa + hono migration

commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 01:10:47 2026 +0200

    docs: add docker nightly tagging design

commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Thu May 14 20:05:44 2026 +0200

    feat: migrate to hono spa server
This commit is contained in:
Amruth Pillai
2026-05-19 13:14:21 +02:00
parent 5b1297fa2b
commit 62f8270b3e
518 changed files with 29398 additions and 26871 deletions
+3 -2
View File
@@ -8,7 +8,8 @@ import { user } from "@reactive-resume/db/schema";
interface ORPCContext {
locale: Locale;
reqHeaders?: Headers;
reqHeaders: Headers;
resHeaders?: Headers;
}
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
@@ -77,7 +78,7 @@ export async function resolveUserFromRequestHeaders(headers: Headers): Promise<U
const base = os.$context<ORPCContext>();
export const publicProcedure = base.use(async ({ context, next }) => {
const user = await resolveUserFromRequestHeaders(context.reqHeaders ?? new Headers());
const user = await resolveUserFromRequestHeaders(context.reqHeaders);
return next({
context: {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { redactResumeForViewer } from "../helpers/resume-access-policy";
import { redactResumeForViewer } from "../features/resume/access-policy";
import { resumeDto } from "./resume";
describe("resume DTO output validation", () => {
+2 -2
View File
@@ -1,8 +1,8 @@
import { createSelectSchema } from "drizzle-zod";
import z from "zod";
import * as schema from "@reactive-resume/db/schema";
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { jsonPatchOperationSchema } from "@reactive-resume/utils/resume/patch";
const resumeSchema = createSelectSchema(schema.resume, {
id: z.string().describe("The ID of the resume."),
@@ -39,7 +39,7 @@ export const resumeDto = {
input: z.object({ username: z.string(), slug: z.string() }),
// `name` is the owner-chosen dashboard title and is intentionally redacted
// to an empty string for non-owner viewers (see redactResumeForViewer in
// helpers/resume-access-policy.ts). Relax the `min(1)` constraint here so
// features/resume/access-policy.ts). Relax the `min(1)` constraint here so
// the redacted public response passes output validation.
output: resumeSchema
.omit({ name: true, password: true, userId: true, createdAt: true, updatedAt: true })
@@ -0,0 +1,24 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { isAgentEnvironmentUnavailable, throwUnavailable } from "./routing";
import { agentService } from "./service";
export const actionsRouter = {
revert: protectedProcedure
.route({
method: "POST",
path: "/agent/actions/{id}/revert",
tags: ["Agent"],
operationId: "revertAgentAction",
summary: "Restore agent action snapshot",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -0,0 +1,62 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { storageUploadRateLimit } from "../../middleware/rate-limit";
import { isAgentEnvironmentUnavailable, throwUnavailable } from "./routing";
import { agentService } from "./service";
function base64ToUint8Array(value: string) {
return Uint8Array.from(Buffer.from(value, "base64"));
}
export const attachmentsRouter = {
create: protectedProcedure
.route({
method: "POST",
path: "/agent/attachments",
tags: ["Agent"],
operationId: "createAgentAttachment",
summary: "Create agent attachment",
})
.input(
z.object({
threadId: z.string(),
filename: z.string().trim().min(1),
mediaType: z.string().trim().min(1),
data: z.string().min(1),
}),
)
.use(storageUploadRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.attachments.create({
userId: context.user.id,
threadId: input.threadId,
filename: input.filename,
mediaType: input.mediaType,
data: base64ToUint8Array(input.data),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/attachments/{id}",
tags: ["Agent"],
operationId: "deleteAgentAttachment",
summary: "Delete agent attachment",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -0,0 +1,84 @@
import type { UIMessage } from "ai";
import z from "zod";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { isAgentEnvironmentUnavailable, isUiMessage, throwUnavailable } from "./routing";
import { agentService } from "./service";
export const messagesRouter = {
send: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/send",
tags: ["Agent"],
operationId: "sendAgentMessage",
summary: "Send agent message",
})
.input(
z.object({
threadId: z.string(),
message: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }),
attachmentIds: z.array(z.string().trim().min(1)).max(10).optional(),
}),
)
.use(aiRequestRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.messages.send({
userId: context.user.id,
threadId: input.threadId,
message: input.message,
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
stop: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/stop",
tags: ["Agent"],
operationId: "stopAgentMessage",
summary: "Stop active agent run",
})
.input(
z.object({
threadId: z.string(),
partialMessage: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }).optional(),
}),
)
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.messages.stop({
userId: context.user.id,
threadId: input.threadId,
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
resume: protectedProcedure
.route({
method: "GET",
path: "/agent/messages/resume",
tags: ["Agent"],
operationId: "resumeAgentMessages",
summary: "Resume agent message stream",
})
.input(z.object({ threadId: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./agent-resume";
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./resume";
describe("agent resume setup helpers", () => {
it("names duplicated resumes as AI drafts", () => {
+11
View File
@@ -0,0 +1,11 @@
import { actionsRouter } from "./actions";
import { attachmentsRouter } from "./attachments";
import { messagesRouter } from "./messages";
import { threadsRouter } from "./threads";
export const agentRouter = {
threads: threadsRouter,
messages: messagesRouter,
attachments: attachmentsRouter,
actions: actionsRouter,
};
@@ -0,0 +1,23 @@
import type { UIMessage } from "ai";
import { ORPCError } from "@orpc/client";
export function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
export function throwUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
});
}
export function isUiMessage(value: unknown): value is UIMessage {
if (!value || typeof value !== "object") return false;
const message = value as Partial<UIMessage>;
return (
typeof message.id === "string" &&
(message.role === "system" || message.role === "user" || message.role === "assistant") &&
Array.isArray(message.parts)
);
}
@@ -62,6 +62,10 @@ vi.mock("@reactive-resume/db/schema", () => ({
id: "agent_actions.id",
threadId: "agent_actions.thread_id",
userId: "agent_actions.user_id",
resumeId: "agent_actions.resume_id",
kind: "agent_actions.kind",
status: "agent_actions.status",
appliedUpdatedAt: "agent_actions.applied_updated_at",
createdAt: "agent_actions.created_at",
},
agentAttachment: {
@@ -85,6 +89,7 @@ vi.mock("drizzle-orm", () => ({
count: () => ({ type: "count" }),
desc: (value: unknown) => ({ type: "desc", value }),
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
gte: (left: unknown, right: unknown) => ({ type: "gte", left, right }),
inArray: (left: unknown, values: unknown[]) => ({ type: "inArray", left, values }),
isNull: (value: unknown) => ({ type: "isNull", value }),
max: (value: unknown) => ({ type: "max", value }),
@@ -97,28 +102,29 @@ vi.mock("ai", () => ({
ToolLoopAgent: vi.fn(),
}));
vi.mock("./ai", () => ({ getAgentModel: vi.fn() }));
vi.mock("./ai-credentials", () => ({ assertAgentEnvironment: vi.fn() }));
vi.mock("./ai-providers", () => ({ aiProvidersService: aiProvidersServiceMock }));
vi.mock("./resume", () => ({ resumeService: resumeServiceMock }));
vi.mock("./storage", () => ({ getStorageService: vi.fn(() => storageServiceMock), inferContentType: vi.fn() }));
vi.mock("./agent-patches", () => ({ createInverseResumePatches: vi.fn() }));
vi.mock("./agent-resume", () => ({
vi.mock("../ai/service", () => ({ getAgentModel: vi.fn() }));
vi.mock("../ai/credentials", () => ({ assertAgentEnvironment: vi.fn() }));
vi.mock("../ai-providers/service", () => ({ aiProvidersService: aiProvidersServiceMock }));
vi.mock("../resume/service", () => ({ resumeService: resumeServiceMock }));
vi.mock("../storage/service", () => ({
getStorageService: vi.fn(() => storageServiceMock),
inferContentType: vi.fn(),
}));
vi.mock("./resume", () => ({
buildAgentDraftResumeName: vi.fn(),
buildUniqueAgentDraftSlug: vi.fn(),
}));
vi.mock("./agent-run-state", () => ({
vi.mock("./runs", () => ({
claimActiveAgentRun: claimActiveAgentRunMock,
clearActiveAgentRunIfCurrent: clearActiveAgentRunIfCurrentMock,
}));
vi.mock("./agent-streams", () => ({
vi.mock("./streams", () => ({
agentStreamLifecycle: { create: vi.fn(), resume: vi.fn() },
}));
vi.mock("./agent-tools", () => ({
vi.mock("./tools", () => ({
buildAgentInstructions: vi.fn(),
buildAgentTools: vi.fn(() => ({})),
}));
vi.mock("./agent-url", () => ({ fetchUrlForAgent: vi.fn() }));
vi.mock("@reactive-resume/schema/resume/default", () => ({ defaultResumeData: {} }));
vi.mock("@reactive-resume/utils/string", () => ({ generateId: () => "test-id" }));
vi.mock("@orpc/server", () => ({ streamToEventIterator: vi.fn() }));
@@ -200,6 +206,14 @@ function selectOrderByResult(rows: unknown[]) {
return { from };
}
function selectWhereOrderByLimitResult(rows: unknown[]) {
const limit = vi.fn(async () => rows);
const orderBy = vi.fn(() => ({ limit }));
const where = vi.fn(() => ({ orderBy }));
const from = vi.fn(() => ({ where }));
return { from };
}
describe("agentService.threads.get", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -235,7 +249,7 @@ describe("agentService.threads.get", () => {
updatedAt: new Date(),
});
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const result = await agentService.threads.get({ id: "thread-1", userId: "user-1" });
@@ -251,7 +265,7 @@ describe("buildAttachmentModelParts", () => {
});
it("converts readable, image, supported binary, and unsupported attachments into model parts", async () => {
const { buildAttachmentModelParts } = await import("./agent");
const { buildAttachmentModelParts } = await import("./service");
const imageBytes = new Uint8Array([1, 2, 3]);
const pdfBytes = new Uint8Array([4, 5, 6]);
@@ -342,7 +356,7 @@ describe("agentService.messages.send", () => {
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
const { convertToModelMessages, ToolLoopAgent } = await import("ai");
const { agentStreamLifecycle } = await import("./agent-streams");
const { agentStreamLifecycle } = await import("./streams");
const { streamToEventIterator } = await import("@orpc/server");
vi.mocked(convertToModelMessages).mockResolvedValue([
{ role: "user", content: [{ type: "text", text: "Use this file" }] },
@@ -354,7 +368,7 @@ describe("agentService.messages.send", () => {
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.messages.send({
threadId: "thread-1",
@@ -384,6 +398,119 @@ describe("agentService.messages.send", () => {
]);
});
it("stores snapshotData and applies a valid JSON Patch without a timestamp conflict guard", async () => {
const activeThread = buildActiveThread();
const persistedMessage = {
id: "message-1",
userId: "user-1",
threadId: "thread-1",
role: "user",
status: "completed",
sequence: 0,
uiMessage: {
id: "ui-message-1",
role: "user",
parts: [{ type: "text", text: "Add a custom field" }],
},
};
dbMock.select
.mockImplementationOnce(() => selectLimitResult([activeThread]))
.mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }]))
.mockImplementationOnce(() => selectWhereResult([{ total: 1 }]))
.mockImplementationOnce(() => selectOrderByResult([persistedMessage]));
dbMock.insert.mockReturnValue({
values: vi.fn(() => ({ returning: vi.fn(async () => [persistedMessage]) })),
});
dbMock.update.mockReturnValue({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) });
claimActiveAgentRunMock.mockResolvedValue(true);
aiProvidersServiceMock.getRunnableById.mockResolvedValue({
id: "provider-1",
provider: "openai",
model: "gpt-5",
apiKey: "secret",
baseURL: null,
});
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
const { convertToModelMessages, ToolLoopAgent } = await import("ai");
const { agentStreamLifecycle } = await import("./streams");
const { buildAgentTools } = await import("./tools");
const { streamToEventIterator } = await import("@orpc/server");
vi.mocked(convertToModelMessages).mockResolvedValue([
{ role: "user", content: [{ type: "text", text: "Add a custom field" }] },
]);
class MockToolLoopAgent {
stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) }));
}
vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never);
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
const { agentService } = await import("./service");
await agentService.messages.send({
threadId: "thread-1",
userId: "user-1",
message: {
id: "ui-message-1",
role: "user",
parts: [{ type: "text", text: "Add a custom field" }],
// biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test
} as any,
});
const beforeData = { basics: { customFields: [] } };
const beforeUpdatedAt = new Date("2026-05-01T00:00:00.000Z");
const patchedUpdatedAt = new Date("2026-05-02T00:00:00.000Z");
const operations = [
{ op: "add", path: "/basics/customFields/-", value: { id: "field-1", icon: "phosphor", text: "x", link: "" } },
];
const insertValues: unknown[] = [];
resumeServiceMock.getById.mockResolvedValue({ data: beforeData, updatedAt: beforeUpdatedAt });
resumeServiceMock.patchInTransaction.mockResolvedValue({ id: "resume-1", updatedAt: patchedUpdatedAt });
dbMock.insert.mockReturnValue({
values: vi.fn((value) => {
insertValues.push(value);
return {
returning: vi.fn(async () => [
{
id: "action-1",
...value,
messageId: null,
revertedAt: null,
revertMessage: null,
createdAt: patchedUpdatedAt,
updatedAt: patchedUpdatedAt,
},
]),
};
}),
});
const toolConfig = vi.mocked(buildAgentTools).mock.calls.at(-1)?.[0];
// biome-ignore lint/suspicious/noExplicitAny: captured mocked tool config has intentionally loose handler types
const result = await (toolConfig as any).handlers.applyResumePatch({ title: "Append field", operations });
expect(resumeServiceMock.patchInTransaction).toHaveBeenCalledWith(dbMock, {
id: "resume-1",
userId: "user-1",
operations,
});
expect(insertValues).toContainEqual(
expect.objectContaining({
operations,
snapshotData: beforeData,
baseUpdatedAt: beforeUpdatedAt,
appliedUpdatedAt: patchedUpdatedAt,
}),
);
expect(result).toEqual(expect.objectContaining({ actionId: "action-1", resumeId: "resume-1" }));
});
it("persists canonical attachment UI parts, links selected attachments, and appends server-read model parts", async () => {
const activeThread = buildActiveThread();
const attachment = buildAttachment({
@@ -451,7 +578,7 @@ describe("agentService.messages.send", () => {
storageServiceMock.read.mockResolvedValue({ data: new TextEncoder().encode("hello"), contentType: "text/plain" });
const { convertToModelMessages, ToolLoopAgent } = await import("ai");
const { agentStreamLifecycle } = await import("./agent-streams");
const { agentStreamLifecycle } = await import("./streams");
const { streamToEventIterator } = await import("@orpc/server");
const streamMock = vi.fn(async () => ({
toUIMessageStream: vi.fn(() => new ReadableStream()),
@@ -466,7 +593,7 @@ describe("agentService.messages.send", () => {
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.messages.send({
threadId: "thread-1",
@@ -571,10 +698,27 @@ describe("agentService.messages.send", () => {
choices: ["Only change the main resume header name"],
},
output: "Only change the main resume header name",
callProviderMetadata: { openai: { itemId: "fc_duplicate_item" } },
resultProviderMetadata: { openai: { itemId: "fc_duplicate_item" } },
},
],
},
};
const answeredAssistantModelInput = {
...answeredAssistantMessage.uiMessage,
parts: [
{
type: "tool-ask_user_question",
toolCallId: "call-1",
state: "output-available",
input: {
question: "How broadly should I rename?",
choices: ["Only change the main resume header name"],
},
output: "Only change the main resume header name",
},
],
};
const updateSets: unknown[] = [];
dbMock.select
@@ -600,7 +744,7 @@ describe("agentService.messages.send", () => {
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
const { convertToModelMessages, ToolLoopAgent } = await import("ai");
const { agentStreamLifecycle } = await import("./agent-streams");
const { agentStreamLifecycle } = await import("./streams");
const { streamToEventIterator } = await import("@orpc/server");
vi.mocked(convertToModelMessages).mockResolvedValue([
{ role: "user", content: [{ type: "text", text: "Change the name" }] },
@@ -627,7 +771,7 @@ describe("agentService.messages.send", () => {
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.messages.send({
threadId: "thread-1",
@@ -662,7 +806,7 @@ describe("agentService.messages.send", () => {
}),
}),
);
expect(convertToModelMessages).toHaveBeenCalledWith([userMessage.uiMessage, answeredAssistantMessage.uiMessage]);
expect(convertToModelMessages).toHaveBeenCalledWith([userMessage.uiMessage, answeredAssistantModelInput]);
});
it("repairs legacy user-answer messages that followed an unresolved ask-user-question tool call", async () => {
@@ -760,7 +904,7 @@ describe("agentService.messages.send", () => {
aiProvidersServiceMock.markUsed.mockResolvedValue(undefined);
const { convertToModelMessages, ToolLoopAgent } = await import("ai");
const { agentStreamLifecycle } = await import("./agent-streams");
const { agentStreamLifecycle } = await import("./streams");
const { streamToEventIterator } = await import("@orpc/server");
vi.mocked(convertToModelMessages).mockResolvedValue([{ role: "user", content: [{ type: "text", text: "Retry" }] }]);
class MockToolLoopAgent {
@@ -770,7 +914,7 @@ describe("agentService.messages.send", () => {
vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream());
vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never);
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.messages.send({
threadId: "thread-1",
@@ -819,7 +963,7 @@ describe("agentService.messages.send", () => {
baseURL: null,
});
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const sending = agentService.messages.send({
threadId: "thread-1",
@@ -850,7 +994,7 @@ describe("agentService.messages.send", () => {
baseURL: null,
});
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const sending = agentService.messages.send({
threadId: "thread-1",
@@ -878,7 +1022,7 @@ describe("agentService.messages.send", () => {
return { from };
});
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const sending = agentService.messages.send({
threadId: "thread-1",
@@ -919,7 +1063,7 @@ describe("agentService.threads.archive", () => {
const updateSet = vi.fn(() => ({ where: updateWhere }));
dbMock.update.mockReturnValue({ set: updateSet });
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.threads.archive({ id: "thread-1", userId: "user-1" });
@@ -949,7 +1093,7 @@ describe("agentService.threads.archive", () => {
clearActiveAgentRunIfCurrentMock.mockResolvedValue(undefined);
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.threads.archive({ id: "thread-1", userId: "user-1" });
@@ -985,7 +1129,7 @@ describe("agentService.threads.archive", () => {
clearActiveAgentRunIfCurrentMock.mockRejectedValue(new Error("boom"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.threads.archive({ id: "thread-1", userId: "user-1" });
@@ -1010,7 +1154,7 @@ describe("agentService.threads.delete", () => {
return { from };
});
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const deleting = agentService.threads.delete({ id: "thread-x", userId: "user-y" });
@@ -1048,7 +1192,7 @@ describe("agentService.threads.delete", () => {
storageServiceMock.delete.mockResolvedValue(undefined);
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
await agentService.threads.delete({ id: "thread-own", userId: "user-own" });
@@ -1075,7 +1219,7 @@ describe("agentService.actions.revert", () => {
title: "Tighten summary",
summary: null,
operations: [{ op: "replace", path: "/basics/name", value: "Bob" }],
inverseOperations: [{ op: "replace", path: "/basics/name", value: "Alice" }],
snapshotData: { basics: { name: "Alice" } },
baseUpdatedAt: new Date("2026-05-01T00:00:00.000Z"),
appliedUpdatedAt: new Date("2026-05-02T00:00:00.000Z"),
revertedAt: null,
@@ -1086,11 +1230,18 @@ describe("agentService.actions.revert", () => {
};
}
it("reverts an applied action, calls resumeService.patch with the inverse operations, and updates the DB row", async () => {
it("rolls back an applied action by restoring its snapshot and marks later applied actions rolled_back", async () => {
const action = buildAction();
const updatedAction = { ...action, status: "reverted", revertedAt: new Date(), revertMessage: null };
const laterAction = buildAction({
id: "action-2",
appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"),
createdAt: new Date("2026-05-03T00:00:00.000Z"),
});
const updatedAction = { ...action, status: "rolled_back", revertedAt: new Date(), revertMessage: null };
dbMock.select.mockImplementation(() => selectLimitResult([action]));
dbMock.select
.mockImplementationOnce(() => selectLimitResult([action]))
.mockImplementationOnce(() => selectWhereOrderByLimitResult([laterAction]));
const updateReturning = vi.fn(async () => [updatedAction]);
const updateWhere = vi.fn(() => ({ returning: updateReturning }));
@@ -1102,35 +1253,41 @@ describe("agentService.actions.revert", () => {
updatedAt: new Date("2026-05-03T00:00:00.000Z"),
});
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" });
expect(resumeServiceMock.patchInTransaction).toHaveBeenCalledWith(dbMock, {
id: "resume-1",
userId: "user-1",
operations: action.inverseOperations,
expectedUpdatedAt: action.appliedUpdatedAt,
operations: [{ op: "replace", path: "", value: action.snapshotData }],
expectedUpdatedAt: laterAction.appliedUpdatedAt,
});
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
status: "reverted",
revertMessage: null,
status: "rolled_back",
revertMessage: "This patch was rolled back when the resume was restored to an earlier state.",
appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"),
}),
);
expect(result.status).toBe("reverted");
expect(result.status).toBe("rolled_back");
});
it("returns a conflicted action when resumeService.patch throws RESUME_VERSION_CONFLICT", async () => {
it("returns a conflicted action when snapshot restore throws RESUME_VERSION_CONFLICT", async () => {
const action = buildAction();
const latestAction = buildAction({
id: "action-2",
appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"),
});
const conflictedAction = {
...action,
status: "conflicted",
revertMessage: "The resume changed after this action was applied.",
};
dbMock.select.mockImplementation(() => selectLimitResult([action]));
dbMock.select
.mockImplementationOnce(() => selectLimitResult([action]))
.mockImplementationOnce(() => selectWhereOrderByLimitResult([latestAction]));
const updateReturning = vi.fn(async () => [conflictedAction]);
const updateWhere = vi.fn(() => ({ returning: updateReturning }));
@@ -1139,7 +1296,7 @@ describe("agentService.actions.revert", () => {
resumeServiceMock.patchInTransaction.mockRejectedValue(new ORPCError("RESUME_VERSION_CONFLICT"));
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" });
@@ -1156,30 +1313,44 @@ describe("agentService.actions.revert", () => {
expect(result.revertMessage).toBe("The resume changed after this action was applied.");
});
it("returns the existing action unchanged when its status is already reverted", async () => {
it("returns the existing action unchanged when its status is already rolled_back", async () => {
const action = buildAction({
status: "reverted",
status: "rolled_back",
revertedAt: new Date("2026-05-03T00:00:00.000Z"),
});
dbMock.select.mockImplementation(() => selectLimitResult([action]));
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" });
expect(resumeServiceMock.patch).not.toHaveBeenCalled();
expect(dbMock.update).not.toHaveBeenCalled();
expect(result.status).toBe("reverted");
expect(result.status).toBe("rolled_back");
expect(result.id).toBe("action-1");
});
it("throws BAD_REQUEST when an applied legacy action has no snapshotData", async () => {
const action = buildAction({ snapshotData: null });
dbMock.select.mockImplementation(() => selectLimitResult([action]));
const { agentService } = await import("./service");
const reverting = agentService.actions.revert({ id: "action-1", userId: "user-1" });
await expect(reverting).rejects.toBeInstanceOf(ORPCError);
await expect(reverting).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(resumeServiceMock.patch).not.toHaveBeenCalled();
});
it("throws BAD_REQUEST when the action has no resumeId", async () => {
const action = buildAction({ resumeId: null });
dbMock.select.mockImplementation(() => selectLimitResult([action]));
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const reverting = agentService.actions.revert({ id: "action-1", userId: "user-1" });
@@ -1191,7 +1362,7 @@ describe("agentService.actions.revert", () => {
it("throws NOT_FOUND when no matching action is found", async () => {
dbMock.select.mockImplementation(() => selectLimitResult([]));
const { agentService } = await import("./agent");
const { agentService } = await import("./service");
const reverting = agentService.actions.revert({ id: "missing-id", userId: "user-1" });
@@ -1,26 +1,24 @@
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
import type { Locale } from "@reactive-resume/utils/locale";
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
import type { FilePart, ImagePart, ModelMessage, TextPart, UIMessage } from "ai";
import type { getModel } from "./ai";
import type { getModel } from "../ai/service";
import { ORPCError } from "@orpc/client";
import { streamToEventIterator } from "@orpc/server";
import { convertToModelMessages, stepCountIs, ToolLoopAgent } from "ai";
import { and, asc, count, desc, eq, inArray, isNull, max, sql } from "drizzle-orm";
import { and, asc, count, desc, eq, gte, inArray, isNull, max, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { createInverseResumePatches } from "./agent-patches";
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./agent-resume";
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./agent-run-state";
import { agentStreamLifecycle } from "./agent-streams";
import { buildAgentInstructions, buildAgentTools } from "./agent-tools";
import { fetchUrlForAgent } from "./agent-url";
import { getAgentModel } from "./ai";
import { assertAgentEnvironment } from "./ai-credentials";
import { aiProvidersService } from "./ai-providers";
import { resumeService } from "./resume";
import { getStorageService, inferContentType } from "./storage";
import { assertAgentEnvironment } from "../ai/credentials";
import { getAgentModel } from "../ai/service";
import { aiProvidersService } from "../ai-providers/service";
import { resumeService } from "../resume/service";
import { getStorageService, inferContentType } from "../storage/service";
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./resume";
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./runs";
import { agentStreamLifecycle } from "./streams";
import { buildAgentInstructions, buildAgentTools } from "./tools";
const MAX_AGENT_STEPS = 30;
const MAX_ATTACHMENTS_PER_MESSAGE = 10;
@@ -37,6 +35,8 @@ const DIRECT_MODEL_FILE_ATTACHMENT_TYPES = new Set([
]);
const AGENT_ATTACHMENT_URL_PREFIX = "agent-attachment:";
const MAX_ATTACHMENT_TEXT_CHARS = 40_000;
const ROLLBACK_CONFLICT_MESSAGE = "The resume changed after this action was applied.";
const ROLLED_BACK_MESSAGE = "This patch was rolled back when the resume was restored to an earlier state.";
const activeRunControllers = new Map<string, AbortController>();
const canceledRunsWithPersistedPartial = new Set<string>();
@@ -111,7 +111,7 @@ function toAction(row: AgentActionRecord) {
title: row.title,
summary: row.summary,
operations: row.operations,
inverseOperations: row.inverseOperations,
canRollback: row.status === "applied" && row.snapshotData !== null,
baseUpdatedAt: row.baseUpdatedAt,
appliedUpdatedAt: row.appliedUpdatedAt,
revertedAt: row.revertedAt,
@@ -156,6 +156,30 @@ function withoutAgentAttachmentUiParts(message: UIMessage): UIMessage {
};
}
// Provider output metadata can contain provider-owned item IDs. Keep it in UI history, but do not replay it as model input.
function withoutProviderMetadata(message: UIMessage): UIMessage {
const cleanMessage = {
...message,
parts: message.parts.map((part) => {
const cleanPart = { ...part } as Record<string, unknown>;
delete cleanPart.providerMetadata;
delete cleanPart.callProviderMetadata;
delete cleanPart.resultProviderMetadata;
return cleanPart as UIMessage["parts"][number];
}),
} as Record<string, unknown> & UIMessage;
delete cleanMessage.providerMetadata;
delete cleanMessage.callProviderMetadata;
delete cleanMessage.resultProviderMetadata;
return cleanMessage;
}
function toModelInputMessage(message: UIMessage): UIMessage {
return withoutProviderMetadata(withoutAgentAttachmentUiParts(message));
}
type AgentToolPart = UIMessage["parts"][number] & {
errorText?: string;
output?: unknown;
@@ -679,14 +703,13 @@ async function applyResumePatch(input: {
operations: JsonPatchOperation[];
}) {
const before = await resumeService.getById({ id: input.resumeId, userId: input.userId });
const inverseOperations = createInverseResumePatches(before.data, input.operations);
const snapshotData = cloneResumeData(before.data);
const { action, patched } = await db.transaction(async (tx) => {
const patched = await resumeService.patchInTransaction(tx, {
id: input.resumeId,
userId: input.userId,
operations: input.operations,
expectedUpdatedAt: before.updatedAt,
});
const [action] = await tx
@@ -700,7 +723,7 @@ async function applyResumePatch(input: {
title: input.title,
...(input.summary !== undefined ? { summary: input.summary } : {}),
operations: input.operations,
inverseOperations,
snapshotData,
baseUpdatedAt: before.updatedAt,
appliedUpdatedAt: patched.updatedAt,
})
@@ -742,7 +765,6 @@ function createAgent(input: {
const tools = buildAgentTools({
provider: input.provider,
handlers: {
fetchUrl: fetchUrlForAgent,
readResume: async () => {
const resume = await resumeService.getById({ id: input.resumeId, userId: input.userId });
return {
@@ -1009,7 +1031,7 @@ export const agentService = {
{ threadId: input.threadId, userId: input.userId },
);
const messages = messageRows.map(toMessage);
const modelMessages = await convertToModelMessages(messages.map(withoutAgentAttachmentUiParts));
const modelMessages = await convertToModelMessages(messages.map(toModelInputMessage));
const attachmentModelParts = buildAttachmentModelParts(await readAttachmentModelInputs(attachmentsForModel));
const agent = createAgent({
userId: input.userId,
@@ -1206,37 +1228,75 @@ export const agentService = {
.limit(1);
if (!action) throw new ORPCError("NOT_FOUND");
if (action.status === "reverted") return toAction(action);
if (!action.resumeId) throw new ORPCError("BAD_REQUEST", { message: "The edited resume no longer exists." });
if (action.status !== "applied") return toAction(action);
if (action.kind !== "resume_patch") {
throw new ORPCError("BAD_REQUEST", { message: "Only resume patch actions can be rolled back." });
}
const resumeId = action.resumeId;
const snapshotData = action.snapshotData;
if (!resumeId) throw new ORPCError("BAD_REQUEST", { message: "The edited resume no longer exists." });
if (!snapshotData) {
throw new ORPCError("BAD_REQUEST", { message: "This legacy patch does not have a rollback snapshot." });
}
const [latestAction] = await db
.select()
.from(schema.agentAction)
.where(
and(
eq(schema.agentAction.userId, input.userId),
eq(schema.agentAction.threadId, action.threadId),
eq(schema.agentAction.resumeId, resumeId),
eq(schema.agentAction.kind, "resume_patch"),
eq(schema.agentAction.status, "applied"),
),
)
.orderBy(desc(schema.agentAction.appliedUpdatedAt))
.limit(1);
if (!latestAction) {
throw new ORPCError("BAD_REQUEST", { message: "This patch is no longer applied." });
}
try {
const { updated, reverted } = await db.transaction(async (tx) => {
const reverted = await resumeService.patchInTransaction(tx, {
id: action.resumeId as string,
const { updated, restored } = await db.transaction(async (tx) => {
const restored = await resumeService.patchInTransaction(tx, {
id: resumeId,
userId: input.userId,
operations: action.inverseOperations,
expectedUpdatedAt: action.appliedUpdatedAt,
operations: [{ op: "replace", path: "", value: cloneResumeData(snapshotData) }],
expectedUpdatedAt: latestAction.appliedUpdatedAt,
});
const [updated] = await tx
const rolledBackAt = new Date();
const updatedActions = await tx
.update(schema.agentAction)
.set({
status: "reverted",
revertedAt: new Date(),
revertMessage: null,
appliedUpdatedAt: reverted.updatedAt,
status: "rolled_back",
revertedAt: rolledBackAt,
revertMessage: ROLLED_BACK_MESSAGE,
appliedUpdatedAt: restored.updatedAt,
})
.where(and(eq(schema.agentAction.id, input.id), eq(schema.agentAction.userId, input.userId)))
.where(
and(
eq(schema.agentAction.userId, input.userId),
eq(schema.agentAction.threadId, action.threadId),
eq(schema.agentAction.resumeId, resumeId),
eq(schema.agentAction.kind, "resume_patch"),
eq(schema.agentAction.status, "applied"),
gte(schema.agentAction.appliedUpdatedAt, action.appliedUpdatedAt),
),
)
.returning();
const updated = updatedActions.find((row) => row.id === action.id);
if (!updated) throw new ORPCError("NOT_FOUND");
return { updated, reverted };
return { updated, restored };
});
await resumeService.notifyResumePatched({
resumeId: reverted.id,
resumeId: restored.id,
userId: input.userId,
updatedAt: reverted.updatedAt,
updatedAt: restored.updatedAt,
});
return toAction(updated);
@@ -1244,7 +1304,7 @@ export const agentService = {
if (error instanceof ORPCError && error.code === "RESUME_VERSION_CONFLICT") {
const [updated] = await db
.update(schema.agentAction)
.set({ status: "conflicted", revertMessage: "The resume changed after this action was applied." })
.set({ status: "conflicted", revertMessage: ROLLBACK_CONFLICT_MESSAGE })
.where(and(eq(schema.agentAction.id, input.id), eq(schema.agentAction.userId, input.userId)))
.returning();
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./agent-run-state";
import { createAgentStreamLifecycle, emptyAgentStream } from "./agent-streams";
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./runs";
import { createAgentStreamLifecycle, emptyAgentStream } from "./streams";
vi.mock("@reactive-resume/db/client", () => ({ db: { update: vi.fn() } }));
vi.mock("@reactive-resume/db/schema", () => ({
+102
View File
@@ -0,0 +1,102 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { isAgentEnvironmentUnavailable, throwUnavailable } from "./routing";
import { agentService } from "./service";
export const threadsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/agent/threads",
tags: ["Agent"],
operationId: "listAgentThreads",
summary: "List agent threads",
})
.handler(async ({ context }) => {
try {
return await agentService.threads.list({ userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
create: protectedProcedure
.route({
method: "POST",
path: "/agent/threads",
tags: ["Agent"],
operationId: "createAgentThread",
summary: "Create agent thread",
})
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.create({
userId: context.user.id,
locale: context.locale,
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
get: protectedProcedure
.route({
method: "GET",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "getAgentThread",
summary: "Get agent thread",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.get({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
archive: protectedProcedure
.route({
method: "POST",
path: "/agent/threads/{id}/archive",
tags: ["Agent"],
operationId: "archiveAgentThread",
summary: "Archive agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.archive({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "deleteAgentThread",
summary: "Delete agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -1,9 +1,8 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { describe, expect, it } from "vitest";
import { buildAgentInstructions, buildAgentTools } from "./agent-tools";
import { buildAgentInstructions, buildAgentTools } from "./tools";
const handlers = {
fetchUrl: async (url: string) => ({ url, title: null, content: "Fetched content" }),
readResume: async () => ({
id: "resume-1",
name: "Resume",
@@ -39,21 +38,18 @@ describe("agent tools", () => {
const tools = buildTools("openai");
expect(tools).toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("adds provider-native web search for OpenAI providers using the explicit default base URL", () => {
const tools = buildTools("openai", { baseURL: "https://api.openai.com/v1" });
expect(tools).toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("does not add provider-native web search for OpenAI providers with a custom base URL", () => {
const tools = buildTools("openai", { baseURL: "https://openai-compatible.example.com/v1" });
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it.each([
@@ -63,14 +59,12 @@ describe("agent tools", () => {
const tools = buildTools("openai", { baseURL });
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("does not add provider-native web search for unsupported OpenAI models", () => {
const tools = buildTools("openai", { model: "custom-model" });
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it.each<AIProvider>([
@@ -84,14 +78,16 @@ describe("agent tools", () => {
const tools = buildTools(provider);
expect(tools).not.toHaveProperty("web_search");
expect(tools).toHaveProperty("fetch_url");
});
it("keeps instructions explicit about native search versus exact URL fetching", () => {
it("keeps instructions explicit about native search availability", () => {
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("Use web_search");
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("Use fetch_url");
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("user-provided public URLs");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).not.toContain("Use web_search");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Use fetch_url");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Live web research is unavailable");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain(
"paste or attach the relevant content",
);
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Batch related JSON Patch operations");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("/basics/name");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("never /data/basics/name or /name");
@@ -3,8 +3,8 @@ import type { ToolSet } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { tool } from "ai";
import z from "zod";
import { jsonPatchOperationSchema } from "@reactive-resume/utils/resume/patch";
import { supportsProviderNativeWebSearch } from "./ai-capabilities";
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
import { supportsProviderNativeWebSearch } from "../ai/capabilities";
type AgentProviderConfig = {
provider: AIProvider;
@@ -13,7 +13,7 @@ type AgentProviderConfig = {
baseURL?: string | null;
};
export const applyResumePatchToolInputSchema = z.object({
const applyResumePatchToolInputSchema = z.object({
title: z.string().trim().min(1),
summary: z.string().trim().optional(),
operations: z.array(jsonPatchOperationSchema).min(1),
@@ -24,14 +24,13 @@ type ApplyResumePatchToolInput = z.infer<typeof applyResumePatchToolInputSchema>
type BuildAgentToolsInput = {
provider: AgentProviderConfig;
handlers: {
fetchUrl: (url: string) => Promise<unknown>;
readResume: () => Promise<unknown>;
readAttachment: (attachmentId: string) => Promise<unknown>;
applyResumePatch: (input: ApplyResumePatchToolInput) => Promise<unknown>;
};
};
export function buildProviderNativeAgentTools(provider: AgentProviderConfig): ToolSet {
function buildProviderNativeAgentTools(provider: AgentProviderConfig): ToolSet {
if (!supportsProviderNativeWebSearch(provider)) return {};
const openai = createOpenAI({
@@ -56,10 +55,10 @@ export function buildAgentInstructions({ hasProviderNativeSearch }: { hasProvide
"You are an expert resume-writing agent inside Reactive Resume. Help the user improve the working resume for a target role. Read the resume before editing. Respond to the user in clean Markdown with concise paragraphs, bullets, and bold text when it improves scanability. Apply concise, valid JSON Patch operations when changes are useful. Patch paths are evaluated against the resume data object returned by read_resume, so use paths like /basics/name for the visible name and never /data/basics/name or /name. apply_resume_patch cannot rename the resume file/title metadata. Batch related JSON Patch operations into one apply_resume_patch call for each coherent edit instead of making repeated patch calls for the same request. Ask the user a question when a missing preference blocks a high-confidence edit.";
if (!hasProviderNativeSearch) {
return `${baseInstructions} Use fetch_url for user-provided public HTTPS URLs, exact pages, public job descriptions, or company pages.`;
return `${baseInstructions} Live web research is unavailable with the selected provider or model. If the user asks you to browse, search the web, fetch a URL, or use current online context, briefly tell them live web research is unavailable with the selected provider/model and ask them to paste or attach the relevant content. Continue normal resume editing using the resume, chat context, and attachments.`;
}
return `${baseInstructions} Use web_search for open-ended or current web research, such as finding recent company, industry, or role context. Use fetch_url for user-provided public HTTPS URLs, exact pages, public job descriptions, or company pages.`;
return `${baseInstructions} Use web_search for live or current web research, including user-provided public URLs, job descriptions, company pages, and recent company, industry, or role context.`;
}
export function buildAgentTools(input: BuildAgentToolsInput): ToolSet {
@@ -74,12 +73,6 @@ export function buildAgentTools(input: BuildAgentToolsInput): ToolSet {
recommendedChoice: z.string().trim().optional(),
}),
}),
fetch_url: tool({
description:
"Fetch readable text from a public HTTPS URL, such as a job description. Private, local, and non-HTTPS URLs are blocked.",
inputSchema: z.object({ url: z.string().url() }),
execute: ({ url }) => input.handlers.fetchUrl(url),
}),
read_resume: tool({
description: "Read the current working resume JSON and metadata.",
inputSchema: z.object({}),
@@ -93,7 +86,7 @@ export function buildAgentTools(input: BuildAgentToolsInput): ToolSet {
}),
apply_resume_patch: tool({
description:
"Apply one cohesive batch of JSON Patch operations to the working resume data immediately. Paths are rooted at resume data; use /basics/name for the visible resume name, not /data/basics/name or /name. This tool cannot rename the resume file/title metadata. The user can revert the action later.",
"Apply one cohesive batch of JSON Patch operations to the working resume data immediately. Paths are rooted at resume data; use /basics/name for the visible resume name, not /data/basics/name or /name. This tool cannot rename the resume file/title metadata. The user can restore the draft to the snapshot captured before a patch later.",
inputSchema: applyResumePatchToolInputSchema,
execute: (toolInput) => input.handlers.applyResumePatch(toolInput),
}),
@@ -1,11 +1,11 @@
import type { AiProviderResponse } from "../services/ai-providers";
import type { AiProviderResponse } from "./service";
import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import z from "zod";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit } from "../middleware/rate-limit";
import { aiProvidersService } from "../services/ai-providers";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { aiProvidersService } from "./service";
const providerInput = z.object({
label: z.string().trim().min(1),
@@ -4,14 +4,14 @@ import { and, asc, desc, eq, sql } from "drizzle-orm";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { testConnection } from "./ai";
import {
assertCredentialEncryptionConfigured,
decryptCredential,
encryptCredential,
redactEncryptedCredential,
} from "./ai-credentials";
import { resolveAiBaseUrl } from "./ai-url-policy";
} from "../ai/credentials";
import { testConnection } from "../ai/service";
import { resolveAiBaseUrl } from "../ai/url-policy";
type AiProviderRecord = typeof schema.aiProvider.$inferSelect;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isDirectOpenAIProvider, supportsOpenAIWebSearch } from "./ai-capabilities";
import { isDirectOpenAIProvider, supportsOpenAIWebSearch } from "./capabilities";
describe("AI provider capabilities", () => {
it("identifies direct OpenAI base URL configs", () => {
@@ -14,7 +14,7 @@ const {
fingerprintCredential,
isAgentEnvironmentConfigured,
redactEncryptedCredential,
} = await import("./ai-credentials");
} = await import("./credentials");
describe("AI credential encryption", () => {
it("encrypts and decrypts provider API keys without storing plaintext", () => {
@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { env } from "@reactive-resume/env/server";
const CIPHER = "aes-256-gcm";
@@ -77,14 +77,6 @@ export function decryptCredential(payload: string) {
return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString("utf8");
}
export function credentialMatchesFingerprint(input: { apiKey: string; salt: string; hash: string }) {
const nextHash = fingerprintCredential(input.apiKey, input.salt);
const current = Buffer.from(input.hash, "hex");
const next = Buffer.from(nextHash, "hex");
return current.length === next.length && timingSafeEqual(current, next);
}
export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields {
return {
apiKeyFingerprint: fields.apiKeyHash,
@@ -92,11 +84,11 @@ export function redactEncryptedCredential(fields: StoredCredentialFields): Redac
};
}
export function isCredentialEncryptionConfigured() {
function isCredentialEncryptionConfigured() {
return !!getEncryptionSecret();
}
export function isAgentStreamingConfigured() {
function isAgentStreamingConfigured() {
return !!env.REDIS_URL?.trim();
}
@@ -5,11 +5,11 @@ import { type } from "@orpc/server";
import { AISDKError } from "ai";
import { flattenError, ZodError, z } from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit } from "../middleware/rate-limit";
import { aiService, fileInputSchema } from "../services/ai";
import { aiProvidersService } from "../services/ai-providers";
import { resumeService } from "../services/resume";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { aiProvidersService } from "../ai-providers/service";
import { resumeService } from "../resume/service";
import { aiService, fileInputSchema } from "./service";
function isInvalidAiBaseUrlError(error: unknown): boolean {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
@@ -27,10 +27,10 @@ import {
resumePatchProposalToolOutputSchema,
} from "@reactive-resume/ai/tools/patch-proposal";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { applyResumePatches } from "@reactive-resume/resume/patch";
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { applyResumePatches } from "@reactive-resume/utils/resume/patch";
import { supportsProviderNativeWebSearch } from "./ai-capabilities";
import { resolveAiBaseUrl } from "./ai-url-policy";
import { supportsProviderNativeWebSearch } from "./capabilities";
import { resolveAiBaseUrl } from "./url-policy";
const aiExtractionTemplate = buildAiExtractionTemplate();
@@ -104,7 +104,7 @@ export function getAgentModel(input: GetModelInput) {
return createOpenAI({ apiKey: input.apiKey, baseURL: resolveAiBaseUrl(input) }).responses(input.model);
}
export const aiCredentialsSchema = z.object({
const aiCredentialsSchema = z.object({
provider: aiProviderSchema,
model: z.string().trim().min(1),
apiKey: z.string().trim().min(1),
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { resolveAiBaseUrl } = await import("./url-policy");
describe("AI provider base URL policy", () => {
it("allows public HTTPS provider URLs", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(resolveAiBaseUrl({ provider: "openai", baseURL: "https://api.openai.com/v1" })).toBe(
"https://api.openai.com/v1",
);
});
it("blocks private and non-HTTPS provider URLs by default", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://localhost:11434/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
it("allows private and non-HTTPS provider URLs when explicitly enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://localhost:11434/v1" })).toBe(
"http://localhost:11434/v1",
);
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://10.0.0.5/v1" })).toBe(
"https://10.0.0.5/v1",
);
});
it("rejects non-HTTP schemes even when unsafe provider URLs are enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "file:///etc/passwd" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "ftp://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
});
@@ -29,7 +29,3 @@ export function resolveAiBaseUrl(input: ResolveAiBaseUrlInput) {
return assertSafeUrl(baseURL, "INVALID_AI_BASE_URL", { allowUnsafe: env.FLAG_ALLOW_UNSAFE_AI_BASE_URL });
}
export function assertFetchablePublicHttpsUrl(input: string) {
return assertSafeUrl(input, "URL_NOT_FETCHABLE");
}
@@ -1,6 +1,6 @@
import type { ProviderList } from "../services/auth";
import { protectedProcedure, publicProcedure } from "../context";
import { authService } from "../services/auth";
import type { ProviderList } from "./service";
import { protectedProcedure, publicProcedure } from "../../context";
import { authService } from "./service";
export const authRouter = {
providers: {
@@ -16,9 +16,9 @@ vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
// auth.ts also imports db client and storage; stub them with no-op surfaces.
vi.mock("@reactive-resume/db/client", () => ({ db: { delete: vi.fn() } }));
vi.mock("@reactive-resume/db/schema", () => ({ user: {} }));
vi.mock("./storage", () => ({ getStorageService: () => ({ delete: vi.fn() }) }));
vi.mock("../storage/service", () => ({ getStorageService: () => ({ delete: vi.fn() }) }));
const { authService } = await import("./auth");
const { authService } = await import("./service");
const resetEnv = () => {
envMock.GOOGLE_CLIENT_ID = undefined;
@@ -4,7 +4,7 @@ import { eq } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { getStorageService } from "./storage";
import { getStorageService } from "../storage/service";
export type ProviderList = Partial<Record<AuthProvider, string>>;
+1
View File
@@ -0,0 +1 @@
export type { FeatureFlags } from "./service";
@@ -1,7 +1,7 @@
import type { FeatureFlags } from "../services/flags";
import type { FeatureFlags } from "./service";
import z from "zod";
import { publicProcedure } from "../context";
import { flagsService } from "../services/flags";
import { publicProcedure } from "../../context";
import { flagsService } from "./service";
export const flagsRouter = {
get: publicProcedure
@@ -7,7 +7,7 @@ const envMock = vi.hoisted(() => ({
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { flagsService } = await import("./flags");
const { flagsService } = await import("./service");
describe("flagsService.getFlags", () => {
it("reads disableSignups + disableEmailAuth from env", () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./resume-access-policy";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy";
describe("isOwner", () => {
it("returns true when viewer.id matches resume.userId", () => {
@@ -0,0 +1,70 @@
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({ APP_URL: "https://example.com" }));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { hasResumeAccess, grantResumeAccess } = await import("./access");
const signToken = (resumeId: string, passwordHash: string) =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
const requestHeadersWithCookie = (name: string, value: string) =>
new Headers({ Cookie: `other=value; ${name}=${value}; theme=dark` });
describe("hasResumeAccess", () => {
it("returns false when no passwordHash is supplied", () => {
expect(hasResumeAccess(new Headers(), "resume-1", null)).toBe(false);
});
it("returns false when no cookie is present", () => {
expect(hasResumeAccess(new Headers(), "resume-1", "hash")).toBe(false);
});
it("returns true for a cookie value that matches the expected signed token", () => {
const token = signToken("resume-1", "hash");
const headers = requestHeadersWithCookie("resume_access_resume-1", token);
expect(hasResumeAccess(headers, "resume-1", "hash")).toBe(true);
});
it("returns false for a cookie value that does not match the expected signed token", () => {
const headers = requestHeadersWithCookie("resume_access_resume-1", "not-the-right-token");
expect(hasResumeAccess(headers, "resume-1", "hash")).toBe(false);
});
it("returns false when the cookie has a different length than the expected token", () => {
const headers = requestHeadersWithCookie("resume_access_resume-1", "short");
expect(hasResumeAccess(headers, "resume-1", "hash")).toBe(false);
});
});
describe("grantResumeAccess", () => {
it("appends a signed Set-Cookie header scoped to the resume id with httpOnly + sameSite=lax + 10-minute TTL", () => {
const responseHeaders = new Headers();
grantResumeAccess(responseHeaders, "resume-42", "hash");
const cookie = responseHeaders.get("Set-Cookie");
expect(cookie).toContain(`resume_access_resume-42=${signToken("resume-42", "hash")}`);
expect(cookie).toContain("Path=/");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
expect(cookie).toContain("Max-Age=600");
});
it("only marks the cookie secure when APP_URL is https", () => {
envMock.APP_URL = "http://localhost:3000";
const localHeaders = new Headers();
grantResumeAccess(localHeaders, "r", "h");
expect(localHeaders.get("Set-Cookie")).not.toContain("Secure");
envMock.APP_URL = "https://example.com";
const productionHeaders = new Headers();
grantResumeAccess(productionHeaders, "r", "h");
expect(productionHeaders.get("Set-Cookie")).toContain("Secure");
});
});
@@ -0,0 +1,63 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { env } from "@reactive-resume/env/server";
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
const RESUME_ACCESS_TTL_SECONDS = 60 * 10; // 10 minutes
const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_PREFIX}_${resumeId}`;
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
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);
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);
};
@@ -0,0 +1,23 @@
import z from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { protectedProcedure } from "../../context";
import { resumeService } from "./service";
export const analysisRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/analysis",
tags: ["Resume Analysis"],
operationId: "getResumeAnalysis",
summary: "Get latest resume analysis",
description:
"Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
successDescription: "The latest persisted resume analysis, or null if no analysis has been saved yet.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(storedResumeAnalysisSchema.nullable())
.handler(async ({ context, input }) => {
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
}),
};
@@ -1,115 +1,11 @@
import z from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure, publicProcedure } from "../context";
import { resumeDto } from "../dto/resume";
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../middleware/rate-limit";
import { resumeService } from "../services/resume";
import { subscribeResumeUpdated } from "../services/resume-events";
const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resumes/tags",
tags: ["Resumes"],
operationId: "listResumeTags",
summary: "List all resume tags",
description:
"Returns a sorted list of all unique tags across the authenticated user's resumes. Useful for populating tag filters in the dashboard. Requires authentication.",
successDescription: "A sorted array of unique tag strings.",
})
.output(z.array(z.string()))
.handler(async ({ context }) => {
return resumeService.tags.list({ userId: context.user.id });
}),
};
const statisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/statistics",
tags: ["Resume Statistics"],
operationId: "getResumeStatistics",
summary: "Get resume statistics",
description:
"Returns view and download statistics for the specified resume, including total counts and the timestamps of the last view and download. Requires authentication.",
successDescription: "The resume's view and download statistics.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(
z.object({
isPublic: z.boolean().describe("Whether the resume is currently public."),
views: z.number().describe("Total number of times the resume has been viewed."),
downloads: z.number().describe("Total number of times the resume has been downloaded."),
lastViewedAt: z.date().nullable().describe("Timestamp of the last view, or null if never viewed."),
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
}),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
};
const analysisRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/analysis",
tags: ["Resume Analysis"],
operationId: "getResumeAnalysis",
summary: "Get latest resume analysis",
description:
"Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
successDescription: "The latest persisted resume analysis, or null if no analysis has been saved yet.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(storedResumeAnalysisSchema.nullable())
.handler(async ({ context, input }) => {
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
}),
};
const updatesRouter = {
subscribe: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/updates",
tags: ["Resumes"],
operationId: "subscribeResumeUpdates",
summary: "Subscribe to resume updates",
description:
"Streams lightweight invalidation events when the specified resume changes. The event payload contains metadata only; clients should refetch the resume for canonical data.",
successDescription: "A stream of resume update invalidation events.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.handler(async function* ({ context, input, signal }) {
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
yield {
type: "resume.updated" as const,
resumeId: input.id,
userId: context.user.id,
updatedAt: resume.updatedAt.toISOString(),
mutation: "sync" as const,
};
yield* subscribeResumeUpdated({
resumeId: input.id,
userId: context.user.id,
...(signal ? { signal } : {}),
});
}),
};
export const resumeRouter = {
tags: tagsRouter,
statistics: statisticsRouter,
analysis: analysisRouter,
updates: updatesRouter,
import { protectedProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const crudRouter = {
list: protectedProcedure
.route({
method: "GET",
@@ -148,26 +44,6 @@ export const resumeRouter = {
return resumeService.getById({ id: input.id, userId: context.user.id });
}),
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resumes/{username}/{slug}",
tags: ["Resume Sharing"],
operationId: "getResumeBySlug",
summary: "Get public resume by username and slug",
description:
"Returns a publicly shared resume identified by the owner's username and the resume's slug. If the resume is password-protected and the viewer has not yet verified the password, a 401 error with code NEED_PASSWORD is returned. No authentication required for public resumes; if authenticated as the owner, private resumes are also accessible.",
successDescription: "The public resume with its full data.",
})
.input(resumeDto.getBySlug.input)
.output(resumeDto.getBySlug.output)
.handler(async ({ input, context }) => {
return resumeService.getBySlug({
...input,
...(context.user?.id ? { currentUserId: context.user.id } : {}),
});
}),
create: protectedProcedure
.route({
method: "POST",
@@ -320,77 +196,6 @@ export const resumeRouter = {
});
}),
setPassword: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "setResumePassword",
summary: "Set resume password",
description:
"Sets or updates a password on a resume. When a password is set, viewers of the public resume must enter the password before the resume data is revealed. The password must be between 6 and 64 characters. Requires authentication.",
successDescription: "The resume password was set successfully.",
})
.input(resumeDto.setPassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setPassword.output)
.handler(async ({ context, input }) => {
return resumeService.setPassword({
id: input.id,
userId: context.user.id,
password: input.password,
});
}),
verifyPassword: publicProcedure
.route({
method: "POST",
path: "/resumes/{username}/{slug}/password/verify",
tags: ["Resume Sharing"],
operationId: "verifyResumePassword",
summary: "Verify resume password",
description:
"Verifies a password for a password-protected public resume. On success, the viewer is granted access to view the resume data for the duration of their session. No authentication required.",
successDescription: "The password was verified successfully and access has been granted.",
})
.input(
z.object({
username: z.string().min(1).describe("The username of the resume owner."),
slug: z.string().min(1).describe("The slug of the resume."),
password: z.string().min(1).describe("The password to verify."),
}),
)
.use(resumePasswordRateLimit)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
});
}),
removePassword: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "removeResumePassword",
summary: "Remove resume password",
description:
"Removes password protection from a resume. After removal, the resume (if public) can be viewed without entering a password. Requires authentication.",
successDescription: "The resume password was removed successfully.",
})
.input(resumeDto.removePassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.removePassword.output)
.handler(async ({ context, input }) => {
return resumeService.removePassword({
id: input.id,
userId: context.user.id,
});
}),
duplicate: protectedProcedure
.route({
method: "POST",
@@ -0,0 +1,36 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { subscribeResumeUpdated } from "./events";
import { resumeService } from "./service";
export const updatesRouter = {
subscribe: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/updates",
tags: ["Resumes"],
operationId: "subscribeResumeUpdates",
summary: "Subscribe to resume updates",
description:
"Streams lightweight invalidation events when the specified resume changes. The event payload contains metadata only; clients should refetch the resume for canonical data.",
successDescription: "A stream of resume update invalidation events.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.handler(async function* ({ context, input, signal }) {
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
yield {
type: "resume.updated" as const,
resumeId: input.id,
userId: context.user.id,
updatedAt: resume.updatedAt.toISOString(),
mutation: "sync" as const,
};
yield* subscribeResumeUpdated({
resumeId: input.id,
userId: context.user.id,
...(signal ? { signal } : {}),
});
}),
};
@@ -7,7 +7,7 @@ const pool = vi.hoisted(() => ({
vi.mock("@reactive-resume/db/client", () => ({ getPool: () => pool }));
const { publishResumeUpdated, subscribeResumeUpdated } = await import("./resume-events");
const { publishResumeUpdated, subscribeResumeUpdated } = await import("./events");
const exampleEvent = {
type: "resume.updated" as const,
@@ -0,0 +1,48 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { createResumePdfFile } from "@reactive-resume/pdf/server";
import { generateFilename } from "@reactive-resume/utils/file";
import { protectedProcedure } from "../../context";
import { pdfExportRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const downloadResumePdfProcedure = protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/pdf",
tags: ["Resumes"],
operationId: "downloadResumePdf",
summary: "Download resume as PDF",
description:
"Generates a PDF for the specified resume and returns it as a forced download. Only resumes belonging to the authenticated user can be downloaded. Requires authentication.",
successDescription: "The generated resume PDF.",
outputStructure: "detailed",
})
.input(z.object({ id: z.string().describe("The ID of the resume.") }))
.output(
z.object({
headers: z.object({
"content-disposition": z.string(),
}),
body: z.file().mime("application/pdf"),
}),
)
.use(pdfExportRateLimit)
.handler(async ({ context, input }) => {
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
const filename = generateFilename(resume.name, "pdf");
try {
const body = await createResumePdfFile({ data: resume.data, filename });
return {
headers: {
"content-disposition": `attachment; filename="${filename}"`,
},
body,
};
} catch (error) {
console.error("[PDF API] Failed to render resume PDF", { resumeId: input.id, error });
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate resume PDF" });
}
});
@@ -0,0 +1,2 @@
export { downloadResumePdfProcedure } from "./export";
export { resumeService } from "./service";
@@ -0,0 +1,27 @@
import { analysisRouter } from "./analysis";
import { crudRouter } from "./crud";
import { updatesRouter } from "./event-router";
import { sharingRouter } from "./sharing";
import { resumeStatisticsRouter } from "./statistics";
import { tagsRouter } from "./tags";
export const resumeRouter = {
tags: tagsRouter,
statistics: resumeStatisticsRouter,
analysis: analysisRouter,
updates: updatesRouter,
list: crudRouter.list,
getById: crudRouter.getById,
getBySlug: sharingRouter.getBySlug,
create: crudRouter.create,
import: crudRouter.import,
update: crudRouter.update,
patch: crudRouter.patch,
setLocked: crudRouter.setLocked,
setPassword: sharingRouter.setPassword,
verifyPassword: sharingRouter.verifyPassword,
removePassword: sharingRouter.removePassword,
duplicate: crudRouter.duplicate,
delete: crudRouter.delete,
};
@@ -1,8 +1,8 @@
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
import type { StoredResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
import type { ResumeUpdatedEvent } from "./resume-events";
import type { ResumeUpdatedEvent } from "./events";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
@@ -10,18 +10,13 @@ import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { applyResumePatches, ResumePatchError } from "@reactive-resume/resume/patch";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { applyResumePatches, ResumePatchError } from "@reactive-resume/utils/resume/patch";
import { generateId } from "@reactive-resume/utils/string";
import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access";
import {
assertCanView,
isOwner,
redactResumeForViewer,
shouldCountForStatistics,
} from "../helpers/resume-access-policy";
import { publishResumeUpdated } from "./resume-events";
import { getStorageService } from "./storage";
import { getStorageService } from "../storage/service";
import { grantResumeAccess, hasResumeAccess } from "./access";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy";
import { publishResumeUpdated } from "./events";
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
@@ -291,7 +286,7 @@ export const resumeService = {
return resume;
},
getBySlug: async (input: { username: string; slug: string; currentUserId?: string }) => {
getBySlug: async (input: { username: string; slug: string; requestHeaders: Headers; currentUserId?: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
@@ -314,7 +309,7 @@ export const resumeService = {
const viewer = input.currentUserId ? { id: input.currentUserId } : null;
assertCanView(resume, viewer);
if (resume.hasPassword && !hasResumeAccess(resume.id, resume.passwordHash)) {
if (resume.hasPassword && !hasResumeAccess(input.requestHeaders, resume.id, resume.passwordHash)) {
throw new ORPCError("NEED_PASSWORD", {
status: 401,
data: { username: input.username, slug: input.slug },
@@ -505,7 +500,7 @@ export const resumeService = {
});
},
verifyPassword: async (input: { slug: string; username: string; password: string }) => {
verifyPassword: async (input: { slug: string; username: string; password: string; responseHeaders?: Headers }) => {
const [resume] = await db
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
@@ -525,7 +520,7 @@ export const resumeService = {
if (!isValid) throw new ORPCError("INVALID_PASSWORD", { status: 401 });
grantResumeAccess(resume.id, passwordHash);
if (input.responseHeaders) grantResumeAccess(input.responseHeaders, resume.id, passwordHash);
return true;
},
+100
View File
@@ -0,0 +1,100 @@
import z from "zod";
import { protectedProcedure, publicProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const sharingRouter = {
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resumes/{username}/{slug}",
tags: ["Resume Sharing"],
operationId: "getResumeBySlug",
summary: "Get public resume by username and slug",
description:
"Returns a publicly shared resume identified by the owner's username and the resume's slug. If the resume is password-protected and the viewer has not yet verified the password, a 401 error with code NEED_PASSWORD is returned. No authentication required for public resumes; if authenticated as the owner, private resumes are also accessible.",
successDescription: "The public resume with its full data.",
})
.input(resumeDto.getBySlug.input)
.output(resumeDto.getBySlug.output)
.handler(async ({ input, context }) => {
return resumeService.getBySlug({
...input,
requestHeaders: context.reqHeaders,
...(context.user?.id ? { currentUserId: context.user.id } : {}),
});
}),
setPassword: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "setResumePassword",
summary: "Set resume password",
description:
"Sets or updates a password on a resume. When a password is set, viewers of the public resume must enter the password before the resume data is revealed. The password must be between 6 and 64 characters. Requires authentication.",
successDescription: "The resume password was set successfully.",
})
.input(resumeDto.setPassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setPassword.output)
.handler(async ({ context, input }) => {
return resumeService.setPassword({
id: input.id,
userId: context.user.id,
password: input.password,
});
}),
verifyPassword: publicProcedure
.route({
method: "POST",
path: "/resumes/{username}/{slug}/password/verify",
tags: ["Resume Sharing"],
operationId: "verifyResumePassword",
summary: "Verify resume password",
description:
"Verifies a password for a password-protected public resume. On success, the viewer is granted access to view the resume data for the duration of their session. No authentication required.",
successDescription: "The password was verified successfully and access has been granted.",
})
.input(
z.object({
username: z.string().min(1).describe("The username of the resume owner."),
slug: z.string().min(1).describe("The slug of the resume."),
password: z.string().min(1).describe("The password to verify."),
}),
)
.use(resumePasswordRateLimit)
.output(z.boolean())
.handler(async ({ context, input }): Promise<boolean> => {
return resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
...(context.resHeaders ? { responseHeaders: context.resHeaders } : {}),
});
}),
removePassword: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "removeResumePassword",
summary: "Remove resume password",
description:
"Removes password protection from a resume. After removal, the resume (if public) can be viewed without entering a password. Requires authentication.",
successDescription: "The resume password was removed successfully.",
})
.input(resumeDto.removePassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.removePassword.output)
.handler(async ({ context, input }) => {
return resumeService.removePassword({
id: input.id,
userId: context.user.id,
});
}),
};
@@ -0,0 +1,30 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { resumeService } from "./service";
export const resumeStatisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/statistics",
tags: ["Resume Statistics"],
operationId: "getResumeStatistics",
summary: "Get resume statistics",
description:
"Returns view and download statistics for the specified resume, including total counts and the timestamps of the last view and download. Requires authentication.",
successDescription: "The resume's view and download statistics.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(
z.object({
isPublic: z.boolean().describe("Whether the resume is currently public."),
views: z.number().describe("Total number of times the resume has been viewed."),
downloads: z.number().describe("Total number of times the resume has been downloaded."),
lastViewedAt: z.date().nullable().describe("Timestamp of the last view, or null if never viewed."),
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
}),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
};
+21
View File
@@ -0,0 +1,21 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { resumeService } from "./service";
export const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resumes/tags",
tags: ["Resumes"],
operationId: "listResumeTags",
summary: "List all resume tags",
description:
"Returns a sorted list of all unique tags across the authenticated user's resumes. Useful for populating tag filters in the dashboard. Requires authentication.",
successDescription: "A sorted array of unique tag strings.",
})
.output(z.array(z.string()))
.handler(async ({ context }) => {
return resumeService.tags.list({ userId: context.user.id });
}),
};
@@ -1,6 +1,6 @@
import z from "zod";
import { publicProcedure } from "../context";
import { statisticsService } from "../services/statistics";
import { publicProcedure } from "../../context";
import { statisticsService } from "./service";
const userRouter = {
getCount: publicProcedure
@@ -30,7 +30,7 @@ afterEach(() => {
dbMock.select.mockClear();
});
const { statisticsService } = await import("./statistics");
const { statisticsService } = await import("./service");
// Each test gets a unique LOCAL_STORAGE_PATH to avoid cross-test cache hits.
beforeEach(() => {
@@ -0,0 +1 @@
export { getStorageService } from "./service";
@@ -1,8 +1,8 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { protectedProcedure } from "../context";
import { storageDeleteRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "../services/storage";
import { protectedProcedure } from "../../context";
import { storageDeleteRateLimit, storageUploadRateLimit } from "../../middleware/rate-limit";
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "./service";
const storageService = getStorageService();
@@ -33,7 +33,7 @@ vi.mock("@aws-sdk/client-s3", () => ({
ListObjectsV2Command: vi.fn(),
}));
const { getStorageService, inferContentType, isImageFile, processImageForUpload } = await import("./storage");
const { getStorageService, inferContentType, isImageFile, processImageForUpload } = await import("./service");
const makeFile = (bytes: Uint8Array, type = "image/png") =>
({
@@ -1,81 +0,0 @@
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({ APP_URL: "https://example.com" }));
const cookies = vi.hoisted(() => ({
get: vi.fn<(name: string) => string | undefined>(),
set: vi.fn(),
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
vi.mock("@tanstack/react-start/server", () => ({
getCookie: (name: string) => cookies.get(name),
setCookie: (name: string, value: string, options: unknown) => cookies.set(name, value, options),
}));
const { hasResumeAccess, grantResumeAccess } = await import("./resume-access");
const signToken = (resumeId: string, passwordHash: string) =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
describe("hasResumeAccess", () => {
it("returns false when no passwordHash is supplied", () => {
expect(hasResumeAccess("resume-1", null)).toBe(false);
});
it("returns false when no cookie is present", () => {
cookies.get.mockReturnValueOnce(undefined);
expect(hasResumeAccess("resume-1", "hash")).toBe(false);
});
it("returns true for a cookie value that matches the expected signed token", () => {
const token = signToken("resume-1", "hash");
cookies.get.mockReturnValueOnce(token);
expect(hasResumeAccess("resume-1", "hash")).toBe(true);
});
it("returns false for a cookie value that does not match the expected signed token", () => {
cookies.get.mockReturnValueOnce("not-the-right-token");
expect(hasResumeAccess("resume-1", "hash")).toBe(false);
});
it("returns false when the cookie has a different length than the expected token", () => {
cookies.get.mockReturnValueOnce("short");
expect(hasResumeAccess("resume-1", "hash")).toBe(false);
});
});
describe("grantResumeAccess", () => {
it("writes a signed cookie scoped to the resume id with httpOnly + sameSite=lax + 10-minute TTL", () => {
cookies.set.mockReset();
grantResumeAccess("resume-42", "hash");
expect(cookies.set).toHaveBeenCalledTimes(1);
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the cookie write exists before destructuring it.
const [name, value, options] = cookies.set.mock.calls[0]!;
expect(name).toBe("resume_access_resume-42");
expect(value).toBe(signToken("resume-42", "hash"));
expect(options).toMatchObject({
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 600,
});
});
it("only marks the cookie secure when APP_URL is https", () => {
envMock.APP_URL = "http://localhost:3000";
cookies.set.mockReset();
grantResumeAccess("r", "h");
expect(cookies.set.mock.calls[0]?.[2]).toMatchObject({ secure: false });
envMock.APP_URL = "https://example.com";
cookies.set.mockReset();
grantResumeAccess("r", "h");
expect(cookies.set.mock.calls[0]?.[2]).toMatchObject({ secure: true });
});
});
-36
View File
@@ -1,36 +0,0 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { env } from "@reactive-resume/env/server";
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
const RESUME_ACCESS_TTL_SECONDS = 60 * 10; // 10 minutes
const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_PREFIX}_${resumeId}`;
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
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);
};
export const hasResumeAccess = (resumeId: string, passwordHash: string | null) => {
if (!passwordHash) return false;
const cookieName = getResumeAccessCookieName(resumeId);
const cookieValue = getCookie(cookieName);
if (!cookieValue) return false;
const expected = signResumeAccessToken(resumeId, passwordHash);
return safeEquals(cookieValue, expected);
};
export const grantResumeAccess = (resumeId: string, passwordHash: string) =>
setCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: RESUME_ACCESS_TTL_SECONDS,
secure: env.APP_URL.startsWith("https"),
});
@@ -65,8 +65,6 @@ function getInputKeyPart(input: unknown): string {
const resumePasswordLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumePassword);
const pdfLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.pdfExport);
const aiLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.aiRequest);
const jobsSearchLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsSearch);
const jobsTestConnectionLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsTestConnection);
const storageUploadLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageUpload);
const storageDeleteLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageDelete);
const resumeMutationLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumeMutations);
@@ -98,16 +96,6 @@ export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders,
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${getInputKeyPart(input)}`,
});
export const jobsSearchRateLimit = createRatelimitMiddleware<ContextWithHeaders, { params: { query: string } }>({
limiter: productionLimiter(jobsSearchLimiter),
key: ({ context }, input) => `jobs-search:${getUserKey(context)}:${input.params.query.trim().toLowerCase()}`,
});
export const jobsTestConnectionRateLimit = createRatelimitMiddleware<ContextWithHeaders, { apiKey: string }>({
limiter: productionLimiter(jobsTestConnectionLimiter),
key: ({ context }) => `jobs-test-connection:${getUserKey(context)}`,
});
export const storageUploadRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: productionLimiter(storageUploadLimiter),
key: ({ context }) => `storage-upload:${getUserKey(context)}`,
-287
View File
@@ -1,287 +0,0 @@
import type { UIMessage } from "ai";
import { ORPCError } from "@orpc/client";
import z from "zod";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
import { agentService } from "../services/agent";
function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
function throwUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
});
}
function base64ToUint8Array(value: string) {
return Uint8Array.from(Buffer.from(value, "base64"));
}
function isUiMessage(value: unknown): value is UIMessage {
if (!value || typeof value !== "object") return false;
const message = value as Partial<UIMessage>;
return (
typeof message.id === "string" &&
(message.role === "system" || message.role === "user" || message.role === "assistant") &&
Array.isArray(message.parts)
);
}
const threadsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/agent/threads",
tags: ["Agent"],
operationId: "listAgentThreads",
summary: "List agent threads",
})
.handler(async ({ context }) => {
try {
return await agentService.threads.list({ userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
create: protectedProcedure
.route({
method: "POST",
path: "/agent/threads",
tags: ["Agent"],
operationId: "createAgentThread",
summary: "Create agent thread",
})
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.create({
userId: context.user.id,
locale: context.locale,
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
get: protectedProcedure
.route({
method: "GET",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "getAgentThread",
summary: "Get agent thread",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.get({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
archive: protectedProcedure
.route({
method: "POST",
path: "/agent/threads/{id}/archive",
tags: ["Agent"],
operationId: "archiveAgentThread",
summary: "Archive agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.archive({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "deleteAgentThread",
summary: "Delete agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
const messagesRouter = {
send: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/send",
tags: ["Agent"],
operationId: "sendAgentMessage",
summary: "Send agent message",
})
.input(
z.object({
threadId: z.string(),
message: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }),
attachmentIds: z.array(z.string().trim().min(1)).max(10).optional(),
}),
)
.use(aiRequestRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.messages.send({
userId: context.user.id,
threadId: input.threadId,
message: input.message,
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
stop: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/stop",
tags: ["Agent"],
operationId: "stopAgentMessage",
summary: "Stop active agent run",
})
.input(
z.object({
threadId: z.string(),
partialMessage: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }).optional(),
}),
)
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.messages.stop({
userId: context.user.id,
threadId: input.threadId,
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
resume: protectedProcedure
.route({
method: "GET",
path: "/agent/messages/resume",
tags: ["Agent"],
operationId: "resumeAgentMessages",
summary: "Resume agent message stream",
})
.input(z.object({ threadId: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
const attachmentsRouter = {
create: protectedProcedure
.route({
method: "POST",
path: "/agent/attachments",
tags: ["Agent"],
operationId: "createAgentAttachment",
summary: "Create agent attachment",
})
.input(
z.object({
threadId: z.string(),
filename: z.string().trim().min(1),
mediaType: z.string().trim().min(1),
data: z.string().min(1),
}),
)
.use(storageUploadRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.attachments.create({
userId: context.user.id,
threadId: input.threadId,
filename: input.filename,
mediaType: input.mediaType,
data: base64ToUint8Array(input.data),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/attachments/{id}",
tags: ["Agent"],
operationId: "deleteAgentAttachment",
summary: "Delete agent attachment",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
const actionsRouter = {
revert: protectedProcedure
.route({
method: "POST",
path: "/agent/actions/{id}/revert",
tags: ["Agent"],
operationId: "revertAgentAction",
summary: "Revert agent action",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
export const agentRouter = {
threads: threadsRouter,
messages: messagesRouter,
attachments: attachmentsRouter,
actions: actionsRouter,
};
+8 -8
View File
@@ -1,11 +1,11 @@
import { agentRouter } from "./agent";
import { aiRouter } from "./ai";
import { aiProvidersRouter } from "./ai-providers";
import { authRouter } from "./auth";
import { flagsRouter } from "./flags";
import { resumeRouter } from "./resume";
import { statisticsRouter } from "./statistics";
import { storageRouter } from "./storage";
import { agentRouter } from "../features/agent/router";
import { aiRouter } from "../features/ai/router";
import { aiProvidersRouter } from "../features/ai-providers/router";
import { authRouter } from "../features/auth/router";
import { flagsRouter } from "../features/flags/router";
import { resumeRouter } from "../features/resume/router";
import { statisticsRouter } from "../features/statistics/router";
import { storageRouter } from "../features/storage/router";
export default {
ai: aiRouter,
@@ -1,155 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createInverseResumePatches } from "./agent-patches";
function buildFixture(): ResumeData {
const clone = JSON.parse(JSON.stringify(defaultResumeData)) as ResumeData;
clone.basics.name = "Alice";
clone.basics.email = "alice@example.com";
clone.basics.customFields = [
{ id: "field-1", icon: "phosphor", text: "first", link: "" },
{ id: "field-2", icon: "phosphor", text: "second", link: "" },
];
return clone;
}
describe("createInverseResumePatches", () => {
it("inverts a single replace into a replace back to the original value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "replace", path: "/basics/name", value: "Bob" }];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "replace", path: "/basics/name", value: "Alice" }]);
});
it("inverts a single remove into an add carrying the original value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "remove", path: "/basics/customFields/0" }];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([
{
op: "add",
path: "/basics/customFields/0",
value: { id: "field-1", icon: "phosphor", text: "first", link: "" },
},
]);
});
it("inverts a single add into a remove at the same path", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{
op: "add",
path: "/basics/customFields/2",
value: { id: "field-3", icon: "phosphor", text: "third", link: "" },
},
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "remove", path: "/basics/customFields/2" }]);
});
it("inverts an array insert at an existing index into a remove at the same path", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{
op: "add",
path: "/basics/customFields/1",
value: { id: "field-inserted", icon: "phosphor", text: "inserted", link: "" },
},
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "remove", path: "/basics/customFields/1" }]);
});
it("composes inverses in reverse order with each original value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{ op: "replace", path: "/basics/name", value: "Bob" },
{ op: "replace", path: "/basics/email", value: "bob@example.com" },
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([
{ op: "replace", path: "/basics/email", value: "alice@example.com" },
{ op: "replace", path: "/basics/name", value: "Alice" },
]);
});
it("reads downstream pointers against the working copy after upstream removals", () => {
// Removing /basics/customFields/1 does not affect /basics/name.
// The inverse builder reads /basics/name from the working copy after the
// removal has been applied; that value must still be the original "Alice".
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{ op: "remove", path: "/basics/customFields/1" },
{ op: "replace", path: "/basics/name", value: "Bob" },
];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([
{ op: "replace", path: "/basics/name", value: "Alice" },
{
op: "add",
path: "/basics/customFields/1",
value: { id: "field-2", icon: "phosphor", text: "second", link: "" },
},
]);
});
it("throws INVERTIBLE_PATCH_REQUIRED when a path ends with /- (array append)", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [
{ op: "add", path: "/basics/customFields/-", value: { id: "x", icon: "phosphor", text: "x", link: "" } },
];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVERTIBLE_PATCH_REQUIRED for move operations", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "move", path: "/basics/email", from: "/basics/name" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVERTIBLE_PATCH_REQUIRED for copy operations", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "copy", path: "/basics/email", from: "/basics/name" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVERTIBLE_PATCH_REQUIRED for test operations", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "test", path: "/basics/name", value: "Alice" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
});
it("throws INVALID_PATCH_OPERATIONS when reading a non-existent path", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "replace", path: "/does/not/exist", value: "x" }];
expect(() => createInverseResumePatches(data, operations)).toThrow("INVALID_PATCH_OPERATIONS");
});
it("inverts an add at an existing object member into a replace with the prior value", () => {
const data = buildFixture();
const operations: JsonPatchOperation[] = [{ op: "add", path: "/basics/name", value: "Bob" }];
const inverse = createInverseResumePatches(data, operations);
expect(inverse).toEqual([{ op: "replace", path: "/basics/name", value: "Alice" }]);
});
});
@@ -1,83 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
import { applyResumePatches } from "@reactive-resume/utils/resume/patch";
function decodePointerSegment(segment: string) {
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
}
function readPointer(document: unknown, pointer: string): unknown {
if (pointer === "") return document;
if (!pointer.startsWith("/")) throw new Error("INVALID_PATCH_OPERATIONS");
return pointer
.slice(1)
.split("/")
.map(decodePointerSegment)
.reduce<unknown>((current, segment) => {
if (current == null || typeof current !== "object") throw new Error("INVALID_PATCH_OPERATIONS");
return (current as Record<string, unknown>)[segment];
}, document);
}
function pointerExists(document: unknown, pointer: string): boolean {
if (pointer === "") return true;
if (!pointer.startsWith("/")) return false;
const segments = pointer.slice(1).split("/").map(decodePointerSegment);
let current: unknown = document;
for (const segment of segments) {
if (current == null || typeof current !== "object") return false;
const record = current as Record<string, unknown>;
if (!Object.hasOwn(record, segment)) return false;
current = record[segment];
}
return true;
}
function getParentPointer(pointer: string) {
const lastSlashIndex = pointer.lastIndexOf("/");
return lastSlashIndex <= 0 ? "" : pointer.slice(0, lastSlashIndex);
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export function createInverseResumePatches(data: ResumeData, operations: JsonPatchOperation[]): JsonPatchOperation[] {
const working = cloneJson(data);
const inverse: JsonPatchOperation[] = [];
for (const operation of operations) {
if (operation.path.endsWith("/-")) throw new Error("INVERTIBLE_PATCH_REQUIRED");
if (operation.op === "replace") {
inverse.unshift({ op: "replace", path: operation.path, value: cloneJson(readPointer(working, operation.path)) });
} else if (operation.op === "remove") {
inverse.unshift({ op: "add", path: operation.path, value: cloneJson(readPointer(working, operation.path)) });
} else if (operation.op === "add") {
const parent = readPointer(working, getParentPointer(operation.path));
// JSON Patch "add" inserts into arrays, but overwrites existing object members.
// Array inserts must be reverted with remove; object overwrites need replace.
if (Array.isArray(parent)) {
inverse.unshift({ op: "remove", path: operation.path });
} else if (pointerExists(working, operation.path)) {
inverse.unshift({
op: "replace",
path: operation.path,
value: cloneJson(readPointer(working, operation.path)),
});
} else {
inverse.unshift({ op: "remove", path: operation.path });
}
} else {
throw new Error("INVERTIBLE_PATCH_REQUIRED");
}
applyResumePatches(working, [operation]);
}
return inverse;
}
-725
View File
@@ -1,725 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
CLOUDFLARE_ACCOUNT_ID: "",
CLOUDFLARE_API_TOKEN: "",
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
const dnsMock = vi.hoisted(() => ({
lookup: vi.fn(),
}));
const undiciMock = vi.hoisted(() => {
class MockAgent {
static instances: MockAgent[] = [];
close = vi.fn().mockResolvedValue(undefined);
constructor(readonly options: Record<string, unknown>) {
MockAgent.instances.push(this);
}
}
return {
Agent: MockAgent,
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init)),
};
});
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
vi.mock("node:dns/promises", () => dnsMock);
vi.mock("undici", () => ({
Agent: undiciMock.Agent,
fetch: undiciMock.fetch,
}));
const { fetchUrlForAgent } = await import("./agent-url");
function textResponse(body: string, options: { contentType: string; url?: string; status?: number }) {
return new Response(body, {
status: options.status ?? 200,
headers: { "content-type": options.contentType },
}) as Response & { url: string };
}
function responseWithUrl(body: string, options: { contentType: string; url?: string; status?: number }) {
const response = textResponse(body, options);
Object.defineProperty(response, "url", { value: options.url ?? "https://example.com/article" });
return response;
}
describe("fetchUrlForAgent", () => {
beforeEach(() => {
envMock.CLOUDFLARE_ACCOUNT_ID = "";
envMock.CLOUDFLARE_API_TOKEN = "";
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
vi.restoreAllMocks();
dnsMock.lookup.mockReset();
dnsMock.lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
undiciMock.Agent.instances.length = 0;
undiciMock.fetch.mockReset();
undiciMock.fetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) =>
globalThis.fetch(input, init),
);
vi.useRealTimers();
});
it("extracts local HTML article text with Readability and strips navigation/script/style noise", async () => {
const articleText =
"This is the actual article body with enough useful detail for the AI agent to summarize accurately. ".repeat(4);
const fetchMock = vi.fn().mockResolvedValue(
responseWithUrl(
`
<!doctype html>
<html>
<head>
<title>Head title should lose to article title</title>
<style>.secret { color: red; }</style>
</head>
<body>
<nav>Home Pricing Login</nav>
<script>window.__tracking = "do not include";</script>
<article>
<h1>Readable Article Title</h1>
<p>${articleText}</p>
</article>
<footer>Privacy Terms</footer>
</body>
</html>
`,
{ contentType: "text/html; charset=utf-8", url: "https://example.com/final/article" },
),
);
vi.stubGlobal("fetch", fetchMock);
const result = await fetchUrlForAgent("https://example.com/article");
expect(result).toEqual({
url: "https://example.com/article",
title: "Head title should lose to article title",
content: expect.stringContaining("actual article body"),
source: "local",
});
expect(result.content).toContain("Readable Article Title");
expect(result.content).not.toContain("Home Pricing Login");
expect(result.content).not.toContain("window.__tracking");
expect(result.content).not.toContain("color: red");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/article",
expect.objectContaining({
redirect: "manual",
signal: expect.any(AbortSignal),
}),
);
});
it("compacts local plain text and JSON responses", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("one\n\n two\tthree", { contentType: "text/plain" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ name: "Ada", role: "Engineer" }, null, 2), {
contentType: "application/json",
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/plain.txt")).resolves.toMatchObject({
content: "one two three",
source: "local",
title: null,
});
await expect(fetchUrlForAgent("https://example.com/data.json")).resolves.toMatchObject({
content: '{ "name": "Ada", "role": "Engineer" }',
source: "local",
title: null,
});
});
it("falls back to Cloudflare when local content type is unsupported and credentials exist", async () => {
vi.useFakeTimers();
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "crawl-job-id" }), {
contentType: "application/json",
url: "https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
}),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: { status: "running", records: [] } }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({
result: { status: "completed", records: [{ markdown: "# Rendered\n\nCloudflare markdown" }] },
}),
{ contentType: "application/json" },
),
);
vi.stubGlobal("fetch", fetchMock);
const result = fetchUrlForAgent("https://example.com/file.pdf");
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(500);
await expect(result).resolves.toMatchObject({
url: "https://example.com/file.pdf",
content: "# Rendered Cloudflare markdown",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
expect.objectContaining({
method: "POST",
signal: expect.any(AbortSignal),
body: JSON.stringify({
url: "https://example.com/file.pdf",
crawlPurposes: ["ai-input"],
formats: ["markdown"],
render: true,
limit: 1,
depth: 0,
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl/crawl-job-id?limit=1",
expect.objectContaining({
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
authorization: "Bearer api-token",
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl/crawl-job-id?limit=1",
expect.any(Object),
);
});
it("submits the final validated redirect URL to Cloudflare when local extraction fails after redirects", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/file.pdf" } }))
.mockResolvedValueOnce(
responseWithUrl("binary", { contentType: "application/pdf", url: "https://example.com/file.pdf" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "redirect-job-id" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "redirect fallback" }] } }),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
url: "https://example.com/file.pdf",
content: "redirect fallback",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
expect.objectContaining({
body: expect.stringContaining('"url":"https://example.com/file.pdf"'),
}),
);
});
it("blocks DNS resolutions to private addresses before local fetch or Cloudflare fallback", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
dnsMock.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://public.example.com/article")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(dnsMock.lookup).toHaveBeenCalledWith("public.example.com", { all: true });
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks DNS resolutions to special-use addresses before local fetch or Cloudflare fallback", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
for (const address of [
"100.64.0.1",
"192.0.2.1",
"192.88.99.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"255.255.255.255",
"::",
"::ffff:8.8.8.8",
"::ffff:0808:0808",
"64:ff9b::1",
"100::1",
"100:0:0:1::1",
"2001::1",
"2001:2::1",
"2001:10::1",
"2001:100::1",
"ff02::1",
"2001:db8::1",
"3fff::1",
"5f00::1",
]) {
dnsMock.lookup.mockResolvedValueOnce([{ address, family: address.includes(":") ? 6 : 4 }]);
await expect(
fetchUrlForAgent(`https://special-${address.replaceAll(":", "-")}.example.com/article`),
).rejects.toThrow("URL_NOT_FETCHABLE");
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks IPv4-mapped IPv6 DNS resolutions to private addresses", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
for (const address of ["::ffff:10.0.0.1", "::ffff:127.0.0.1", "::ffff:169.254.169.254"]) {
dnsMock.lookup.mockResolvedValueOnce([{ address, family: 6 }]);
await expect(fetchUrlForAgent(`https://${address.replaceAll(":", "-")}.example.com/article`)).rejects.toThrow(
"URL_NOT_FETCHABLE",
);
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses a pinned dispatcher lookup for the validated DNS address", async () => {
dnsMock.lookup.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("Pinned DNS response", { contentType: "text/plain" }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://public.example.com/article")).resolves.toMatchObject({
content: "Pinned DNS response",
source: "local",
});
const [agent] = undiciMock.Agent.instances;
const connect = agent?.options.connect as
| {
autoSelectFamily?: boolean;
lookup?: (
hostname: string,
options: unknown,
callback: (
error: Error | null,
address: string | Array<{ address: string; family: number }>,
family?: number,
) => void,
) => void;
}
| undefined;
expect(connect?.autoSelectFamily).toBe(false);
await expect(
new Promise<{ address: string; family: number }>((resolve, reject) => {
connect?.lookup?.("public.example.com", {}, (error, address, family) => {
if (error) reject(error);
else if (typeof address === "string" && family) resolve({ address, family });
else reject(new Error("Expected single address lookup result"));
});
}),
).resolves.toEqual({ address: "93.184.216.34", family: 4 });
await expect(
new Promise<Array<{ address: string; family: number }>>((resolve, reject) => {
connect?.lookup?.("public.example.com", { all: true }, (error, addresses) => {
if (error) reject(error);
else if (Array.isArray(addresses)) resolve(addresses);
else reject(new Error("Expected all-address lookup result"));
});
}),
).resolves.toEqual([{ address: "93.184.216.34", family: 4 }]);
expect(dnsMock.lookup).toHaveBeenCalledTimes(1);
});
it("times out DNS validation before local fetch or Cloudflare fallback", async () => {
vi.useFakeTimers();
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
dnsMock.lookup.mockReturnValueOnce(new Promise(() => undefined));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = fetchUrlForAgent("https://slow-dns.example.com/article");
const rejection = expect(result).rejects.toThrow("URL_NOT_FETCHABLE");
await vi.advanceTimersByTimeAsync(5_000);
await rejection;
expect(fetchMock).not.toHaveBeenCalled();
});
it("revalidates DNS for redirect targets before following them", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
dnsMock.lookup
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
.mockResolvedValueOnce([{ address: "fd00::1", family: 6 }]);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { location: "https://cdn.example.com/final" } }),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(dnsMock.lookup).toHaveBeenNthCalledWith(1, "example.com", { all: true });
expect(dnsMock.lookup).toHaveBeenNthCalledWith(2, "cdn.example.com", { all: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("uses the latest validated redirect URL for Cloudflare fallback after network errors", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/final" } }))
.mockRejectedValueOnce(new Error("connect timeout"))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "network-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "network fallback" }] } }),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
url: "https://example.com/final",
content: "network fallback",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
expect.objectContaining({
body: expect.stringContaining('"url":"https://example.com/final"'),
}),
);
});
it("cancels redirect, non-OK, unsupported, and oversized response bodies", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const redirectCancel = vi.fn();
const nonOkCancel = vi.fn();
const unsupportedCancel = vi.fn();
const oversizedCancel = vi.fn();
const oversizedBody = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(2 * 1024 * 1024 + 1));
},
cancel: oversizedCancel,
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: redirectCancel }), {
status: 302,
headers: { location: "https://example.com/redirected" },
}),
)
.mockResolvedValueOnce(
responseWithUrl("ok ".repeat(80), { contentType: "text/plain", url: "https://example.com/redirected" }),
)
.mockResolvedValueOnce(new Response(new ReadableStream({ cancel: nonOkCancel }), { status: 500 }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "non-ok-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "non-ok fallback" }] } }),
{ contentType: "application/json" },
),
)
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: unsupportedCancel }), {
status: 200,
headers: { "content-type": "application/pdf" },
}),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "unsupported-job" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "unsupported fallback" }] } }),
{ contentType: "application/json" },
),
)
.mockResolvedValueOnce(new Response(oversizedBody, { status: 200, headers: { "content-type": "text/plain" } }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "oversized-job" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({ result: { status: "completed", records: [{ markdown: "oversized fallback" }] } }),
{ contentType: "application/json" },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/redirect")).resolves.toMatchObject({ source: "local" });
await expect(fetchUrlForAgent("https://example.com/non-ok")).resolves.toMatchObject({ content: "non-ok fallback" });
await expect(fetchUrlForAgent("https://example.com/unsupported")).resolves.toMatchObject({
content: "unsupported fallback",
});
await expect(fetchUrlForAgent("https://example.com/oversized")).resolves.toMatchObject({
content: "oversized fallback",
});
expect(redirectCancel).toHaveBeenCalled();
expect(nonOkCancel).toHaveBeenCalled();
expect(unsupportedCancel).toHaveBeenCalled();
expect(oversizedCancel).toHaveBeenCalled();
});
it("cancels Cloudflare non-OK crawl creation and poll response bodies", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const createCancel = vi.fn();
const pollCancel = vi.fn();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: createCancel }), {
status: 500,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "poll-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel: pollCancel }), {
status: 503,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/create-fails")).rejects.toThrow("URL_FETCH_FAILED");
await expect(fetchUrlForAgent("https://example.com/poll-fails")).rejects.toThrow("URL_FETCH_FAILED");
expect(createCancel).toHaveBeenCalled();
expect(pollCancel).toHaveBeenCalled();
});
it("falls back to Cloudflare when local Readability content is too small and credentials exist", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
responseWithUrl("<html><head><title>Thin</title></head><body><article><p>Tiny.</p></article></body></html>", {
contentType: "text/html",
}),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "thin-job-id" }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({
result: { status: "completed", records: [{ markdown: "rendered fallback for thin page" }] },
}),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/thin")).resolves.toMatchObject({
content: "rendered fallback for thin page",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("parses supported Cloudflare markdown payload shapes", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "records-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: { status: "completed", records: [{ markdown: "records shape" }] } }), {
contentType: "application/json",
}),
)
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "markdown-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: { markdown: "first shape" } }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "array-job" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ result: [{ markdown: "array shape" }] }), { contentType: "application/json" }),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/records")).resolves.toMatchObject({ content: "records shape" });
await expect(fetchUrlForAgent("https://example.com/markdown")).resolves.toMatchObject({ content: "first shape" });
await expect(fetchUrlForAgent("https://example.com/array")).resolves.toMatchObject({ content: "array shape" });
});
it("blocks private and non-HTTPS URLs before Cloudflare fallback", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("http://example.com/article")).rejects.toThrow("URL_NOT_FETCHABLE");
await expect(fetchUrlForAgent("https://localhost/internal")).rejects.toThrow("URL_NOT_FETCHABLE");
await expect(fetchUrlForAgent("https://10.0.0.5/internal")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks redirects to private and non-HTTPS URLs before following them or falling back to Cloudflare", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "https://localhost/internal" } }))
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "http://example.com/plain" } }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/private-redirect")).rejects.toThrow("URL_NOT_FETCHABLE");
await expect(fetchUrlForAgent("https://example.com/http-redirect")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"https://example.com/private-redirect",
expect.objectContaining({ redirect: "manual" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://example.com/http-redirect",
expect.objectContaining({ redirect: "manual" }),
);
});
it("blocks redirects without Location before falling back to Cloudflare", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 302 }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/missing-location")).rejects.toThrow("URL_NOT_FETCHABLE");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/missing-location",
expect.objectContaining({ redirect: "manual" }),
);
});
it("resolves relative HTTPS redirects and uses the final URL for local Readability extraction", async () => {
const articleText =
"This redirected article has enough readable content for extraction after following a relative Location header. ".repeat(
4,
);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/final/article" } }))
.mockResolvedValueOnce(
responseWithUrl(
`<html><head><title>Redirected</title></head><body><article><h1>Redirected</h1><p>${articleText}</p></article></body></html>`,
{ contentType: "text/html", url: "https://example.com/final/article" },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
url: "https://example.com/final/article",
content: expect.stringContaining("redirected article"),
source: "local",
});
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://example.com/final/article",
expect.objectContaining({ redirect: "manual" }),
);
});
it("falls back to Cloudflare when the local response exceeds the byte limit", async () => {
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
envMock.CLOUDFLARE_API_TOKEN = "api-token";
const oversized = "a".repeat(2 * 1024 * 1024 + 1);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(responseWithUrl(oversized, { contentType: "text/plain" }))
.mockResolvedValueOnce(
responseWithUrl(JSON.stringify({ success: true, result: "huge-job-id" }), { contentType: "application/json" }),
)
.mockResolvedValueOnce(
responseWithUrl(
JSON.stringify({
result: { status: "completed", records: [{ markdown: "fallback after oversized response" }] },
}),
{
contentType: "application/json",
},
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(fetchUrlForAgent("https://example.com/huge.txt")).resolves.toMatchObject({
content: "fallback after oversized response",
source: "cloudflare",
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
-362
View File
@@ -1,362 +0,0 @@
import { lookup } from "node:dns/promises";
import { Readability } from "@mozilla/readability";
import { parseHTML } from "linkedom";
import { Agent, fetch as undiciFetch } from "undici";
import { env } from "@reactive-resume/env/server";
import { isPrivateOrLoopbackHost } from "@reactive-resume/utils/url-security.node";
import { assertFetchablePublicHttpsUrl } from "./ai-url-policy";
const MAX_FETCHED_TEXT_CHARS = 40_000;
const MAX_LOCAL_FETCH_BYTES = 2 * 1024 * 1024;
const MAX_LOCAL_REDIRECTS = 5;
const MAX_CLOUDFLARE_CRAWL_POLLS = 6;
const CLOUDFLARE_CRAWL_POLL_DELAY_MS = 500;
const LOCAL_FETCH_TIMEOUT_MS = 10_000;
const DNS_LOOKUP_TIMEOUT_MS = 5_000;
const CLOUDFLARE_CRAWL_CREATE_TIMEOUT_MS = 15_000;
const CLOUDFLARE_CRAWL_POLL_TIMEOUT_MS = 10_000;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
type FetchedUrlResult = {
url: string;
title: string | null;
content: string;
source: "local" | "cloudflare";
};
type FetchResponse = Awaited<ReturnType<typeof undiciFetch>>;
type ResolvedAddress = { address: string; family: number };
class LocalFetchError extends Error {
constructor(
message: string,
readonly fallbackUrl: string,
) {
super(message);
}
}
function compactText(value: string) {
return value.replace(/\s+/g, " ").trim().slice(0, MAX_FETCHED_TEXT_CHARS);
}
function extractReadableHtml(html: string, url: string) {
const { document } = parseHTML(html);
Object.defineProperties(document, {
baseURI: { value: url },
documentURI: { value: url },
});
const article = new Readability(document).parse();
const content = compactText(article?.textContent ?? "");
if (content.length < 160) throw new Error("URL_READABILITY_FAILED");
return {
title: article?.title ? compactText(article.title) : null,
content,
};
}
async function readLimitedText(response: FetchResponse) {
const reader = response.body?.getReader();
if (!reader) return response.text();
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
size += value.byteLength;
if (size > MAX_LOCAL_FETCH_BYTES) {
await reader.cancel("FETCHED_URL_TOO_LARGE");
throw new Error("FETCHED_URL_TOO_LARGE");
}
chunks.push(value);
}
return new TextDecoder().decode(Buffer.concat(chunks));
}
function timeoutSignal(ms: number) {
if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async function withTimeout<T>(promise: Promise<T>, ms: number, errorCode: string) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(errorCode)), ms);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function cancelResponseBody(response: FetchResponse) {
try {
await response.body?.cancel();
} catch {
// Best effort cleanup only; the original fetch/extraction error should stay authoritative.
}
}
function isRedirectResponse(response: { status: number }) {
return REDIRECT_STATUSES.has(response.status);
}
function resolveRedirectUrl(location: string, currentUrl: string) {
try {
return new URL(location, currentUrl).toString();
} catch {
throw new Error("URL_NOT_FETCHABLE");
}
}
async function assertResolvesToPublicAddress(url: string) {
const { hostname } = new URL(url);
let addresses: ResolvedAddress[];
try {
addresses = await withTimeout(lookup(hostname, { all: true }), DNS_LOOKUP_TIMEOUT_MS, "URL_NOT_FETCHABLE");
} catch {
throw new Error("URL_NOT_FETCHABLE");
}
if (addresses.length === 0) throw new Error("URL_NOT_FETCHABLE");
if (addresses.some(({ address }) => isPrivateOrLoopbackHost(address))) throw new Error("URL_NOT_FETCHABLE");
const [address] = addresses;
if (!address) throw new Error("URL_NOT_FETCHABLE");
return address;
}
function createPinnedDispatcher(url: string, address: string, family: number) {
const { hostname } = new URL(url);
return new Agent({
connect: {
autoSelectFamily: false,
servername: hostname,
lookup: (_hostname, options, callback) => {
if (typeof options === "object" && options && "all" in options && options.all) {
callback(null, [{ address, family }]);
return;
}
callback(null, address, family);
},
},
});
}
async function fetchLocalResponse(inputUrl: string) {
let url = assertFetchablePublicHttpsUrl(inputUrl);
for (let redirectCount = 0; redirectCount <= MAX_LOCAL_REDIRECTS; redirectCount++) {
const address = await assertResolvesToPublicAddress(url);
const dispatcher = createPinnedDispatcher(url, address.address, address.family);
let response: FetchResponse;
try {
response = await undiciFetch(url, {
dispatcher,
redirect: "manual",
signal: timeoutSignal(LOCAL_FETCH_TIMEOUT_MS),
headers: {
accept: "text/html, text/plain;q=0.9, application/json;q=0.8",
"user-agent": "ReactiveResumeAI/1.0",
},
});
} catch (error) {
await dispatcher.close();
if (error instanceof Error && error.message === "URL_NOT_FETCHABLE") throw error;
throw new LocalFetchError("URL_FETCH_FAILED", url);
}
if (!isRedirectResponse(response)) return { response, url, dispatcher };
const location = response.headers.get("location");
await cancelResponseBody(response);
await dispatcher.close();
if (!location) throw new Error("URL_NOT_FETCHABLE");
if (redirectCount === MAX_LOCAL_REDIRECTS) throw new Error("URL_NOT_FETCHABLE");
url = assertFetchablePublicHttpsUrl(resolveRedirectUrl(location, url));
}
throw new Error("URL_NOT_FETCHABLE");
}
async function fetchLocally(url: string): Promise<FetchedUrlResult> {
const { response, url: responseUrl, dispatcher } = await fetchLocalResponse(url);
try {
if (!response.ok) {
await cancelResponseBody(response);
throw new LocalFetchError("URL_FETCH_FAILED", responseUrl);
}
const contentType = response.headers.get("content-type") ?? "";
if (
!contentType.includes("text/html") &&
!contentType.includes("text/plain") &&
!contentType.includes("application/json")
) {
await cancelResponseBody(response);
throw new LocalFetchError("URL_FETCH_UNSUPPORTED_CONTENT_TYPE", responseUrl);
}
const raw = await readLimitedText(response);
const isHtml = contentType.includes("text/html");
const extracted = isHtml ? extractReadableHtml(raw, responseUrl) : { title: null, content: compactText(raw) };
return {
url: responseUrl,
title: extracted.title,
content: extracted.content,
source: "local",
};
} catch (error) {
if (error instanceof LocalFetchError) throw error;
if (error instanceof Error) throw new LocalFetchError(error.message, responseUrl);
throw new LocalFetchError("URL_READABILITY_FAILED", responseUrl);
} finally {
await dispatcher.close();
}
}
function extractCloudflareMarkdown(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
const result = record.result;
if (result && typeof result === "object") {
const resultRecord = result as Record<string, unknown>;
if (Array.isArray(resultRecord.records)) {
const [first] = resultRecord.records;
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
if (typeof markdown === "string") return markdown;
}
if (typeof resultRecord.markdown === "string") return resultRecord.markdown;
if (Array.isArray(resultRecord.pages)) {
const [first] = resultRecord.pages;
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
if (typeof markdown === "string") return markdown;
}
}
if (Array.isArray(result)) {
const [first] = result;
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
if (typeof markdown === "string") return markdown;
}
return null;
}
function extractCloudflareJobId(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const result = (payload as Record<string, unknown>).result;
return typeof result === "string" && result.trim() ? result : null;
}
function extractCloudflareCrawlStatus(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const result = (payload as Record<string, unknown>).result;
if (!result || typeof result !== "object") return null;
const status = (result as Record<string, unknown>).status;
return typeof status === "string" ? status.toLowerCase() : null;
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithCloudflare(url: string): Promise<FetchedUrlResult> {
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) throw new Error("URL_READABILITY_FAILED");
const crawlUrl = `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/browser-rendering/crawl`;
const headers = {
authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
};
const response = await undiciFetch(crawlUrl, {
method: "POST",
signal: timeoutSignal(CLOUDFLARE_CRAWL_CREATE_TIMEOUT_MS),
headers: {
...headers,
"content-type": "application/json",
},
body: JSON.stringify({
url,
crawlPurposes: ["ai-input"],
formats: ["markdown"],
render: true,
limit: 1,
depth: 0,
}),
});
if (!response.ok) {
await cancelResponseBody(response);
throw new Error("URL_FETCH_FAILED");
}
const jobId = extractCloudflareJobId(await response.json());
if (!jobId) throw new Error("URL_READABILITY_FAILED");
let markdown: string | null = null;
for (let attempt = 0; attempt < MAX_CLOUDFLARE_CRAWL_POLLS; attempt++) {
const resultResponse = await undiciFetch(`${crawlUrl}/${encodeURIComponent(jobId)}?limit=1`, {
headers,
signal: timeoutSignal(CLOUDFLARE_CRAWL_POLL_TIMEOUT_MS),
});
if (!resultResponse.ok) {
await cancelResponseBody(resultResponse);
throw new Error("URL_FETCH_FAILED");
}
const payload = await resultResponse.json();
markdown = extractCloudflareMarkdown(payload);
if (markdown) break;
const status = extractCloudflareCrawlStatus(payload);
if (status !== "running" && status !== "queued") break;
if (attempt < MAX_CLOUDFLARE_CRAWL_POLLS - 1) await wait(CLOUDFLARE_CRAWL_POLL_DELAY_MS);
}
if (!markdown) throw new Error("URL_READABILITY_FAILED");
return {
url,
title: null,
content: compactText(markdown),
source: "cloudflare",
};
}
export async function fetchUrlForAgent(input: string): Promise<FetchedUrlResult> {
const url = assertFetchablePublicHttpsUrl(input);
try {
return await fetchLocally(url);
} catch (error) {
if (error instanceof Error && error.message === "URL_NOT_FETCHABLE") throw error;
return fetchWithCloudflare(error instanceof LocalFetchError ? error.fallbackUrl : url);
}
}
@@ -1,86 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { assertFetchablePublicHttpsUrl, resolveAiBaseUrl } = await import("./ai-url-policy");
describe("AI provider base URL policy", () => {
it("allows public HTTPS provider URLs", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(resolveAiBaseUrl({ provider: "openai", baseURL: "https://api.openai.com/v1" })).toBe(
"https://api.openai.com/v1",
);
});
it("blocks private and non-HTTPS provider URLs by default", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://localhost:11434/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
it("allows private and non-HTTPS provider URLs when explicitly enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://localhost:11434/v1" })).toBe(
"http://localhost:11434/v1",
);
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://10.0.0.5/v1" })).toBe(
"https://10.0.0.5/v1",
);
});
it("rejects non-HTTP schemes even when unsafe provider URLs are enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "file:///etc/passwd" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "ftp://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
it("keeps URL-fetch tools public HTTPS only even when unsafe provider URLs are enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => assertFetchablePublicHttpsUrl("https://localhost/internal-job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("http://example.com/job")).toThrow("URL_NOT_FETCHABLE");
expect(assertFetchablePublicHttpsUrl("https://example.com/job")).toBe("https://example.com/job");
});
it("blocks special-use IP literals for URL-fetch tools", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => assertFetchablePublicHttpsUrl("https://100.64.0.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://192.0.2.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://192.88.99.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://198.18.0.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://198.51.100.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://203.0.113.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://224.0.0.1/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[::]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[::ffff:8.8.8.8]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[::ffff:0808:0808]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[64:ff9b::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[100::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[100:0:0:1::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:100::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:2::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:10::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[ff02::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[2001:db8::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[3fff::1]/job")).toThrow("URL_NOT_FETCHABLE");
expect(() => assertFetchablePublicHttpsUrl("https://[5f00::1]/job")).toThrow("URL_NOT_FETCHABLE");
});
});