mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
fix: finalize v6 migrations and document workflows
This commit is contained in:
@@ -56,7 +56,6 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"fflate": "catalog:",
|
||||
"firecrawl": "^4.42.1",
|
||||
"ioredis": "catalog:",
|
||||
"jsonrepair": "catalog:",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
+38
-92
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user