fix: finalize v6 migrations and document workflows

This commit is contained in:
Amruth Pillai
2026-10-01 07:06:15 +02:00
parent 8105889b0c
commit bc92b8cf7f
177 changed files with 78971 additions and 131255 deletions
@@ -8,6 +8,7 @@ import { buildMarkdown } from "@reactive-resume/resume/markdown";
import {
additionAfter,
blockText,
canApplyTo,
collectLetterPassages,
collectPassages,
readTarget,
@@ -180,6 +181,13 @@ export function resolveEdits(document: LoadedDocument, input: ProposeEditsInput)
});
continue;
}
if (!canApplyTo(document.read(passage.target), { before: passage.html })) {
skipped.push({
passageId: edit.passageId,
reason: "This passage is ambiguous or changed. Edit its repeated text manually, or read the document again.",
});
continue;
}
const placed = edit.add
? additionAfter(document.read(passage.target) ?? "", passage.html, edit.text)
@@ -361,10 +361,29 @@ describe("agentService.messages.send", () => {
const { agentService } = await import("./service");
const send = async (context?: { document: boolean; posting: boolean }) => {
const privateHistory = {
...persistedMessage,
id: "old-message",
role: "assistant",
uiMessage: {
id: "old-ui-message",
role: "assistant",
parts: [
{ type: "text", text: "Your email is private-marker@example.test" },
{
type: "tool-read_resume",
toolCallId: "read-1",
state: "output-available",
input: {},
output: { data: { email: "private-marker@example.test" } },
},
],
},
};
dbMock.select
.mockImplementationOnce(() => selectLimitResult([buildActiveThread()]))
.mockImplementationOnce(() => selectWhereResult([{ total: 1 }]))
.mockImplementationOnce(() => selectOrderByResult([persistedMessage]));
.mockImplementationOnce(() => selectOrderByResult([privateHistory, persistedMessage]));
await agentService.messages.send({
threadId: "thread-1",
userId: "user-1",
@@ -381,6 +400,9 @@ describe("agentService.messages.send", () => {
const { buildAgentInstructions, buildAgentTools } = await import("./tools");
await send();
expect(JSON.stringify(vi.mocked(convertToModelMessages).mock.calls.at(-1)?.[0])).toContain(
"private-marker@example.test",
);
expect(vi.mocked(buildAgentTools).mock.calls[0]?.[0]).toMatchObject({
document: "resume",
});
@@ -390,6 +412,7 @@ describe("agentService.messages.send", () => {
expect(documentMock.findPosting).toHaveBeenCalledTimes(1);
await send({ document: false, posting: false });
expect(vi.mocked(convertToModelMessages).mock.calls.at(-1)?.[0]).toEqual([persistedMessage.uiMessage]);
expect(vi.mocked(buildAgentTools).mock.calls[1]?.[0]).toMatchObject({
document: null,
});
+9 -1
View File
@@ -1108,6 +1108,13 @@ export const agentService = {
message: "Agent messages must be user messages or tool results.",
});
}
// Opt-out applies to the entire provider context, including document-derived prose and tool results.
const freshContext = input.context?.document === false || input.context?.posting === false;
if (freshContext && input.message.role !== "user") {
throw new ORPCError("BAD_REQUEST", {
message: "Send a new message after removing context. Previous tool approvals cannot be continued.",
});
}
// Deliberately schema-less: provider-echoed tool parts must pass, and replayed history is never re-validated.
const validated = await safeValidateUIMessages({
@@ -1247,9 +1254,10 @@ export const agentService = {
{ threadId: input.threadId, userId: input.userId },
);
const messages = messageRows.map(toMessage);
const replay = freshContext ? [withAttachmentUiParts(input.message, attachmentsForModel)] : messages;
const connection = await webAccessService.resolve(input.userId);
const modelMessages = await convertToModelMessages(
messages.map((message) => toModelInputMessage(message, runnableProvider, connection !== null)),
replay.map((message) => toModelInputMessage(message, runnableProvider, connection !== null)),
);
const attachmentModelParts = buildAttachmentModelParts(await readAttachmentModelInputs(attachmentsForModel));
+53 -43
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { copyCoverLetterStyle } from "@reactive-resume/resume/cover-letter";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const mocks = vi.hoisted(() => ({ select: vi.fn(), predicates: [] as unknown[] }));
const mocks = vi.hoisted(() => ({ select: vi.fn(), predicates: [] as unknown[], getLetter: vi.fn() }));
vi.mock("@reactive-resume/db/client", () => ({ db: { select: mocks.select } }));
vi.mock("@reactive-resume/db/schema", () => ({
user: { id: "user.id" },
@@ -14,49 +14,59 @@ vi.mock("drizzle-orm", () => ({ eq: (column: unknown, value: unknown) => ({ colu
vi.mock("@reactive-resume/env/server", () => ({ env: {} }));
vi.mock("@reactive-resume/auth/config", () => ({ isCustomOAuthProviderEnabled: () => false }));
vi.mock("../storage/service", () => ({ getStorageService: vi.fn() }));
vi.mock("../cover-letters/service", () => ({ coverLetterService: { getById: mocks.getLetter } }));
const { authService } = await import("./service");
describe("account backup", () => {
it("includes owned cover letters and applications alongside resumes", async () => {
const letter = {
id: "letter",
name: "Saved",
recipient: "",
content: "<p>Body</p>",
style: copyCoverLetterStyle(defaultResumeData),
layout: "freeform",
recipientName: "",
recipientCompany: "",
letterDate: null,
senderLinked: false,
designLinked: false,
isLocked: false,
sourceResumeId: null,
sourceApplicationId: null,
revision: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
const application = { id: "application", userId: "owner", company: "Lumen", role: "Designer" };
for (const rows of [
[{ id: "owner", name: "Owner" }],
[{ id: "resume", data: defaultResumeData }],
[letter],
[application],
]) {
mocks.select.mockReturnValueOnce({
from: () => ({
where: (predicate: unknown) => {
mocks.predicates.push(predicate);
return Promise.resolve(rows);
},
}),
});
}
const exported = await authService.exportData({ userId: "owner" });
expect(exported).toMatchObject({ coverLetters: [letter], resumes: [{ id: "resume" }] });
// Applications come along, without the owner's id.
expect(exported.applications).toEqual([{ id: "application", company: "Lumen", role: "Designer" }]);
expect(mocks.predicates).toContainEqual({ column: "coverLetter.userId", value: "owner" });
});
it.each([false, true])(
"includes current owned letters and applications alongside resumes (linked: %s)",
async (linked) => {
const letter = {
id: "letter",
name: "Saved",
recipient: "",
content: "<p>Body</p>",
style: copyCoverLetterStyle(defaultResumeData),
layout: "freeform",
recipientName: "",
recipientCompany: "",
letterDate: null,
senderLinked: linked,
designLinked: linked,
isLocked: false,
sourceResumeId: linked ? "resume" : null,
sourceApplicationId: null,
revision: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
const resolved = structuredClone(letter);
if (linked) {
resolved.style.basics.name = "Current sender";
resolved.style.metadata.template = "gengar";
}
mocks.getLetter.mockResolvedValue(resolved);
const application = { id: "application", userId: "owner", company: "Lumen", role: "Designer" };
for (const rows of [
[{ id: "owner", name: "Owner" }],
[{ id: "resume", data: defaultResumeData }],
[letter],
[application],
]) {
mocks.select.mockReturnValueOnce({
from: () => ({
where: (predicate: unknown) => {
mocks.predicates.push(predicate);
return Promise.resolve(rows);
},
}),
});
}
const exported = await authService.exportData({ userId: "owner" });
expect(exported).toMatchObject({ coverLetters: [resolved], resumes: [{ id: "resume" }] });
// Applications come along, without the owner's id.
expect(exported.applications).toEqual([{ id: "application", company: "Lumen", role: "Designer" }]);
expect(mocks.predicates).toContainEqual({ column: "coverLetter.userId", value: "owner" });
},
);
});
+4 -2
View File
@@ -5,7 +5,7 @@ import { isCustomOAuthProviderEnabled } from "@reactive-resume/auth/config";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { coverLetterSchema } from "@reactive-resume/schema/cover-letter/data";
import { coverLetterService } from "../cover-letters/service";
import { getStorageService } from "../storage/service";
export type ProviderList = Partial<Record<AuthProvider, string>>;
@@ -68,7 +68,9 @@ export const authService = {
exportedAt: new Date().toISOString(),
user: userRecord,
resumes,
coverLetters: coverLetters.map((letter) => coverLetterSchema.parse(letter)),
coverLetters: await Promise.all(
coverLetters.map(({ id }) => coverLetterService.getById({ id, userId: input.userId })),
),
applications: applications.map(({ userId: _userId, ...application }) => application),
};
},
@@ -50,19 +50,26 @@ describe.skipIf(!process.env.COVER_LETTER_TEST_DATABASE_URL)("cover-letter owned
});
fixture.db = drizzle({ client: fixture.pool });
await fixture.pool.query(
`CREATE TABLE "user" (id text PRIMARY KEY); CREATE TABLE resume (id text PRIMARY KEY, user_id text, data jsonb); CREATE TABLE application (id text PRIMARY KEY, user_id text, company text NOT NULL DEFAULT '', contacts jsonb NOT NULL DEFAULT '[]', cover_letter_id text, updated_at timestamptz);`,
`CREATE TABLE "user" (id text PRIMARY KEY); CREATE TABLE resume (id text PRIMARY KEY, user_id text, data jsonb); CREATE TABLE application (id text PRIMARY KEY, user_id text, company text NOT NULL DEFAULT '', contacts jsonb NOT NULL DEFAULT '[]', cover_letter_id text, updated_at timestamptz, resume_id text, status text DEFAULT 'saved', sent_resume_version_id text);`,
);
// The migrations that shape the letter tables, in order.
for (const name of [
"20260905121445_cover_letter_library",
"20260928175116_documents_trash_and_links",
"20260928201742_letters_structured_and_versions",
]) {
for (const name of ["20260905121445_cover_letter_library", "20261001042749_v6_release"]) {
const migration = await readFile(
new URL(`../../../../../migrations/${name}/migration.sql`, import.meta.url),
"utf8",
);
await fixture.pool.query(migration.replaceAll('"public".', ""));
// This fixture owns only the letter tables; full upgrade coverage lives in the DB package.
for (const statement of migration.split("--> statement-breakpoint")) {
const sql = statement.trim().replace(/^--[^\n]*\n/, "");
if (
/^(?:CREATE TABLE|ALTER TABLE) "cover_letter(?:_version)?"|^CREATE (?:UNIQUE )?INDEX .* ON "cover_letter(?:_version)?"/.test(
sql,
) ||
/^ALTER TABLE "application" .*"sent_cover_letter_version_id"/.test(sql)
) {
await fixture.pool.query(sql.replaceAll('"public".', ""));
}
}
}
service = (await import("./service")).coverLetterService;
});
@@ -98,6 +105,59 @@ describe.skipIf(!process.env.COVER_LETTER_TEST_DATABASE_URL)("cover-letter owned
});
});
it("keeps submitted letters and their snapshots intact through every linking path", async () => {
const original = await service.create({ userId: "alice", name: "Submitted", applicationId: "alice-app" });
const version = await service.recordSent({ userId: "alice", id: original.id, company: "Lumen" });
await getPool().query("UPDATE application SET sent_cover_letter_version_id=$1 WHERE id='alice-app'", [version.id]);
const replacement = await service.create({ userId: "alice", name: "Replacement" });
const { documentsService } = await import("../documents/service");
for (const change of [
() =>
documentsService.linkApplication({
userId: "alice",
type: "letter",
id: replacement.id,
applicationId: "alice-app",
}),
() => service.update({ userId: "alice", id: replacement.id, expectedRevision: 1, applicationId: "alice-app" }),
() => service.update({ userId: "alice", id: original.id, expectedRevision: 1, applicationId: null }),
]) {
await expect(change()).rejects.toMatchObject({ code: "BAD_REQUEST" });
const application = (
await getPool().query(
"SELECT cover_letter_id, sent_cover_letter_version_id FROM application WHERE id='alice-app'",
)
).rows[0];
expect(application).toEqual({ cover_letter_id: original.id, sent_cover_letter_version_id: version.id });
expect(await service.getById({ userId: "alice", id: replacement.id })).toMatchObject({
revision: 1,
sourceApplicationId: null,
});
}
const { getLetterVersion } = await import("./versions");
expect(
await getLetterVersion({ userId: "alice", coverLetterId: original.id, versionId: version.id }),
).toMatchObject({ data: { name: "Submitted" } });
});
it("lists permanent checkpoints behind more than 100 recent autosaves", async () => {
const letter = await service.create({ userId: "alice", name: "Long history" });
const named = await service.createVersion({ userId: "alice", id: letter.id, name: "Keep forever" });
const sent = await service.recordSent({ userId: "alice", id: letter.id, company: "Lumen" });
await getPool().query(
`INSERT INTO cover_letter_version (id, cover_letter_id, user_id, data, kind, created_at)
SELECT 'auto-' || n, $1, 'alice', '{}'::jsonb, 'auto', now() + n * interval '1 second' FROM generate_series(1,120) n`,
[letter.id],
);
const { listLetterVersions } = await import("./versions");
const versions = await listLetterVersions({ userId: "alice", coverLetterId: letter.id });
expect(versions.map((version) => version.id)).toEqual(expect.arrayContaining([named.id, sent.id]));
expect(versions.filter((version) => version.kind === "auto")).toHaveLength(100);
await expect(listLetterVersions({ userId: "bob", coverLetterId: letter.id })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("accepts procedure defaults, rejects invalid input, and permits only one concurrent writer", async () => {
const { coverLettersRouter } = await import("./router");
const client = createRouterClient(coverLettersRouter, { context: { user: { id: "alice" } } as never });
@@ -1,4 +1,5 @@
import type { CoverLetterListInput, CoverLetterUpdateInput } from "../../dto/cover-letter";
import type { DbOrTx } from "@reactive-resume/db/client";
import type {
CoverLetter,
CoverLetterDocument,
@@ -7,7 +8,7 @@ import type {
} from "@reactive-resume/schema/cover-letter/data";
import type { Template } from "@reactive-resume/schema/templates";
import { ORPCError } from "@orpc/client";
import { and, count, desc, eq, ilike, isNull, sql } from "drizzle-orm";
import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { copyCoverLetterStyle } from "@reactive-resume/resume/cover-letter";
@@ -110,51 +111,67 @@ async function assertOwnedApplication(userId: string, id?: string) {
* A letter for an application is the letter that application sends: a new one becomes its letter if it has none, and
* moving a letter to another application takes it along.
*/
export async function linkLetterApplication(input: {
userId: string;
letterId: string;
from?: string | null | undefined;
to?: string | null | undefined;
replace: boolean;
}) {
async function linkLetterApplication(
client: DbOrTx,
input: {
userId: string;
letterId: string;
from?: string | null | undefined;
to?: string | null | undefined;
replace: boolean;
},
) {
if (input.from === input.to) return;
const table = schema.application;
if (input.from && input.from !== input.to) {
await db
const ids = [input.from, input.to].filter((id): id is string => Boolean(id));
if (!ids.length) return;
const applications = await client
.select({ id: table.id, coverLetterId: table.coverLetterId, sentVersion: table.sentCoverLetterVersionId })
.from(table)
.where(and(eq(table.userId, input.userId), inArray(table.id, ids)))
.orderBy(asc(table.id))
.for("update");
if (input.to && !applications.some((application) => application.id === input.to)) throw new ORPCError("NOT_FOUND");
for (const application of applications) {
const next =
application.id === input.from && application.coverLetterId === input.letterId
? null
: application.id === input.to && (input.replace || !application.coverLetterId)
? input.letterId
: application.coverLetterId;
if (next === application.coverLetterId) continue;
if (application.sentVersion)
throw new ORPCError("BAD_REQUEST", {
message:
"Recorded submitted documents cannot be replaced. Prepare a copy to keep the submitted versions intact.",
});
await client
.update(table)
.set({ coverLetterId: null })
.where(and(eq(table.id, input.from), eq(table.userId, input.userId), eq(table.coverLetterId, input.letterId)));
}
if (input.to && input.to !== input.from) {
await db
.update(table)
.set({ coverLetterId: input.letterId })
.where(
and(
eq(table.id, input.to),
eq(table.userId, input.userId),
...(input.replace ? [] : [isNull(table.coverLetterId)]),
),
);
.set({ coverLetterId: next })
.where(and(eq(table.id, application.id), eq(table.userId, input.userId)));
}
}
async function insert(input: {
userId: string;
name: string;
recipient: string;
content: string;
style: CoverLetterStyle;
layout?: CoverLetterLayout | undefined;
recipientName?: string | undefined;
recipientCompany?: string | undefined;
letterDate?: string | null | undefined;
sourceResumeId?: string | null;
sourceApplicationId?: string | null;
senderLinked?: boolean;
designLinked?: boolean;
}): Promise<CoverLetter> {
async function insert(
input: {
userId: string;
name: string;
recipient: string;
content: string;
style: CoverLetterStyle;
layout?: CoverLetterLayout | undefined;
recipientName?: string | undefined;
recipientCompany?: string | undefined;
letterDate?: string | null | undefined;
sourceResumeId?: string | null;
sourceApplicationId?: string | null;
senderLinked?: boolean;
designLinked?: boolean;
},
client: DbOrTx = db,
): Promise<CoverLetter> {
const content = coverLetterContentSchema.parse(input);
const [row] = await db
const [row] = await client
.insert(schema.coverLetter)
.values({
...content,
@@ -169,15 +186,16 @@ async function insert(input: {
.returning();
if (!row) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the letter." });
const letter = toLetter(row);
await writeLetterVersion(db, { letter, userId: input.userId, kind: "created" });
await writeLetterVersion(client, { letter, userId: input.userId, kind: "created" });
return letter;
}
async function updateRevision(
input: RevisionInput,
changes: Partial<typeof schema.coverLetter.$inferInsert>,
client: DbOrTx = db,
): Promise<CoverLetter> {
const [row] = await db
const [row] = await client
.update(schema.coverLetter)
.set({ ...changes, revision: sql`${schema.coverLetter.revision} + 1` })
.where(
@@ -234,22 +252,33 @@ export const coverLetterService = {
const style = await getResumeStyle(input.userId, input.resumeId);
if (input.template) style.metadata.template = input.template;
const linked = Boolean(input.resumeId) && !input.template;
const letter = await insert({
userId: input.userId,
name: input.name,
recipient: input.recipient ?? "",
content: input.content ?? "",
style,
layout: input.layout ?? (input.recipient?.trim() ? "freeform" : "structured"),
recipientName: input.recipientName ?? application?.contacts[0]?.name ?? "",
recipientCompany: input.recipientCompany ?? application?.company ?? "",
letterDate: input.letterDate === undefined ? today() : input.letterDate,
sourceResumeId: input.resumeId ?? null,
sourceApplicationId: input.applicationId ?? null,
senderLinked: Boolean(input.resumeId),
designLinked: linked,
const letter = await db.transaction(async (tx) => {
const letter = await insert(
{
userId: input.userId,
name: input.name,
recipient: input.recipient ?? "",
content: input.content ?? "",
style,
layout: input.layout ?? (input.recipient?.trim() ? "freeform" : "structured"),
recipientName: input.recipientName ?? application?.contacts[0]?.name ?? "",
recipientCompany: input.recipientCompany ?? application?.company ?? "",
letterDate: input.letterDate === undefined ? today() : input.letterDate,
sourceResumeId: input.resumeId ?? null,
sourceApplicationId: input.applicationId ?? null,
senderLinked: Boolean(input.resumeId),
designLinked: linked,
},
tx,
);
await linkLetterApplication(tx, {
userId: input.userId,
letterId: letter.id,
to: input.applicationId,
replace: false,
});
return letter;
});
await linkLetterApplication({ userId: input.userId, letterId: letter.id, to: input.applicationId, replace: false });
return resolveLinks(letter, input.userId);
},
update: async (input: CoverLetterUpdateInput & { userId: string }) => {
@@ -300,16 +329,23 @@ export const coverLetterService = {
if (input.recipientCompany !== undefined) changes.recipientCompany = input.recipientCompany.trim();
if (input.letterDate !== undefined) changes.letterDate = input.letterDate;
const updated = await resolveLinks(await updateRevision(input, changes), input.userId);
if (input.applicationId !== undefined) {
await linkLetterApplication({
userId: input.userId,
letterId: input.id,
from: stored.sourceApplicationId,
to: input.applicationId,
replace: true,
});
}
const persist = async (client: DbOrTx) => {
const updated = await updateRevision(input, changes, client);
if (input.applicationId !== undefined) {
await linkLetterApplication(client, {
userId: input.userId,
letterId: input.id,
from: stored.sourceApplicationId,
to: input.applicationId,
replace: true,
});
}
return updated;
};
const updated = await resolveLinks(
await (input.applicationId === undefined ? persist(db) : db.transaction(persist)),
input.userId,
);
await saveLetterSessionVersion({
letter: updated,
userId: input.userId,
@@ -10,7 +10,7 @@ const resumeServiceMock = vi.hoisted(() => ({
const applicationServiceMock = vi.hoisted(() => ({ update: vi.fn() }));
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("../resume/service", () => ({ resumeService: resumeServiceMock }));
vi.mock("../cover-letters/service", () => ({ linkLetterApplication: vi.fn() }));
vi.mock("../cover-letters/service", () => ({ coverLetterService: { getById: vi.fn(), update: vi.fn() } }));
vi.mock("../applications/service", () => ({ applicationService: applicationServiceMock }));
const { documentsService } = await import("./service");
+6 -11
View File
@@ -4,7 +4,7 @@ import { and, count, eq, isNotNull, isNull, lt, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { applicationService } from "../applications/service";
import { linkLetterApplication } from "../cover-letters/service";
import { coverLetterService } from "../cover-letters/service";
import { resumeService } from "../resume/service";
type DocumentType = DocumentSummary["type"];
@@ -229,17 +229,12 @@ export const documentsService = {
return;
}
const [letter] = await db
.select({ applicationId: schema.coverLetter.sourceApplicationId })
.from(schema.coverLetter)
.where(owned(input));
await update(input, {}, { sourceApplicationId: input.applicationId });
await linkLetterApplication({
const letter = await coverLetterService.getById({ id: input.id, userId: input.userId });
await coverLetterService.update({
id: input.id,
userId: input.userId,
letterId: input.id,
from: letter?.applicationId,
to: input.applicationId,
replace: true,
expectedRevision: letter.revision,
applicationId: input.applicationId,
});
},
@@ -1,7 +1,7 @@
import type { DbOrTx } from "@reactive-resume/db/client";
import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
import { ORPCError } from "@orpc/client";
import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm";
import { and, desc, eq, inArray, lt, notInArray, or } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
// An editing session's autosave is refreshed at most this often.
@@ -10,7 +10,7 @@ const SESSION_REFRESH_MS = 2 * 60 * 1000;
const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
// A safety cap on autosaves per document, so storage stays bounded however often it's edited.
const MAX_AUTOSAVES = 500;
// History shows at most this many versions, newest first.
// Bound recent expiring versions; permanent checkpoints always remain discoverable.
const LIST_LIMIT = 100;
type VersionSummary<TKind extends string> = { id: string; kind: TKind; name: string | null; createdAt: Date };
@@ -139,12 +139,25 @@ export function createVersionHistory<TKind extends string, TData>(config: {
.where(and(eq(config.owner.id, input.documentId), eq(config.owner.userId, input.userId)));
if (!owner) throw new ORPCError("NOT_FOUND");
const recent = db
.select({ id: v.id })
.from(v.table)
.where(
and(eq(v.document, input.documentId), eq(v.userId, input.userId), inArray(v.kind, [...config.expiringKinds])),
)
.orderBy(desc(v.createdAt))
.limit(LIST_LIMIT);
const versions = await db
.select(summary)
.from(v.table)
.where(eq(v.document, input.documentId))
.orderBy(desc(v.createdAt))
.limit(LIST_LIMIT);
.where(
and(
eq(v.document, input.documentId),
eq(v.userId, input.userId),
or(notInArray(v.kind, [...config.expiringKinds]), inArray(v.id, recent)),
),
)
.orderBy(desc(v.createdAt));
return versions as VersionSummary<TKind>[];
},
@@ -1,46 +0,0 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { firecrawlService } from "./service";
const route = { tags: ["Integrations"] };
const errors = {
FORBIDDEN: { message: "Firecrawl is managed by the server.", status: 403 },
PRECONDITION_FAILED: { message: "Credential encryption is not configured.", status: 412 },
CONFLICT: { message: "Manage the selected provider through /integrations/web-access.", status: 409 },
};
export const firecrawlRouter = {
status: protectedProcedure
.route({
...route,
method: "GET",
path: "/integrations/firecrawl",
operationId: "getFirecrawlStatus",
summary: "Get Firecrawl availability",
})
.output(z.object({ managed: z.boolean(), configured: z.boolean(), canSave: z.boolean() }))
.handler(({ context }) => firecrawlService.status(context.user.id)),
save: protectedProcedure
.route({
...route,
method: "PUT",
path: "/integrations/firecrawl",
operationId: "saveFirecrawlKey",
summary: "Save a personal Firecrawl Cloud key",
})
.input(z.object({ apiKey: z.string().trim().min(1).max(2_000) }))
.errors(errors)
.output(z.void())
.handler(({ context, input }) => firecrawlService.save(context.user.id, input.apiKey)),
delete: protectedProcedure
.route({
...route,
method: "DELETE",
path: "/integrations/firecrawl",
operationId: "deleteFirecrawlKey",
summary: "Delete a personal Firecrawl Cloud key",
})
.errors(errors)
.output(z.void())
.handler(({ context }) => firecrawlService.delete(context.user.id)),
};
@@ -1,39 +0,0 @@
import { ORPCError } from "@orpc/client";
import { webAccessService } from "../web-access/credentials";
export type FirecrawlConfig = { apiUrl: string; apiKey: string };
async function assertFirecrawlSelected(userId: string) {
const status = await webAccessService.status(userId);
if (status.provider && status.provider !== "firecrawl")
throw new ORPCError("CONFLICT", {
message: "A different web provider is selected. Manage this connection through /integrations/web-access.",
});
}
/** Compatibility endpoints never read the retired Firecrawl credential table. */
export const firecrawlService = {
status: async (userId: string) => {
const status = await webAccessService.status(userId);
return {
managed: status.managed,
configured: status.provider === "firecrawl",
canSave: status.canSave && (!status.provider || status.provider === "firecrawl"),
};
},
resolve: async (userId: string): Promise<FirecrawlConfig | null> => {
if ((await webAccessService.status(userId)).provider !== "firecrawl") return null;
const connection = await webAccessService.resolve(userId);
return connection?.provider === "firecrawl"
? { apiUrl: connection.apiUrl || "https://api.firecrawl.dev", apiKey: connection.apiKey || "" }
: null;
},
save: async (userId: string, apiKey: string) => {
await assertFirecrawlSelected(userId);
await webAccessService.save(userId, "firecrawl", apiKey, true);
},
delete: async (userId: string) => {
await assertFirecrawlSelected(userId);
await webAccessService.delete(userId, true);
},
};
+5 -1
View File
@@ -1,5 +1,6 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { env } from "@reactive-resume/env/server";
import { generateFilename } from "@reactive-resume/utils/file";
import { protectedProcedure } from "../../context";
import { pdfExportRateLimit } from "../../middleware/rate-limit";
@@ -23,7 +24,7 @@ export async function createResumePdfDownload(input: CreateResumePdfDownloadInpu
// the icon drawings) only when a PDF is actually exported, instead of at server
// boot. Keeps cold starts light on constrained/slow-disk hosts.
const { createResumePdfFile } = await import("@reactive-resume/pdf/server");
const body = await createResumePdfFile({ data, filename });
const body = await createResumePdfFile({ data, filename, uploadOrigin: env.APP_URL });
return {
headers: {
@@ -32,6 +33,9 @@ export async function createResumePdfDownload(input: CreateResumePdfDownloadInpu
body,
};
} catch (error) {
if (error instanceof Error && error.cause === "pdf-text-loss") {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
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" });
}
@@ -1,5 +1,6 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/server";
import { env } from "@reactive-resume/env/server";
import { generateFilename } from "@reactive-resume/utils/file";
import { assertCanView } from "./access-policy";
import { publicRenderRateLimiter } from "./public-render-rate-limit";
@@ -55,7 +56,8 @@ const defaultDependencies: PublicResumePdfDependencies = {
resolveCurrentUserId: async (requestHeaders) =>
(await import("../../context")).resolveUserFromRequestHeaders(requestHeaders).then((user) => user?.id),
rateLimiter: publicRenderRateLimiter,
renderPdf: async (input) => (await import("@reactive-resume/pdf/server")).createResumePdfFile(input),
renderPdf: async (input) =>
(await import("@reactive-resume/pdf/server")).createResumePdfFile({ ...input, uploadOrigin: env.APP_URL }),
};
export async function createPublicResumePdf(
@@ -1,10 +1,8 @@
import type { LookupAddress } from "node:dns";
import type { LookupFunction } from "node:net";
import { lookup } from "node:dns";
import { lookup as lookupAddresses } from "node:dns/promises";
import { request } from "node:https";
import sanitizeHtml from "sanitize-html";
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node";
import { isPrivateOrLoopbackHost, parseUrl, publicLookup } from "@reactive-resume/utils/url-security.node";
import { MAX_PAGE_BYTES, WebAccessError } from "./contracts";
/** Preserve the posting reader's HTTPS-only policy. */
@@ -41,18 +39,6 @@ export async function assertPublicTarget(input: string, signal: AbortSignal) {
return url;
}
/** Validate every address on the socket's actual lookup, preventing DNS rebinding. */
const publicLookup: LookupFunction = (hostname, options, callback) => {
lookup(hostname, { ...options, all: true }, (error, addresses) => {
if (error) return callback(error, "", 4);
const list = addresses as LookupAddress[];
if (!allPublic(list)) return callback(new WebAccessError("unsafe-url"), "", 4);
if (options.all) return (callback as unknown as (error: null, addresses: LookupAddress[]) => void)(null, list);
const [first] = list;
callback(null, first?.address ?? "", first?.family ?? 4);
});
};
export function readBuiltinPage(
input: string,
signal: AbortSignal,
@@ -112,6 +98,7 @@ export function readBuiltinPage(
);
req.on("error", (error) => {
if (signal.aborted) return reject(signal.reason);
if (error.cause === "unsafe-url") return reject(new WebAccessError("unsafe-url"));
reject(error instanceof WebAccessError ? error : new WebAccessError("unreachable"));
});
req.end();
@@ -7,8 +7,6 @@ import { Pool } from "pg";
const fixture = vi.hoisted(() => ({ db: undefined as ReturnType<typeof drizzle> | undefined }));
const env = vi.hoisted(() => ({
ENCRYPTION_SECRET: "integration-test-encryption-secret-32-chars",
FIRECRAWL_API_URL: "",
FIRECRAWL_API_KEY: "",
WEB_ACCESS_PROVIDER: "" as "" | "firecrawl" | "tavily" | "exa",
WEB_ACCESS_API_KEY: "",
WEB_ACCESS_API_URL: "",
@@ -26,8 +24,7 @@ vi.mock("@reactive-resume/db/client", () => ({
// Opt in with a disposable PostgreSQL database. Each run owns and removes a separate schema.
describe.skipIf(!process.env.INTEGRATIONS_TEST_DATABASE_URL)("integration credentials and server precedence", () => {
let firecrawl: typeof import("./service").firecrawlService;
let web: typeof import("../web-access/credentials").webAccessService;
let web: typeof import("./credentials").webAccessService;
let ai: typeof import("../ai-providers/service").aiProvidersService;
let pool: Pool;
let admin: Pool;
@@ -43,16 +40,19 @@ describe.skipIf(!process.env.INTEGRATIONS_TEST_DATABASE_URL)("integration creden
await pool.query(
'CREATE TABLE "user" (id text PRIMARY KEY); CREATE TABLE resume (id text PRIMARY KEY); CREATE TABLE application (id text PRIMARY KEY)',
);
for (const name of [
"20260513181752_bent_human_cannonball",
"20260930140759_perpetual_drax",
"20260930195321_colossal_the_hood",
]) {
for (const name of ["20260513181752_bent_human_cannonball", "20261001042749_v6_release"]) {
const sql = await readFile(new URL(`../../../../../migrations/${name}/migration.sql`, import.meta.url), "utf8");
await pool.query(sql.replaceAll('"public".', ""));
// The fixture owns the AI tables and web credentials, not the rest of the v6 upgrade.
const statements = sql
.split("--> statement-breakpoint")
.filter(
(statement) =>
name !== "20261001042749_v6_release" ||
/^(?:CREATE TABLE|ALTER TABLE) "web_access_credentials"/.test(statement.trim()),
);
await pool.query(statements.join("\n").replaceAll('"public".', ""));
}
firecrawl = (await import("./service")).firecrawlService;
web = (await import("../web-access/credentials")).webAccessService;
web = (await import("./credentials")).webAccessService;
ai = (await import("../ai-providers/service")).aiProvidersService;
});
afterAll(async () => {
@@ -63,8 +63,6 @@ describe.skipIf(!process.env.INTEGRATIONS_TEST_DATABASE_URL)("integration creden
beforeEach(async () => {
Object.assign(env, {
ENCRYPTION_SECRET: "integration-test-encryption-secret-32-chars",
FIRECRAWL_API_URL: "",
FIRECRAWL_API_KEY: "",
WEB_ACCESS_PROVIDER: "",
WEB_ACCESS_API_KEY: "",
WEB_ACCESS_API_URL: "",
@@ -76,81 +74,25 @@ describe.skipIf(!process.env.INTEGRATIONS_TEST_DATABASE_URL)("integration creden
await pool.query("TRUNCATE \"user\" CASCADE; INSERT INTO \"user\" VALUES ('alice'),('bob')");
});
it("encrypts personal Cloud keys, isolates accounts, and locks personal writes under server configuration", async () => {
expect(await firecrawl.resolve("alice")).toBeNull();
await firecrawl.save("alice", "fc-alice-secret");
await firecrawl.save("bob", "fc-bob-secret");
expect(await firecrawl.resolve("alice")).toEqual({
apiUrl: "https://api.firecrawl.dev",
apiKey: "fc-alice-secret",
});
expect(await firecrawl.resolve("bob")).toEqual({ apiUrl: "https://api.firecrawl.dev", apiKey: "fc-bob-secret" });
const saved = (await pool.query("SELECT encrypted_api_key FROM web_access_credentials")).rows;
expect(JSON.stringify(saved)).not.toContain("fc-alice-secret");
expect(JSON.stringify(saved)).not.toContain("fc-bob-secret");
await firecrawl.delete("alice");
expect(await firecrawl.resolve("alice")).toBeNull();
expect(await firecrawl.status("bob")).toEqual({ managed: false, configured: true, canSave: true });
env.FIRECRAWL_API_URL = "http://firecrawl:3002";
env.FIRECRAWL_API_KEY = "server-key";
expect(await firecrawl.resolve("bob")).toEqual({ apiUrl: "http://firecrawl:3002", apiKey: "server-key" });
expect(await firecrawl.status("alice")).toEqual({ managed: true, configured: true, canSave: false });
await expect(firecrawl.save("bob", "override")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(firecrawl.delete("bob")).rejects.toMatchObject({ code: "FORBIDDEN" });
env.FIRECRAWL_API_KEY = "";
env.ENCRYPTION_SECRET = "";
expect(await firecrawl.resolve("alice")).toEqual({ apiUrl: "http://firecrawl:3002", apiKey: "" });
env.FIRECRAWL_API_URL = "";
expect(await firecrawl.status("alice")).toEqual({ managed: false, configured: false, canSave: false });
await expect(firecrawl.save("alice", "secret")).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
});
it("backfills existing Firecrawl ciphertext unchanged without activating legacy keys again", async () => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("DROP TABLE web_access_credentials; ALTER TABLE application DROP COLUMN posting_source");
await client.query("INSERT INTO firecrawl_credentials VALUES ('alice','opaque-existing-ciphertext')");
const migration = await readFile(
new URL("../../../../../migrations/20260930195321_colossal_the_hood/migration.sql", import.meta.url),
"utf8",
);
await client.query(migration.replaceAll('"public".', ""));
expect((await client.query("SELECT * FROM web_access_credentials")).rows).toEqual([
{ user_id: "alice", provider: "firecrawl", encrypted_api_key: "opaque-existing-ciphertext" },
]);
expect((await client.query("SELECT encrypted_api_key FROM firecrawl_credentials")).rows).toEqual([
{ encrypted_api_key: "opaque-existing-ciphertext" },
]);
} finally {
await client.query("ROLLBACK");
client.release();
}
await pool.query("INSERT INTO firecrawl_credentials VALUES ('alice','obsolete')");
it("encrypts personal keys, isolates accounts, and replaces or removes one selected connection", async () => {
expect(await web.resolve("alice")).toBeNull();
await web.save("alice", "tavily", "tavily-personal");
expect((await pool.query("SELECT * FROM firecrawl_credentials")).rows).toEqual([]);
});
it("keeps one personal provider, isolates keys and rejects legacy changes to another provider", async () => {
await web.save("bob", "firecrawl", "fc-bob-secret");
for (const provider of ["firecrawl", "tavily", "exa"] as const) {
await web.save("alice", provider, `${provider}-personal`);
expect(await web.resolve("alice")).toMatchObject({ provider, apiKey: `${provider}-personal` });
expect(await web.resolve("bob")).toBeNull();
expect((await pool.query("SELECT COUNT(*)::int AS count FROM web_access_credentials")).rows).toEqual([
{ count: 1 },
]);
expect(await web.resolve("bob")).toEqual({
provider: "firecrawl",
apiUrl: "https://api.firecrawl.dev",
apiKey: "fc-bob-secret",
});
const saved = (await pool.query("SELECT user_id, encrypted_api_key FROM web_access_credentials")).rows;
expect(saved).toHaveLength(2);
expect(JSON.stringify(saved)).not.toContain(`${provider}-personal`);
expect(JSON.stringify(saved)).not.toContain("fc-bob-secret");
}
expect(await firecrawl.status("alice")).toEqual({ managed: false, configured: false, canSave: false });
expect(await firecrawl.resolve("alice")).toBeNull();
await expect(firecrawl.save("alice", "legacy-overwrite")).rejects.toMatchObject({ code: "CONFLICT" });
await expect(firecrawl.delete("alice")).rejects.toMatchObject({ code: "CONFLICT" });
// The SQL guard also protects a legacy save after a concurrent provider replacement.
await expect(web.save("alice", "firecrawl", "legacy-race", true)).rejects.toMatchObject({ code: "CONFLICT" });
expect(await web.resolve("alice")).toMatchObject({ provider: "exa", apiKey: "exa-personal" });
await pool.query("INSERT INTO firecrawl_credentials VALUES ('alice','obsolete')");
await web.delete("alice");
expect(await web.resolve("alice")).toBeNull();
expect(await web.resolve("bob")).toMatchObject({ provider: "firecrawl", apiKey: "fc-bob-secret" });
expect(await web.status("alice")).toMatchObject({
configured: false,
provider: null,
@@ -158,21 +100,20 @@ describe.skipIf(!process.env.INTEGRATIONS_TEST_DATABASE_URL)("integration creden
read: true,
builtInReader: true,
});
expect((await pool.query("SELECT * FROM firecrawl_credentials")).rows).toEqual([]);
env.ENCRYPTION_SECRET = "";
expect(await web.status("alice")).toMatchObject({ managed: false, configured: false, canSave: false });
await expect(web.save("alice", "firecrawl", "secret")).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
await expect(web.delete("alice")).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
});
it("gives explicit generic server configuration precedence over aliases and personal credentials", async () => {
it("uses server credentials without encryption and restores personal connections when disabled", async () => {
await web.save("alice", "exa", "personal-exa");
Object.assign(env, {
FIRECRAWL_API_KEY: "legacy-server",
WEB_ACCESS_PROVIDER: "tavily",
WEB_ACCESS_API_KEY: "server-tavily",
});
Object.assign(env, { WEB_ACCESS_PROVIDER: "tavily", WEB_ACCESS_API_KEY: "server-tavily" });
expect(await web.resolve("alice")).toEqual({ provider: "tavily", apiKey: "server-tavily" });
expect(await web.status("alice")).toMatchObject({ managed: true, provider: "tavily", canSave: false });
await expect(web.save("alice", "exa", "override")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(web.delete("alice")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(firecrawl.save("alice", "override")).rejects.toMatchObject({ code: "CONFLICT" });
expect(JSON.stringify(await web.status("alice"))).not.toContain("server-tavily");
Object.assign(env, {
WEB_ACCESS_PROVIDER: "firecrawl",
WEB_ACCESS_API_KEY: "",
@@ -180,7 +121,12 @@ describe.skipIf(!process.env.INTEGRATIONS_TEST_DATABASE_URL)("integration creden
ENCRYPTION_SECRET: "",
});
expect(await web.resolve("bob")).toEqual({ provider: "firecrawl", apiKey: "", apiUrl: "http://firecrawl:3002" });
expect(JSON.stringify(await web.status("alice"))).not.toContain("server-tavily");
Object.assign(env, {
WEB_ACCESS_PROVIDER: "",
WEB_ACCESS_API_URL: "",
ENCRYPTION_SECRET: "integration-test-encryption-secret-32-chars",
});
expect(await web.resolve("alice")).toEqual({ provider: "exa", apiKey: "personal-exa" });
});
it("routes AI through server credentials, keeps thread references valid, and restores personal providers when disabled", async () => {
@@ -1,8 +1,8 @@
import type { WebAccessConnection, WebAccessProvider } from "./contracts";
import { ORPCError } from "@orpc/client";
import { and, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import { firecrawlCredential, webAccessCredential } from "@reactive-resume/db/schema";
import { webAccessCredential } from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { decryptCredential, encryptCredential } from "../ai/credentials";
@@ -16,13 +16,7 @@ function serverConfig(): WebAccessConnection | null {
: {}),
};
}
return env.FIRECRAWL_API_URL || env.FIRECRAWL_API_KEY
? {
provider: "firecrawl",
apiUrl: env.FIRECRAWL_API_URL || "https://api.firecrawl.dev",
apiKey: env.FIRECRAWL_API_KEY || "",
}
: null;
return null;
}
async function savedCredential(userId: string) {
@@ -40,11 +34,6 @@ function assertPersonalKeysAllowed() {
throw new ORPCError("PRECONDITION_FAILED", { message: "Credential encryption is not configured." });
}
const legacyConflict = () =>
new ORPCError("CONFLICT", {
message: "A different web provider is selected. Manage this connection through /integrations/web-access.",
});
export const webAccessService = {
status: async (userId: string) => {
const global = serverConfig();
@@ -73,43 +62,16 @@ export const webAccessService = {
}
: null;
},
/** Legacy writes may only change Firecrawl, including when racing a generic provider change. */
save: async (userId: string, provider: WebAccessProvider, apiKey: string, legacyFirecrawl = false) => {
save: async (userId: string, provider: WebAccessProvider, apiKey: string) => {
assertPersonalKeysAllowed();
const { encryptedApiKey } = encryptCredential(apiKey.trim());
await db.transaction(async (tx) => {
const saved = await tx
.insert(webAccessCredential)
.values({ userId, provider, encryptedApiKey })
.onConflictDoUpdate({
target: webAccessCredential.userId,
set: { provider, encryptedApiKey },
...(legacyFirecrawl ? { setWhere: eq(webAccessCredential.provider, "firecrawl") } : {}),
})
.returning({ userId: webAccessCredential.userId });
if (saved.length === 0) throw legacyConflict();
await tx.delete(firecrawlCredential).where(eq(firecrawlCredential.userId, userId));
});
await db
.insert(webAccessCredential)
.values({ userId, provider, encryptedApiKey })
.onConflictDoUpdate({ target: webAccessCredential.userId, set: { provider, encryptedApiKey } });
},
delete: async (userId: string, legacyFirecrawl = false) => {
delete: async (userId: string) => {
assertPersonalKeysAllowed();
await db.transaction(async (tx) => {
const [saved] = await tx
.select()
.from(webAccessCredential)
.where(eq(webAccessCredential.userId, userId))
.limit(1)
.for("update");
if (legacyFirecrawl && saved && saved.provider !== "firecrawl") throw legacyConflict();
await tx
.delete(webAccessCredential)
.where(
and(
eq(webAccessCredential.userId, userId),
legacyFirecrawl ? eq(webAccessCredential.provider, "firecrawl") : undefined,
),
);
await tx.delete(firecrawlCredential).where(eq(firecrawlCredential.userId, userId));
});
await db.delete(webAccessCredential).where(eq(webAccessCredential.userId, userId));
},
};
@@ -1,57 +1,17 @@
import type { ProviderRequest } from "./contracts";
import { Firecrawl } from "firecrawl";
import z from "zod";
import { htmlToText } from "./builtin";
import { MAX_PAGE_BYTES, WebAccessError } from "./contracts";
import { normalizeSearch, parseResponse } from "./transport";
import { normalizeSearch, parseResponse, postJson } from "./transport";
function client({ connection, signal }: ProviderRequest) {
signal.throwIfAborted();
const sdk = new Firecrawl({
apiKey: connection.apiKey ?? "",
apiUrl: connection.apiUrl || "https://api.firecrawl.dev",
timeoutMs: 15_000,
// SDK maxRetries counts attempts, not retries. Disable automatic retries and scrape auto-resume.
maxRetries: 1,
});
// Firecrawl 4.42 has no public abort/size options. Bind its per-request Axios transport; real-SDK tests guard this.
const transport = sdk as unknown as {
http: {
instance: {
defaults: {
signal: AbortSignal;
maxContentLength: number;
maxRedirects: number;
};
};
};
};
Object.assign(transport.http.instance.defaults, {
function request(path: string, body: unknown, { connection, signal }: ProviderRequest) {
const base = (connection.apiUrl || "https://api.firecrawl.dev").replace(/\/+$/, "");
return postJson(
`${base}/v2/${path}`,
connection.apiKey ? { authorization: `Bearer ${connection.apiKey}` } : {},
body,
signal,
maxContentLength: MAX_PAGE_BYTES,
maxRedirects: 0,
});
return sdk;
}
async function request<T>(operation: () => Promise<T>, signal: AbortSignal): Promise<T> {
try {
return await operation();
} catch (error) {
signal.throwIfAborted();
if (error instanceof WebAccessError) throw error;
const failure = error as {
status?: number;
code?: string;
message?: string;
};
if (failure.status === 401 || failure.status === 403) throw new WebAccessError("auth");
if (failure.status === 402 || failure.status === 429) throw new WebAccessError("quota");
if (failure.status === 200) throw new WebAccessError("malformed");
if (failure.code === "ETIMEDOUT" || failure.code === "ECONNABORTED") throw new WebAccessError("timeout");
if (/maxContentLength/i.test(failure.message ?? "")) throw new WebAccessError("too-large");
throw new WebAccessError("unreachable");
}
);
}
const scrapeSchema = z.object({
@@ -68,18 +28,12 @@ const scrapeSchema = z.object({
});
export async function readFirecrawl(url: string, options: ProviderRequest) {
const result = parseResponse(
scrapeSchema,
const { data: result } = parseResponse(
z.object({ success: z.literal(true), data: scrapeSchema }),
await request(
() =>
client(options).scrape(url, {
formats: ["markdown", "rawHtml"],
onlyMainContent: true,
skipTlsVerification: false,
timeout: 14_000,
autoResume: false,
}),
options.signal,
"scrape",
{ url, formats: ["markdown", "rawHtml"], onlyMainContent: true, skipTlsVerification: false, timeout: 14_000 },
options,
),
);
if (result.metadata?.error || (result.metadata?.statusCode ?? 200) >= 400) throw new WebAccessError("unreachable");
@@ -98,17 +52,9 @@ const itemSchema = z.object({
description: z.string().optional(),
});
export async function searchFirecrawl(query: string, options: ProviderRequest) {
const result = parseResponse(
searchSchema,
await request(
() =>
client(options).search(query, {
sources: ["web"],
limit: 5,
timeout: 14_000,
}),
options.signal,
),
const { data: result } = parseResponse(
z.object({ success: z.literal(true), data: searchSchema }),
await request("search", { query, sources: ["web"], limit: 5, timeout: 14_000 }, options),
);
return normalizeSearch(
result.web.flatMap((value) => {
@@ -168,6 +168,7 @@ beforeEach(() => {
vi.stubEnv("NODE_ENV", "test");
vi.stubGlobal("fetch", (input: string | URL | Request, options?: RequestInit) => {
const url = new globalThis.URL(input instanceof Request ? input.url : input.toString());
if (url.origin === endpoint) return nativeFetch(input, options);
const provider = url.hostname === "api.tavily.com" ? "tavily" : "exa";
return nativeFetch(`${endpoint}/${provider}${url.pathname}`, options);
});
-2
View File
@@ -5,7 +5,6 @@ import { applicationsRouter } from "../features/applications/router";
import { authRouter } from "../features/auth/router";
import { coverLettersRouter } from "../features/cover-letters/router";
import { documentsRouter } from "../features/documents/router";
import { firecrawlRouter } from "../features/firecrawl/router";
import { flagsRouter } from "../features/flags/router";
import { resumeRouter } from "../features/resume/router";
import { statisticsRouter } from "../features/statistics/router";
@@ -21,7 +20,6 @@ export default {
coverLetters: coverLettersRouter,
documents: documentsRouter,
flags: flagsRouter,
firecrawl: firecrawlRouter,
resume: resumeRouter,
statistics: statisticsRouter,
storage: storageRouter,