Squashed commit of the following:

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

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

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

    fix: remove timestamp conflict guard

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

    chore(release): v5.1.5

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

    revert: compose.yml

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

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

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

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

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

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

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

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

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

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

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

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

    chore: preserve branch changes before main sync

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

    chore: lot of fixes for monorepo migration

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

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

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

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

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

    chore: update dependencies and enhance PWA metadata in web app

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

    docs: revise manifest-only pwa testing scope

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

    docs: add manifest-only pwa design

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

    chore(dev): simplify server proxy config

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

    docs: add unsafe oauth redirect plan

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

    feat(auth): add unsafe oauth redirect flag

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

    docs: design unsafe oauth redirect flag

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

    chore: update translation source paths

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

    refactor(arch): react spa + hono migration

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

    docs: add docker nightly tagging design

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

    feat: migrate to hono spa server
This commit is contained in:
Amruth Pillai
2026-05-19 13:14:21 +02:00
parent 5b1297fa2b
commit 62f8270b3e
518 changed files with 29398 additions and 26871 deletions
@@ -0,0 +1,24 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { isAgentEnvironmentUnavailable, throwUnavailable } from "./routing";
import { agentService } from "./service";
export const actionsRouter = {
revert: protectedProcedure
.route({
method: "POST",
path: "/agent/actions/{id}/revert",
tags: ["Agent"],
operationId: "revertAgentAction",
summary: "Restore agent action snapshot",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -0,0 +1,62 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { storageUploadRateLimit } from "../../middleware/rate-limit";
import { isAgentEnvironmentUnavailable, throwUnavailable } from "./routing";
import { agentService } from "./service";
function base64ToUint8Array(value: string) {
return Uint8Array.from(Buffer.from(value, "base64"));
}
export const attachmentsRouter = {
create: protectedProcedure
.route({
method: "POST",
path: "/agent/attachments",
tags: ["Agent"],
operationId: "createAgentAttachment",
summary: "Create agent attachment",
})
.input(
z.object({
threadId: z.string(),
filename: z.string().trim().min(1),
mediaType: z.string().trim().min(1),
data: z.string().min(1),
}),
)
.use(storageUploadRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.attachments.create({
userId: context.user.id,
threadId: input.threadId,
filename: input.filename,
mediaType: input.mediaType,
data: base64ToUint8Array(input.data),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/attachments/{id}",
tags: ["Agent"],
operationId: "deleteAgentAttachment",
summary: "Delete agent attachment",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -0,0 +1,84 @@
import type { UIMessage } from "ai";
import z from "zod";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { isAgentEnvironmentUnavailable, isUiMessage, throwUnavailable } from "./routing";
import { agentService } from "./service";
export const messagesRouter = {
send: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/send",
tags: ["Agent"],
operationId: "sendAgentMessage",
summary: "Send agent message",
})
.input(
z.object({
threadId: z.string(),
message: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }),
attachmentIds: z.array(z.string().trim().min(1)).max(10).optional(),
}),
)
.use(aiRequestRateLimit)
.handler(async ({ context, input }) => {
try {
return await agentService.messages.send({
userId: context.user.id,
threadId: input.threadId,
message: input.message,
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
stop: protectedProcedure
.route({
method: "POST",
path: "/agent/messages/stop",
tags: ["Agent"],
operationId: "stopAgentMessage",
summary: "Stop active agent run",
})
.input(
z.object({
threadId: z.string(),
partialMessage: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }).optional(),
}),
)
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.messages.stop({
userId: context.user.id,
threadId: input.threadId,
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
resume: protectedProcedure
.route({
method: "GET",
path: "/agent/messages/resume",
tags: ["Agent"],
operationId: "resumeAgentMessages",
summary: "Resume agent message stream",
})
.input(z.object({ threadId: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./resume";
describe("agent resume setup helpers", () => {
it("names duplicated resumes as AI drafts", () => {
expect(buildAgentDraftResumeName("Senior Product Designer")).toBe("Senior Product Designer - AI Draft");
expect(buildAgentDraftResumeName("Senior Product Designer - AI Draft")).toBe("Senior Product Designer - AI Draft");
});
it("generates unique AI draft slugs", () => {
expect(buildUniqueAgentDraftSlug("Senior Product Designer", new Set())).toBe("senior-product-designer-ai-draft");
expect(buildUniqueAgentDraftSlug("Senior Product Designer", new Set(["senior-product-designer-ai-draft"]))).toBe(
"senior-product-designer-ai-draft-2",
);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { slugify } from "@reactive-resume/utils/string";
const AI_DRAFT_SUFFIX = " - AI Draft";
export function buildAgentDraftResumeName(sourceName: string) {
const normalized = sourceName.trim() || "Resume";
if (normalized.endsWith(AI_DRAFT_SUFFIX)) return normalized;
return `${normalized}${AI_DRAFT_SUFFIX}`;
}
export function buildUniqueAgentDraftSlug(sourceName: string, existingSlugs: Set<string>) {
const base = slugify(buildAgentDraftResumeName(sourceName));
if (!existingSlugs.has(base)) return base;
let index = 2;
let candidate = `${base}-${index}`;
while (existingSlugs.has(candidate)) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
}
+11
View File
@@ -0,0 +1,11 @@
import { actionsRouter } from "./actions";
import { attachmentsRouter } from "./attachments";
import { messagesRouter } from "./messages";
import { threadsRouter } from "./threads";
export const agentRouter = {
threads: threadsRouter,
messages: messagesRouter,
attachments: attachmentsRouter,
actions: actionsRouter,
};
@@ -0,0 +1,23 @@
import type { UIMessage } from "ai";
import { ORPCError } from "@orpc/client";
export function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
export function throwUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
});
}
export function isUiMessage(value: unknown): value is UIMessage {
if (!value || typeof value !== "object") return false;
const message = value as Partial<UIMessage>;
return (
typeof message.id === "string" &&
(message.role === "system" || message.role === "user" || message.role === "assistant") &&
Array.isArray(message.parts)
);
}
+43
View File
@@ -0,0 +1,43 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
type AgentRunStateDb = Pick<typeof db, "update">;
export async function claimActiveAgentRun(
input: { threadId: string; userId: string; runId: string; streamId: string },
database: AgentRunStateDb = db,
) {
const claimed = await database
.update(schema.agentThread)
.set({ activeRunId: input.runId, activeStreamId: input.streamId, activeRunStartedAt: new Date() })
.where(
and(
eq(schema.agentThread.id, input.threadId),
eq(schema.agentThread.userId, input.userId),
isNull(schema.agentThread.activeRunId),
),
)
.returning({ id: schema.agentThread.id });
return claimed.length === 1;
}
export async function clearActiveAgentRunIfCurrent(
input: { threadId: string; userId: string; runId: string; streamId: string | null },
database: AgentRunStateDb = db,
) {
await database
.update(schema.agentThread)
.set({ activeRunId: null, activeStreamId: null, activeRunStartedAt: null })
.where(
and(
eq(schema.agentThread.id, input.threadId),
eq(schema.agentThread.userId, input.userId),
eq(schema.agentThread.activeRunId, input.runId),
input.streamId === null
? isNull(schema.agentThread.activeStreamId)
: eq(schema.agentThread.activeStreamId, input.streamId),
),
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from "vitest";
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./runs";
import { createAgentStreamLifecycle, emptyAgentStream } from "./streams";
vi.mock("@reactive-resume/db/client", () => ({ db: { update: vi.fn() } }));
vi.mock("@reactive-resume/db/schema", () => ({
agentThread: {
id: "agent_threads.id",
userId: "agent_threads.user_id",
activeRunId: "agent_threads.active_run_id",
activeStreamId: "agent_threads.active_stream_id",
},
}));
vi.mock("drizzle-orm", () => ({
and: (...conditions: unknown[]) => ({ type: "and", conditions }),
eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }),
isNull: (value: unknown) => ({ type: "isNull", value }),
}));
async function readStream(stream: ReadableStream<string>) {
const reader = stream.getReader();
const chunks: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return chunks;
}
function createRunStateDb(returningRows: unknown[] = []) {
const returning = vi.fn(async () => returningRows);
const where = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
return {
database: { update },
returning,
set,
update,
where,
};
}
describe("agent run state", () => {
it("claims an active run only when the thread still has no active run", async () => {
const db = createRunStateDb([{ id: "thread-1" }]);
await expect(
claimActiveAgentRun(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: "stream-1" },
db.database as never,
),
).resolves.toBe(true);
expect(db.update).toHaveBeenCalledWith(expect.objectContaining({ id: "agent_threads.id" }));
expect(db.set).toHaveBeenCalledWith({
activeRunId: "run-1",
activeStreamId: "stream-1",
activeRunStartedAt: expect.any(Date),
});
expect(db.where).toHaveBeenCalledWith({
type: "and",
conditions: [
{ type: "eq", left: "agent_threads.id", right: "thread-1" },
{ type: "eq", left: "agent_threads.user_id", right: "user-1" },
{ type: "isNull", value: "agent_threads.active_run_id" },
],
});
});
it("reports a failed claim when the guarded update claims no rows", async () => {
const db = createRunStateDb([]);
await expect(
claimActiveAgentRun(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: "stream-1" },
db.database as never,
),
).resolves.toBe(false);
});
it("clears active run state only for the matching run and stream", async () => {
const db = createRunStateDb();
await clearActiveAgentRunIfCurrent(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: "stream-1" },
db.database as never,
);
expect(db.set).toHaveBeenCalledWith({ activeRunId: null, activeStreamId: null, activeRunStartedAt: null });
expect(db.where).toHaveBeenCalledWith({
type: "and",
conditions: [
{ type: "eq", left: "agent_threads.id", right: "thread-1" },
{ type: "eq", left: "agent_threads.user_id", right: "user-1" },
{ type: "eq", left: "agent_threads.active_run_id", right: "run-1" },
{ type: "eq", left: "agent_threads.active_stream_id", right: "stream-1" },
],
});
});
it("clears active run state with a null stream guard when no stream id was recorded", async () => {
const db = createRunStateDb();
await clearActiveAgentRunIfCurrent(
{ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: null },
db.database as never,
);
expect(db.where).toHaveBeenCalledWith({
type: "and",
conditions: [
{ type: "eq", left: "agent_threads.id", right: "thread-1" },
{ type: "eq", left: "agent_threads.user_id", right: "user-1" },
{ type: "eq", left: "agent_threads.active_run_id", right: "run-1" },
{ type: "isNull", value: "agent_threads.active_stream_id" },
],
});
});
});
describe("agent stream lifecycle", () => {
it("returns a closed stream when no active stream id exists", async () => {
const lifecycle = createAgentStreamLifecycle({
getContext: () => {
throw new Error("context should not be used");
},
});
await expect(readStream(await lifecycle.resume(null))).resolves.toEqual([]);
});
it("creates a resumable stream from UI message SSE chunks", async () => {
const createNewResumableStream = vi.fn(async (_streamId: string, makeStream: () => ReadableStream<string>) =>
makeStream(),
);
const lifecycle = createAgentStreamLifecycle({
getContext: () => ({
createNewResumableStream,
resumeExistingStream: vi.fn(),
}),
});
const stream = await lifecycle.create(
"stream-1",
() =>
new ReadableStream({
start(controller) {
controller.enqueue({ type: "text-start", id: "text-1" });
controller.enqueue({ type: "text-delta", id: "text-1", delta: "Hello" });
controller.close();
},
}),
);
await expect(readStream(stream)).resolves.toEqual([
'data: {"type":"text-start","id":"text-1"}\n\n',
'data: {"type":"text-delta","id":"text-1","delta":"Hello"}\n\n',
"data: [DONE]\n\n",
]);
expect(createNewResumableStream).toHaveBeenCalledWith("stream-1", expect.any(Function));
});
it("returns a closed stream when the active stream is missing or already done", async () => {
const lifecycle = createAgentStreamLifecycle({
getContext: () => ({
createNewResumableStream: vi.fn(),
resumeExistingStream: vi.fn(async () => null),
}),
});
await expect(readStream(await lifecycle.resume("stream-1"))).resolves.toEqual([]);
await expect(readStream(emptyAgentStream())).resolves.toEqual([]);
});
});
@@ -0,0 +1,50 @@
import type { UIMessageChunk } from "ai";
import type { ResumableStreamContext } from "resumable-stream/ioredis";
import { JsonToSseTransformStream } from "ai";
import { createResumableStreamContext } from "resumable-stream/ioredis";
type AgentStreamContext = Pick<ResumableStreamContext, "createNewResumableStream" | "resumeExistingStream">;
type AgentStreamLifecycleOptions = {
getContext: () => AgentStreamContext;
};
let streamContext: AgentStreamContext | null = null;
export function emptyAgentStream() {
return new ReadableStream<string>({
start(controller) {
controller.close();
},
});
}
function getAgentStreamContext() {
streamContext ??= createResumableStreamContext({
keyPrefix: "reactive-resume:agent-stream",
waitUntil: null,
});
return streamContext;
}
export function createAgentStreamLifecycle(options: AgentStreamLifecycleOptions) {
return {
async create(streamId: string, makeStream: () => ReadableStream<UIMessageChunk>) {
const stream = await options
.getContext()
.createNewResumableStream(streamId, () => makeStream().pipeThrough(new JsonToSseTransformStream()));
return stream ?? emptyAgentStream();
},
async resume(streamId: string | null | undefined) {
if (!streamId) return emptyAgentStream();
const stream = await options.getContext().resumeExistingStream(streamId);
return stream ?? emptyAgentStream();
},
};
}
export const agentStreamLifecycle = createAgentStreamLifecycle({ getContext: getAgentStreamContext });
+102
View File
@@ -0,0 +1,102 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { isAgentEnvironmentUnavailable, throwUnavailable } from "./routing";
import { agentService } from "./service";
export const threadsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/agent/threads",
tags: ["Agent"],
operationId: "listAgentThreads",
summary: "List agent threads",
})
.handler(async ({ context }) => {
try {
return await agentService.threads.list({ userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
create: protectedProcedure
.route({
method: "POST",
path: "/agent/threads",
tags: ["Agent"],
operationId: "createAgentThread",
summary: "Create agent thread",
})
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.create({
userId: context.user.id,
locale: context.locale,
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
get: protectedProcedure
.route({
method: "GET",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "getAgentThread",
summary: "Get agent thread",
})
.input(z.object({ id: z.string() }))
.handler(async ({ context, input }) => {
try {
return await agentService.threads.get({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
archive: protectedProcedure
.route({
method: "POST",
path: "/agent/threads/{id}/archive",
tags: ["Agent"],
operationId: "archiveAgentThread",
summary: "Archive agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.archive({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/agent/threads/{id}",
tags: ["Agent"],
operationId: "deleteAgentThread",
summary: "Delete agent thread",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
try {
await agentService.threads.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
};
@@ -0,0 +1,96 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { describe, expect, it } from "vitest";
import { buildAgentInstructions, buildAgentTools } from "./tools";
const handlers = {
readResume: async () => ({
id: "resume-1",
name: "Resume",
updatedAt: "2026-05-13T00:00:00.000Z",
data: {},
}),
readAttachment: async () => ({
id: "attachment-1",
filename: "job.md",
mediaType: "text/markdown",
size: 128,
content: "Job description",
}),
applyResumePatch: async () => ({
actionId: "action-1",
resumeId: "resume-1",
title: "Update resume",
summary: null,
operations: [],
appliedUpdatedAt: "2026-05-13T00:00:00.000Z",
}),
};
function buildTools(provider: AIProvider, options?: { model?: string; baseURL?: string }) {
return buildAgentTools({
provider: { provider, model: options?.model ?? "gpt-5-mini", apiKey: "test-key", baseURL: options?.baseURL ?? "" },
handlers,
});
}
describe("agent tools", () => {
it("adds provider-native web search for direct OpenAI providers", () => {
const tools = buildTools("openai");
expect(tools).toHaveProperty("web_search");
});
it("adds provider-native web search for OpenAI providers using the explicit default base URL", () => {
const tools = buildTools("openai", { baseURL: "https://api.openai.com/v1" });
expect(tools).toHaveProperty("web_search");
});
it("does not add provider-native web search for OpenAI providers with a custom base URL", () => {
const tools = buildTools("openai", { baseURL: "https://openai-compatible.example.com/v1" });
expect(tools).not.toHaveProperty("web_search");
});
it.each([
"https://api.openai.com/v1?proxy=1",
"https://api.openai.com/v1#fragment",
])("does not add provider-native web search for OpenAI providers with non-exact base URL %s", (baseURL) => {
const tools = buildTools("openai", { baseURL });
expect(tools).not.toHaveProperty("web_search");
});
it("does not add provider-native web search for unsupported OpenAI models", () => {
const tools = buildTools("openai", { model: "custom-model" });
expect(tools).not.toHaveProperty("web_search");
});
it.each<AIProvider>([
"anthropic",
"gemini",
"vercel-ai-gateway",
"openrouter",
"ollama",
"openai-compatible",
])("does not add provider-native web search for %s", (provider) => {
const tools = buildTools(provider);
expect(tools).not.toHaveProperty("web_search");
});
it("keeps instructions explicit about native search availability", () => {
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("Use web_search");
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("user-provided public URLs");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).not.toContain("Use web_search");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Live web research is unavailable");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain(
"paste or attach the relevant content",
);
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Batch related JSON Patch operations");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("/basics/name");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("never /data/basics/name or /name");
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("clean Markdown");
});
});
+94
View File
@@ -0,0 +1,94 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ToolSet } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { tool } from "ai";
import z from "zod";
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
import { supportsProviderNativeWebSearch } from "../ai/capabilities";
type AgentProviderConfig = {
provider: AIProvider;
model: string;
apiKey: string;
baseURL?: string | null;
};
const applyResumePatchToolInputSchema = z.object({
title: z.string().trim().min(1),
summary: z.string().trim().optional(),
operations: z.array(jsonPatchOperationSchema).min(1),
});
type ApplyResumePatchToolInput = z.infer<typeof applyResumePatchToolInputSchema>;
type BuildAgentToolsInput = {
provider: AgentProviderConfig;
handlers: {
readResume: () => Promise<unknown>;
readAttachment: (attachmentId: string) => Promise<unknown>;
applyResumePatch: (input: ApplyResumePatchToolInput) => Promise<unknown>;
};
};
function buildProviderNativeAgentTools(provider: AgentProviderConfig): ToolSet {
if (!supportsProviderNativeWebSearch(provider)) return {};
const openai = createOpenAI({
apiKey: provider.apiKey,
...(provider.baseURL ? { baseURL: provider.baseURL } : {}),
});
// Defensive runtime check: older `@ai-sdk/openai` versions and some OpenAI-compatible
// gateways don't expose tools.webSearch. supportsProviderNativeWebSearch() filters out
// non-OpenAI providers, but this guards against SDK-shape drift on the OpenAI path.
if (typeof openai.tools.webSearch !== "function") return {};
return {
web_search: openai.tools.webSearch({
searchContextSize: "low",
}),
};
}
export function buildAgentInstructions({ hasProviderNativeSearch }: { hasProviderNativeSearch: boolean }) {
const baseInstructions =
"You are an expert resume-writing agent inside Reactive Resume. Help the user improve the working resume for a target role. Read the resume before editing. Respond to the user in clean Markdown with concise paragraphs, bullets, and bold text when it improves scanability. Apply concise, valid JSON Patch operations when changes are useful. Patch paths are evaluated against the resume data object returned by read_resume, so use paths like /basics/name for the visible name and never /data/basics/name or /name. apply_resume_patch cannot rename the resume file/title metadata. Batch related JSON Patch operations into one apply_resume_patch call for each coherent edit instead of making repeated patch calls for the same request. Ask the user a question when a missing preference blocks a high-confidence edit.";
if (!hasProviderNativeSearch) {
return `${baseInstructions} Live web research is unavailable with the selected provider or model. If the user asks you to browse, search the web, fetch a URL, or use current online context, briefly tell them live web research is unavailable with the selected provider/model and ask them to paste or attach the relevant content. Continue normal resume editing using the resume, chat context, and attachments.`;
}
return `${baseInstructions} Use web_search for live or current web research, including user-provided public URLs, job descriptions, company pages, and recent company, industry, or role context.`;
}
export function buildAgentTools(input: BuildAgentToolsInput): ToolSet {
return {
...buildProviderNativeAgentTools(input.provider),
ask_user_question: tool({
description:
"Ask the user a short question when you need a preference, missing fact, or choice before continuing. Provide 2-4 recommended answer choices when possible.",
inputSchema: z.object({
question: z.string().trim().min(1),
choices: z.array(z.string().trim().min(1)).min(1).max(4).optional(),
recommendedChoice: z.string().trim().optional(),
}),
}),
read_resume: tool({
description: "Read the current working resume JSON and metadata.",
inputSchema: z.object({}),
execute: input.handlers.readResume,
}),
read_attachment: tool({
description:
"Read a message attachment by id. Text, Markdown, and JSON attachments include content; images and supported files may already be provided directly to the model.",
inputSchema: z.object({ attachmentId: z.string().trim().min(1) }),
execute: ({ attachmentId }) => input.handlers.readAttachment(attachmentId),
}),
apply_resume_patch: tool({
description:
"Apply one cohesive batch of JSON Patch operations to the working resume data immediately. Paths are rooted at resume data; use /basics/name for the visible resume name, not /data/basics/name or /name. This tool cannot rename the resume file/title metadata. The user can restore the draft to the snapshot captured before a patch later.",
inputSchema: applyResumePatchToolInputSchema,
execute: (toolInput) => input.handlers.applyResumePatch(toolInput),
}),
};
}
@@ -0,0 +1,185 @@
import type { AiProviderResponse } from "./service";
import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import z from "zod";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { aiProvidersService } from "./service";
const providerInput = z.object({
label: z.string().trim().min(1),
provider: aiProviderSchema,
model: z.string().trim().min(1),
baseURL: z.string().trim().optional().default(""),
apiKey: z.string().trim().min(1),
});
const updateProviderInput = providerInput
.partial()
.extend({ id: z.string(), enabled: z.boolean().optional() })
.refine((input) => Object.keys(input).some((key) => key !== "id"), {
message: "At least one field must be provided.",
});
function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
function throwUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
});
}
function isInvalidAiBaseUrl(error: unknown) {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
function throwInvalidProviderConfig(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
export const aiProvidersRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/ai-providers",
tags: ["AI Providers"],
operationId: "listAiProviders",
summary: "List saved AI providers",
description: "Lists saved provider/model/API key combinations for the authenticated user. API keys are redacted.",
})
.output(type<AiProviderResponse[]>())
.errors({
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context }) => {
try {
return await aiProvidersService.list({ userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
create: protectedProcedure
.route({
method: "POST",
path: "/ai-providers",
tags: ["AI Providers"],
operationId: "createAiProvider",
summary: "Create saved AI provider",
description: "Stores an encrypted provider/model/API key combination. The key is never returned.",
})
.input(providerInput)
.output(type<AiProviderResponse>())
.errors({
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
return await aiProvidersService.create({
userId: context.user.id,
label: input.label,
provider: input.provider,
model: input.model,
baseURL: input.baseURL,
apiKey: input.apiKey,
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
throw error;
}
}),
update: protectedProcedure
.route({
method: "PATCH",
path: "/ai-providers/{id}",
tags: ["AI Providers"],
operationId: "updateAiProvider",
summary: "Update saved AI provider",
description:
"Updates a saved provider/model/API key combination. Updating the key requires retesting before use.",
})
.input(updateProviderInput)
.output(type<AiProviderResponse>())
.errors({
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
NOT_FOUND: { message: "AI provider was not found.", status: 404 },
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
return await aiProvidersService.update({
id: input.id,
userId: context.user.id,
...(input.label !== undefined ? { label: input.label } : {}),
...(input.provider !== undefined ? { provider: input.provider } : {}),
...(input.model !== undefined ? { model: input.model } : {}),
...(input.baseURL !== undefined ? { baseURL: input.baseURL } : {}),
...(input.apiKey !== undefined ? { apiKey: input.apiKey } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
throw error;
}
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/ai-providers/{id}",
tags: ["AI Providers"],
operationId: "deleteAiProvider",
summary: "Delete saved AI provider",
description: "Deletes a saved provider/model/API key combination.",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.errors({
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
await aiProvidersService.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
test: protectedProcedure
.route({
method: "POST",
path: "/ai-providers/{id}/test",
tags: ["AI Providers"],
operationId: "testAiProvider",
summary: "Test saved AI provider",
description: "Decrypts the saved API key server-side and validates the provider/model connection.",
})
.input(z.object({ id: z.string() }))
.output(type<AiProviderResponse>())
.use(aiRequestRateLimit)
.errors({
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
NOT_FOUND: { message: "AI provider was not found.", status: 404 },
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
return await aiProvidersService.test({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
if (error instanceof ORPCError) throw error;
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
}
}),
};
@@ -0,0 +1,275 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { ORPCError } from "@orpc/client";
import { and, asc, desc, eq, sql } from "drizzle-orm";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import {
assertCredentialEncryptionConfigured,
decryptCredential,
encryptCredential,
redactEncryptedCredential,
} from "../ai/credentials";
import { testConnection } from "../ai/service";
import { resolveAiBaseUrl } from "../ai/url-policy";
type AiProviderRecord = typeof schema.aiProvider.$inferSelect;
export type AiProviderResponse = {
id: string;
label: string;
provider: AIProvider;
model: string;
baseURL: string | null;
enabled: boolean;
testStatus: string;
testError: string | null;
apiKeyPreview: string;
apiKeyFingerprint: string;
lastTestedAt: Date | null;
lastUsedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type CreateAiProviderInput = {
userId: string;
label: string;
provider: AIProvider;
model: string;
baseURL?: string | null;
apiKey: string;
};
type UpdateAiProviderInput = {
id: string;
userId: string;
label?: string;
provider?: AIProvider;
model?: string;
baseURL?: string | null;
apiKey?: string;
enabled?: boolean;
};
function toResponse(row: AiProviderRecord): AiProviderResponse {
const provider = aiProviderSchema.parse(row.provider);
const { apiKeyFingerprint, apiKeyPreview } = redactEncryptedCredential({
encryptedApiKey: row.encryptedApiKey,
apiKeySalt: row.apiKeySalt,
apiKeyHash: row.apiKeyHash,
apiKeyPreview: row.apiKeyPreview,
});
return {
id: row.id,
label: row.label,
provider,
model: row.model,
baseURL: row.baseUrl,
enabled: row.enabled,
testStatus: row.testStatus,
testError: row.testError,
apiKeyPreview,
apiKeyFingerprint,
lastTestedAt: row.lastTestedAt,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function normalizeBaseUrl(input: { provider: AIProvider; baseURL?: string | null }) {
const trimmed = input.baseURL?.trim() ?? "";
if (!trimmed) return null;
return resolveAiBaseUrl({ provider: input.provider, baseURL: trimmed });
}
function orderByLastUsedAtDescNullsLast() {
return desc(sql<Date>`coalesce(${schema.aiProvider.lastUsedAt}, '1970-01-01T00:00:00.000Z'::timestamptz)`);
}
async function getOwnedProvider(input: { id: string; userId: string }) {
const [provider] = await db
.select()
.from(schema.aiProvider)
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.limit(1);
if (!provider) throw new ORPCError("NOT_FOUND");
return provider;
}
export const aiProvidersService = {
list: async (input: { userId: string }) => {
assertCredentialEncryptionConfigured();
const providers = await db
.select()
.from(schema.aiProvider)
.where(eq(schema.aiProvider.userId, input.userId))
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt));
return providers.map(toResponse);
},
getRunnableById: async (input: { id: string; userId: string }) => {
assertCredentialEncryptionConfigured();
const provider = await getOwnedProvider(input);
if (!provider.enabled || provider.testStatus !== "success") {
throw new ORPCError("BAD_REQUEST", { message: "AI provider must be tested and enabled before use." });
}
return {
...toResponse(provider),
apiKey: decryptCredential(provider.encryptedApiKey),
baseURL: provider.baseUrl ?? "",
};
},
getDefaultRunnable: async (input: { userId: string }) => {
assertCredentialEncryptionConfigured();
const [provider] = await db
.select()
.from(schema.aiProvider)
.where(
and(
eq(schema.aiProvider.userId, input.userId),
eq(schema.aiProvider.enabled, true),
eq(schema.aiProvider.testStatus, "success"),
),
)
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt))
.limit(1);
return provider
? {
...toResponse(provider),
apiKey: decryptCredential(provider.encryptedApiKey),
baseURL: provider.baseUrl ?? "",
}
: null;
},
create: async (input: CreateAiProviderInput) => {
assertCredentialEncryptionConfigured();
const encrypted = encryptCredential(input.apiKey.trim());
const [provider] = await db
.insert(schema.aiProvider)
.values({
userId: input.userId,
label: input.label.trim(),
provider: input.provider,
model: input.model.trim(),
baseUrl: normalizeBaseUrl(input),
...encrypted,
})
.returning();
if (!provider) throw new Error("AI_PROVIDER_CREATE_FAILED");
return toResponse(provider);
},
update: async (input: UpdateAiProviderInput) => {
assertCredentialEncryptionConfigured();
const existing = await getOwnedProvider(input);
const provider = input.provider ?? aiProviderSchema.parse(existing.provider);
const nextApiKey = input.apiKey?.trim();
const encrypted = nextApiKey ? encryptCredential(nextApiKey) : {};
const credentialChanged = !!nextApiKey;
const nextBaseUrl =
input.baseURL !== undefined ? normalizeBaseUrl({ provider, baseURL: input.baseURL }) : existing.baseUrl;
const providerChanged = input.provider !== undefined && input.provider !== existing.provider;
const modelChanged = input.model !== undefined && input.model.trim() !== existing.model;
const baseUrlChanged = input.baseURL !== undefined && nextBaseUrl !== existing.baseUrl;
const runtimeChanged = credentialChanged || providerChanged || modelChanged || baseUrlChanged;
if (input.enabled === true && existing.testStatus !== "success" && !runtimeChanged) {
throw new ORPCError("BAD_REQUEST", { message: "AI provider must be tested successfully before enabling." });
}
const [updated] = await db
.update(schema.aiProvider)
.set({
...(input.label !== undefined ? { label: input.label.trim() } : {}),
...(input.provider !== undefined ? { provider: input.provider } : {}),
...(input.model !== undefined ? { model: input.model.trim() } : {}),
...(input.baseURL !== undefined ? { baseUrl: nextBaseUrl } : {}),
...(input.enabled !== undefined && !runtimeChanged ? { enabled: input.enabled } : {}),
...(runtimeChanged ? { enabled: false, testStatus: "untested", lastTestedAt: null, testError: null } : {}),
...encrypted,
})
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return toResponse(updated);
},
delete: async (input: { id: string; userId: string }) => {
assertCredentialEncryptionConfigured();
await db
.delete(schema.aiProvider)
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)));
},
test: async (input: { id: string; userId: string }) => {
assertCredentialEncryptionConfigured();
const provider = await getOwnedProvider(input);
const parsedProvider = aiProviderSchema.parse(provider.provider);
const apiKey = decryptCredential(provider.encryptedApiKey);
try {
const ok = await testConnection({
provider: parsedProvider,
model: provider.model,
apiKey,
baseURL: provider.baseUrl ?? "",
});
const [updated] = await db
.update(schema.aiProvider)
.set({
enabled: ok,
testStatus: ok ? "success" : "failure",
testError: ok ? null : "The provider test returned an unexpected response.",
lastTestedAt: new Date(),
})
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.returning();
if (!updated) throw new ORPCError("NOT_FOUND");
return toResponse(updated);
} catch (error) {
const [updated] = await db
.update(schema.aiProvider)
.set({
enabled: false,
testStatus: "failure",
testError: error instanceof Error ? error.message : "Failed to test provider.",
lastTestedAt: new Date(),
})
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.returning();
if (!updated) throw error;
throw error;
}
},
markUsed: async (input: { id: string; userId: string }) => {
await db
.update(schema.aiProvider)
.set({ lastUsedAt: new Date() })
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)));
},
};
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { isDirectOpenAIProvider, supportsOpenAIWebSearch } from "./capabilities";
describe("AI provider capabilities", () => {
it("identifies direct OpenAI base URL configs", () => {
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "" })).toBe(true);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1/" })).toBe(true);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://example.com/v1" })).toBe(false);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1?proxy=1" })).toBe(false);
expect(isDirectOpenAIProvider({ provider: "openai", baseURL: "https://api.openai.com/v1#fragment" })).toBe(false);
expect(isDirectOpenAIProvider({ provider: "openrouter", baseURL: "https://api.openai.com/v1" })).toBe(false);
});
it("keeps the OpenAI web search model predicate conservative", () => {
const allowedModels = [
"gpt-5.5",
"gpt-5.5-2026-04-23",
"gpt-5.5-pro",
"gpt-5.5-pro-2026-04-23",
"gpt-5.4",
"gpt-5.4-2026-03-05",
"gpt-5.4-mini",
"gpt-5.4-mini-2026-03-17",
"gpt-5.4-nano",
"gpt-5.4-nano-2026-03-17",
"gpt-5.4-pro",
"gpt-5.4-pro-2026-03-05",
"gpt-5",
"gpt-5-2025-08-07",
"gpt-5-mini",
"gpt-5-mini-2025-08-07",
"gpt-5-nano",
"gpt-5-nano-2025-08-07",
"gpt-4.1",
"gpt-4.1-2025-04-14",
"gpt-4.1-mini",
"gpt-4.1-mini-2025-04-14",
"o4-mini",
"o4-mini-2025-04-16",
];
const deniedModels = [
"gpt-4.1-nano",
"gpt-4.1-nano-2025-04-14",
"gpt-4o",
"gpt-4o-mini",
"gpt-4o-search-preview",
"o1",
"o1-2024-12-17",
"o3",
"o3-mini",
"gpt-3.5-turbo",
"gpt-5-codex",
"gpt-5.1-codex",
"gpt-5.5-codex",
"gpt-4x1-2025-04-14",
"gpt-5x5-2026-04-23",
"gpt-5x5-pro-2026-04-23",
"custom-model",
];
for (const model of allowedModels) {
expect(supportsOpenAIWebSearch(model), model).toBe(true);
}
for (const model of deniedModels) {
expect(supportsOpenAIWebSearch(model), model).toBe(false);
}
});
});
@@ -0,0 +1,90 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
type AiProviderCapabilityInput = {
provider: AIProvider;
model: string;
baseURL?: string | null;
};
function normalizeDirectOpenAIBaseUrl(baseURL: string) {
try {
const parsed = new URL(baseURL);
if (parsed.search || parsed.hash) return null;
return parsed.toString().replace(/\/+$/, "");
} catch {
return baseURL.trim().replace(/\/+$/, "");
}
}
export function isDirectOpenAIProvider(input: Pick<AiProviderCapabilityInput, "provider" | "baseURL">) {
if (input.provider !== "openai") return false;
if (!input.baseURL?.trim()) return true;
const baseURL = normalizeDirectOpenAIBaseUrl(input.baseURL);
if (!baseURL) return false;
return baseURL === normalizeDirectOpenAIBaseUrl(AI_PROVIDER_DEFAULT_BASE_URLS.openai);
}
const OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS = new Set([
// Snapshot from official OpenAI model docs on 2026-05-13. These model pages list Responses
// API support and Responses web search support. Most are also explicit in installed
// @ai-sdk/openai OpenAIResponsesModelId; gpt-5.5-pro is accepted through the SDK's string
// model ID fallback and openai.responses("gpt-5.5-pro") runtime construction.
// https://developers.openai.com/api/docs/models/gpt-5.5-pro
"gpt-5.5-pro",
// https://developers.openai.com/api/docs/models/gpt-5.5
"gpt-5.5",
// https://developers.openai.com/api/docs/models/gpt-5.4
"gpt-5.4",
// https://developers.openai.com/api/docs/models/gpt-5.4-mini
"gpt-5.4-mini",
// https://developers.openai.com/api/docs/models/gpt-5.4-nano
"gpt-5.4-nano",
// https://developers.openai.com/api/docs/models/gpt-5.4-pro
"gpt-5.4-pro",
// https://developers.openai.com/api/docs/models/gpt-5
"gpt-5",
// https://developers.openai.com/api/docs/models/gpt-5-mini
"gpt-5-mini",
// https://developers.openai.com/api/docs/models/gpt-5-nano
"gpt-5-nano",
// https://developers.openai.com/api/docs/models/gpt-4.1
"gpt-4.1",
// https://developers.openai.com/api/docs/models/gpt-4.1-mini
"gpt-4.1-mini",
// https://developers.openai.com/api/docs/guides/tools-web-search?api-mode=responses
"o4-mini",
]);
function isDateSnapshotForModel(model: string, modelId: string) {
const snapshotPrefix = `${modelId}-`;
if (!model.startsWith(snapshotPrefix)) return false;
const suffix = model.slice(snapshotPrefix.length);
const [year, month, day] = suffix.split("-");
return (
suffix.length === "YYYY-MM-DD".length &&
year?.length === 4 &&
month?.length === 2 &&
day?.length === 2 &&
[year, month, day].every((part) => /^\d+$/.test(part))
);
}
export function supportsOpenAIWebSearch(model: string) {
const normalized = model.trim().toLowerCase();
if (!normalized || normalized.includes("codex")) return false;
if (OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS.has(normalized)) return true;
return Array.from(OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS).some((modelId) =>
isDateSnapshotForModel(normalized, modelId),
);
}
export function supportsProviderNativeWebSearch(provider: AiProviderCapabilityInput) {
return isDirectOpenAIProvider(provider) && supportsOpenAIWebSearch(provider.model);
}
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
ENCRYPTION_SECRET: "test-secret-with-enough-entropy",
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const {
assertAgentEnvironment,
decryptCredential,
encryptCredential,
fingerprintCredential,
isAgentEnvironmentConfigured,
redactEncryptedCredential,
} = await import("./credentials");
describe("AI credential encryption", () => {
it("encrypts and decrypts provider API keys without storing plaintext", () => {
const encrypted = encryptCredential("sk-test-secret");
expect(encrypted.encryptedApiKey).not.toContain("sk-test-secret");
expect(encrypted.apiKeyPreview).toBe("sk-t...cret");
expect(decryptCredential(encrypted.encryptedApiKey)).toBe("sk-test-secret");
});
it("generates salted non-revealable fingerprints", () => {
const first = fingerprintCredential("sk-test-secret", "salt-a");
const again = fingerprintCredential("sk-test-secret", "salt-a");
const differentSalt = fingerprintCredential("sk-test-secret", "salt-b");
expect(first).toBe(again);
expect(first).not.toBe(differentSalt);
expect(first).not.toContain("sk-test-secret");
});
it("redacts stored encrypted credential fields from API responses", () => {
const encrypted = encryptCredential("sk-test-secret");
const redacted = redactEncryptedCredential({
encryptedApiKey: encrypted.encryptedApiKey,
apiKeySalt: encrypted.apiKeySalt,
apiKeyHash: encrypted.apiKeyHash,
apiKeyPreview: encrypted.apiKeyPreview,
});
expect(redacted).toEqual({
apiKeyFingerprint: encrypted.apiKeyHash,
apiKeyPreview: encrypted.apiKeyPreview,
});
expect(JSON.stringify(redacted)).not.toContain(encrypted.encryptedApiKey);
expect(JSON.stringify(redacted)).not.toContain(encrypted.apiKeySalt);
});
});
describe("AI agent environment", () => {
it("is available only when Redis and encryption secret are configured", () => {
expect(isAgentEnvironmentConfigured()).toBe(true);
expect(() => assertAgentEnvironment()).not.toThrow();
envMock.REDIS_URL = "";
expect(isAgentEnvironmentConfigured()).toBe(false);
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
envMock.REDIS_URL = "redis://localhost:6379";
envMock.ENCRYPTION_SECRET = "";
expect(isAgentEnvironmentConfigured()).toBe(false);
expect(() => assertAgentEnvironment()).toThrow("AGENT_ENVIRONMENT_UNAVAILABLE");
});
});
+105
View File
@@ -0,0 +1,105 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { env } from "@reactive-resume/env/server";
const CIPHER = "aes-256-gcm";
const CREDENTIAL_VERSION = "v1";
const IV_BYTES = 12;
const SALT_BYTES = 16;
type StoredCredentialFields = {
encryptedApiKey: string;
apiKeySalt: string;
apiKeyHash: string;
apiKeyPreview: string;
};
type RedactedCredentialFields = {
apiKeyFingerprint: string;
apiKeyPreview: string;
};
function getEncryptionSecret() {
return env.ENCRYPTION_SECRET?.trim() ?? "";
}
function getEncryptionKey() {
const secret = getEncryptionSecret();
if (!secret) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
return createHash("sha256").update(secret).digest();
}
function encode(value: Buffer) {
return value.toString("base64url");
}
function decode(value: string) {
return Buffer.from(value, "base64url");
}
function makePreview(apiKey: string) {
const trimmed = apiKey.trim();
if (trimmed.length <= 8) return "••••";
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
}
export function fingerprintCredential(apiKey: string, salt: string) {
return createHash("sha256").update(salt).update(":").update(apiKey).digest("hex");
}
export function encryptCredential(apiKey: string): StoredCredentialFields {
const iv = randomBytes(IV_BYTES);
const salt = encode(randomBytes(SALT_BYTES));
const cipher = createCipheriv(CIPHER, getEncryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload = [CREDENTIAL_VERSION, encode(iv), encode(authTag), encode(ciphertext)].join(".");
return {
encryptedApiKey: payload,
apiKeySalt: salt,
apiKeyHash: fingerprintCredential(apiKey, salt),
apiKeyPreview: makePreview(apiKey),
};
}
export function decryptCredential(payload: string) {
const [version, encodedIv, encodedAuthTag, encodedCiphertext] = payload.split(".");
if (version !== CREDENTIAL_VERSION || !encodedIv || !encodedAuthTag || !encodedCiphertext) {
throw new Error("INVALID_ENCRYPTED_CREDENTIAL");
}
const decipher = createDecipheriv(CIPHER, getEncryptionKey(), decode(encodedIv));
decipher.setAuthTag(decode(encodedAuthTag));
return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString("utf8");
}
export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields {
return {
apiKeyFingerprint: fields.apiKeyHash,
apiKeyPreview: fields.apiKeyPreview,
};
}
function isCredentialEncryptionConfigured() {
return !!getEncryptionSecret();
}
function isAgentStreamingConfigured() {
return !!env.REDIS_URL?.trim();
}
export function isAgentEnvironmentConfigured() {
return isCredentialEncryptionConfigured() && isAgentStreamingConfigured();
}
export function assertCredentialEncryptionConfigured() {
if (!isCredentialEncryptionConfigured()) throw new Error("AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE");
}
export function assertAgentEnvironment() {
if (!isAgentEnvironmentConfigured()) throw new Error("AGENT_ENVIRONMENT_UNAVAILABLE");
}
+241
View File
@@ -0,0 +1,241 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { UIMessage } from "ai";
import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import { AISDKError } from "ai";
import { flattenError, ZodError, z } from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { protectedProcedure } from "../../context";
import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { aiProvidersService } from "../ai-providers/service";
import { resumeService } from "../resume/service";
import { aiService, fileInputSchema } from "./service";
function isInvalidAiBaseUrlError(error: unknown): boolean {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
function isAiProviderGatewayError(error: unknown): boolean {
return error instanceof AISDKError;
}
function isCredentialEncryptionUnavailable(error: unknown): boolean {
return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE";
}
function throwAiProviderGatewayError(): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
}
function throwAiProviderConfigError(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
function throwCredentialEncryptionUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
});
}
function throwResumeStructureError(error: ZodError): never {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
async function getRunnableProvider(userId: string, aiProviderId?: string) {
const provider = aiProviderId
? await aiProvidersService.getRunnableById({ id: aiProviderId, userId })
: await aiProvidersService.getDefaultRunnable({ userId });
if (!provider) throw new ORPCError("BAD_REQUEST", { message: "No tested AI provider is available." });
return provider;
}
export const aiRouter = {
parsePdf: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-pdf",
tags: ["AI"],
operationId: "parseResumePdf",
summary: "Parse a PDF file into resume data",
description:
"Extracts structured resume data from a PDF file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The PDF was successfully parsed into structured resume data.",
})
.input(z.object({ aiProviderId: z.string().optional(), file: fileInputSchema }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ context, input }): Promise<ResumeData> => {
try {
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
return await aiService.parsePdf({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
file: input.file,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
parseDocx: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-docx",
tags: ["AI"],
operationId: "parseResumeDocx",
summary: "Parse a DOCX file into resume data",
description:
"Extracts structured resume data from a DOCX or DOC file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials and the document's media type. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The DOCX was successfully parsed into structured resume data.",
})
.input(
z.object({
aiProviderId: z.string().optional(),
file: fileInputSchema,
mediaType: z.enum([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]),
}),
)
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ context, input }) => {
try {
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
return await aiService.parseDocx({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
mediaType: input.mediaType,
file: input.file,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
chat: protectedProcedure
.route({
method: "POST",
path: "/ai/chat",
tags: ["AI"],
operationId: "aiChat",
summary: "Chat with AI to modify resume",
description:
"Streams a chat response from the configured AI provider. The LLM can call the propose_resume_patches tool to generate JSON Patch proposals for explicit user approval. Requires authentication and AI provider credentials.",
})
.input(
type<{
aiProviderId?: string;
messages: UIMessage[];
resumeId: string;
}>(),
)
.use(aiRequestRateLimit)
.handler(async ({ context, input }) => {
try {
const [provider, resume] = await Promise.all([
getRunnableProvider(context.user.id, input.aiProviderId),
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
]);
return await aiService.chat({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
messages: input.messages,
resumeData: resume.data,
resumeUpdatedAt: resume.updatedAt,
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
analyzeResume: protectedProcedure
.route({
method: "POST",
path: "/ai/analyze-resume",
tags: ["AI"],
operationId: "analyzeResume",
summary: "Analyze resume and persist latest analysis",
description:
"Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
successDescription: "Structured resume analysis returned and persisted successfully.",
})
.input(
z.object({
aiProviderId: z.string().optional(),
resumeId: z.string(),
}),
)
.use(aiRequestRateLimit)
.output(storedResumeAnalysisSchema)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ context, input }) => {
try {
const [provider, resume] = await Promise.all([
getRunnableProvider(context.user.id, input.aiProviderId),
resumeService.getById({ id: input.resumeId, userId: context.user.id }),
]);
const analysis = await aiService.analyzeResume({
provider: provider.provider,
model: provider.model,
apiKey: provider.apiKey,
baseURL: provider.baseURL ?? "",
resumeData: resume.data,
});
return await resumeService.analysis.upsert({
id: input.resumeId,
userId: context.user.id,
analysis: {
...analysis,
updatedAt: new Date(),
modelMeta: { provider: provider.provider, model: provider.model },
},
});
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume analysis structure",
cause: flattenError(error),
});
}
throw error;
}
}),
};
@@ -0,0 +1,85 @@
import type { UIMessage } from "ai";
import { describe, expect, it } from "vitest";
import { convertToModelMessages, modelMessageSchema } from "ai";
describe("AI chat service", () => {
it("keeps proposal tool history valid for follow-up chat messages", async () => {
const messages: UIMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Add draft references." }],
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-propose_resume_patches",
toolCallId: "call-1",
state: "output-available",
input: {
proposals: [
{
title: "Add draft references",
operations: [
{
op: "replace",
path: "/sections/references/items",
value: [
{ id: "reference-1", name: "Jane Mitchell" },
{ id: "reference-2", name: "Marcus Chen" },
{ id: "reference-3", name: "Olivia Ramirez" },
],
},
],
},
],
},
output: {
proposals: [
{
id: "proposal-1",
title: "Add draft references",
baseUpdatedAt: "2026-05-10T06:38:27.093Z",
operations: [
{
op: "replace",
path: "/sections/references/items",
value: [
{ id: "reference-1", name: "Jane Mitchell" },
{ id: "reference-2", name: "Marcus Chen" },
{ id: "reference-3", name: "Olivia Ramirez" },
],
},
],
},
],
},
},
],
},
{
id: "assistant-2",
role: "assistant",
parts: [{ type: "text", text: "I prepared draft reference changes for review." }],
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Reduce it down to the first two." }],
},
];
const modelMessages = await convertToModelMessages(messages);
expect(modelMessages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "assistant", "user"]);
expect(JSON.stringify(modelMessages)).toContain("proposal-1");
expect(JSON.stringify(modelMessages)).toContain("/sections/references/items");
expect(JSON.stringify(modelMessages)).toContain("tool-result");
for (const message of modelMessages) {
expect(modelMessageSchema.safeParse(message).success).toBe(true);
}
});
});
+281
View File
@@ -0,0 +1,281 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ModelMessage, UIMessage } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamToEventIterator } from "@orpc/server";
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
import { createOllama } from "ollama-ai-provider-v2";
import { match } from "ts-pattern";
import { z } from "zod";
import {
analyzeResumeSystemPrompt as analyzeResumeSystemPromptTemplate,
chatSystemPromptTemplate,
docxParserSystemPrompt,
docxParserUserPrompt,
pdfParserSystemPrompt,
pdfParserUserPrompt,
} from "@reactive-resume/ai/prompts";
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
import {
normalizeResumePatchProposals,
resumePatchProposalToolInputSchema,
resumePatchProposalToolOutputSchema,
} from "@reactive-resume/ai/tools/patch-proposal";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { applyResumePatches } from "@reactive-resume/resume/patch";
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { supportsProviderNativeWebSearch } from "./capabilities";
import { resolveAiBaseUrl } from "./url-policy";
const aiExtractionTemplate = buildAiExtractionTemplate();
function logAndRethrow(context: string, error: unknown): never {
if (error instanceof Error) {
console.error(`${context}:`, error);
throw error;
}
console.error(`${context}:`, error);
throw new Error(`An unknown error occurred during ${context}.`);
}
function parseAndValidateResumeJson(resultText: string): ResumeData {
const { data, diagnostics } = sanitizeAndParseResumeJson(resultText);
if (diagnostics.coercions.length === 0 && diagnostics.droppedSectionItems.length === 0) return data;
const droppedBySection = diagnostics.droppedSectionItems.reduce<Record<string, number>>((acc, item) => {
acc[item.section] = (acc[item.section] ?? 0) + 1;
return acc;
}, {});
console.info("AI resume sanitization diagnostics", {
coercions: diagnostics.coercions.length,
droppedBySection,
salvageApplied: diagnostics.salvageApplied,
});
return data;
}
type GetModelInput = {
provider: AIProvider;
model: string;
apiKey: string;
baseURL?: string;
};
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
export function getModel(input: GetModelInput) {
const { provider, model, apiKey } = input;
const baseURL = resolveAiBaseUrl(input);
return match(provider)
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
.with("anthropic", () => createAnthropic({ apiKey, baseURL }).languageModel(model))
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
.with("openai-compatible", () =>
createOpenAICompatible({ name: "openai-compatible", apiKey, baseURL }).languageModel(model),
)
.with("ollama", () => {
const ollama = createOllama({
name: "ollama",
baseURL,
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
});
return ollama.languageModel(model);
})
.exhaustive();
}
export function getAgentModel(input: GetModelInput) {
if (!supportsProviderNativeWebSearch(input)) return getModel(input);
return createOpenAI({ apiKey: input.apiKey, baseURL: resolveAiBaseUrl(input) }).responses(input.model);
}
const aiCredentialsSchema = z.object({
provider: aiProviderSchema,
model: z.string().trim().min(1),
apiKey: z.string().trim().min(1),
baseURL: z.string().optional().default(""),
});
export const fileInputSchema = z.object({
name: z.string(),
data: z.string().max(MAX_AI_FILE_BASE64_CHARS, "File is too large. Maximum size is 10MB."),
});
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
export async function testConnection(input: TestConnectionInput): Promise<boolean> {
const RESPONSE_OK = "1";
const result = await generateText({
model: getModel(input),
output: Output.choice({ options: [RESPONSE_OK] }),
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
});
return result.output === RESPONSE_OK;
}
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
};
type BuildResumeParsingMessagesInput = {
systemPrompt: string;
userPrompt: string;
file: z.infer<typeof fileInputSchema>;
mediaType: string;
};
function buildResumeParsingMessages({
systemPrompt,
userPrompt,
file,
mediaType,
}: BuildResumeParsingMessagesInput): ModelMessage[] {
return [
{
role: "system",
content: `${systemPrompt}\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n${JSON.stringify(aiExtractionTemplate, null, 2)}`,
},
{
role: "user",
content: [
{ type: "text", text: userPrompt },
{ type: "file", data: file.data, mediaType, filename: file.name },
],
},
];
}
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: pdfParserSystemPrompt,
userPrompt: pdfParserUserPrompt,
file: input.file,
mediaType: "application/pdf",
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
}
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
};
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: docxParserSystemPrompt,
userPrompt: docxParserUserPrompt,
file: input.file,
mediaType: input.mediaType,
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
}
function buildChatSystemPrompt(resumeData: ResumeData): string {
return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2));
}
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
messages: UIMessage[];
resumeData: ResumeData;
resumeUpdatedAt: Date;
};
async function chat(input: ChatInput) {
const model = getModel(input);
const systemPrompt = buildChatSystemPrompt(input.resumeData);
const result = streamText({
model,
system: systemPrompt,
messages: await convertToModelMessages(input.messages),
tools: {
propose_resume_patches: tool({
description:
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
inputSchema: resumePatchProposalToolInputSchema,
outputSchema: resumePatchProposalToolOutputSchema,
execute: async (toolInput) => {
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
for (const proposal of proposals) {
applyResumePatches(input.resumeData, proposal.operations);
}
return { proposals };
},
}),
},
stopWhen: stepCountIs(3),
});
return streamToEventIterator(result.toUIMessageStream());
}
type AnalyzeResumeInput = z.infer<typeof aiCredentialsSchema> & {
resumeData: ResumeData;
};
function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
return `${analyzeResumeSystemPromptTemplate}\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
}
async function analyzeResume(input: AnalyzeResumeInput): Promise<ResumeAnalysis> {
const model = getModel(input);
const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
const result = await generateText({
model,
output: Output.object({ schema: resumeAnalysisOutputSchema }),
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content:
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.",
},
],
});
if (result.output == null) {
throw new Error("AI returned no structured analysis output.");
}
return resumeAnalysisSchema.parse(result.output);
}
export const aiService = {
analyzeResume,
chat,
parseDocx,
parsePdf,
testConnection,
};
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { resolveAiBaseUrl } = await import("./url-policy");
describe("AI provider base URL policy", () => {
it("allows public HTTPS provider URLs", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(resolveAiBaseUrl({ provider: "openai", baseURL: "https://api.openai.com/v1" })).toBe(
"https://api.openai.com/v1",
);
});
it("blocks private and non-HTTPS provider URLs by default", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://localhost:11434/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
it("allows private and non-HTTPS provider URLs when explicitly enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://localhost:11434/v1" })).toBe(
"http://localhost:11434/v1",
);
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://10.0.0.5/v1" })).toBe(
"https://10.0.0.5/v1",
);
});
it("rejects non-HTTP schemes even when unsafe provider URLs are enabled", () => {
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "file:///etc/passwd" })).toThrow(
"INVALID_AI_BASE_URL",
);
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "ftp://example.com/v1" })).toThrow(
"INVALID_AI_BASE_URL",
);
});
});
@@ -0,0 +1,31 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
import { env } from "@reactive-resume/env/server";
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node";
type ResolveAiBaseUrlInput = {
provider: AIProvider;
baseURL?: string | null;
};
function assertSafeUrl(input: string, errorCode: string, options?: { allowUnsafe?: boolean }) {
const parsed = parseUrl(input);
if (!parsed) throw new Error(errorCode);
if (parsed.username || parsed.password) throw new Error(errorCode);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(errorCode);
if (!options?.allowUnsafe) {
if (parsed.protocol !== "https:") throw new Error(errorCode);
if (isPrivateOrLoopbackHost(parsed.hostname)) throw new Error(errorCode);
}
parsed.hash = "";
return parsed.toString();
}
export function resolveAiBaseUrl(input: ResolveAiBaseUrlInput) {
const baseURL = input.baseURL?.trim() || AI_PROVIDER_DEFAULT_BASE_URLS[input.provider];
if (!baseURL) throw new Error("INVALID_AI_BASE_URL");
return assertSafeUrl(baseURL, "INVALID_AI_BASE_URL", { allowUnsafe: env.FLAG_ALLOW_UNSAFE_AI_BASE_URL });
}
+37
View File
@@ -0,0 +1,37 @@
import type { ProviderList } from "./service";
import { protectedProcedure, publicProcedure } from "../../context";
import { authService } from "./service";
export const authRouter = {
providers: {
list: publicProcedure
.route({
method: "GET",
path: "/auth/providers",
tags: ["Authentication"],
operationId: "listAuthProviders",
summary: "List authentication providers",
description:
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, LinkedIn, and custom OAuth. No authentication required.",
successDescription: "A map of enabled authentication provider identifiers to their display names.",
})
.handler((): ProviderList => {
return authService.providers.list();
}),
},
deleteAccount: protectedProcedure
.route({
method: "DELETE",
path: "/auth/account",
tags: ["Authentication"],
operationId: "deleteAccount",
summary: "Delete user account",
description:
"Permanently deletes the authenticated user's account, including all resumes, uploaded files (profile pictures, screenshots, PDFs), and associated data. This action is irreversible. Requires authentication.",
successDescription: "The user account and all associated data have been successfully deleted.",
})
.handler(async ({ context }): Promise<void> => {
return await authService.deleteAccount({ userId: context.user.id });
}),
};
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
GOOGLE_CLIENT_ID: undefined as string | undefined,
GOOGLE_CLIENT_SECRET: undefined as string | undefined,
GITHUB_CLIENT_ID: undefined as string | undefined,
GITHUB_CLIENT_SECRET: undefined as string | undefined,
LINKEDIN_CLIENT_ID: undefined as string | undefined,
LINKEDIN_CLIENT_SECRET: undefined as string | undefined,
OAUTH_CLIENT_ID: undefined as string | undefined,
OAUTH_CLIENT_SECRET: undefined as string | undefined,
OAUTH_PROVIDER_NAME: undefined as string | undefined,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
// auth.ts also imports db client and storage; stub them with no-op surfaces.
vi.mock("@reactive-resume/db/client", () => ({ db: { delete: vi.fn() } }));
vi.mock("@reactive-resume/db/schema", () => ({ user: {} }));
vi.mock("../storage/service", () => ({ getStorageService: () => ({ delete: vi.fn() }) }));
const { authService } = await import("./service");
const resetEnv = () => {
envMock.GOOGLE_CLIENT_ID = undefined;
envMock.GOOGLE_CLIENT_SECRET = undefined;
envMock.GITHUB_CLIENT_ID = undefined;
envMock.GITHUB_CLIENT_SECRET = undefined;
envMock.LINKEDIN_CLIENT_ID = undefined;
envMock.LINKEDIN_CLIENT_SECRET = undefined;
envMock.OAUTH_CLIENT_ID = undefined;
envMock.OAUTH_CLIENT_SECRET = undefined;
envMock.OAUTH_PROVIDER_NAME = undefined;
};
describe("authService.providers.list", () => {
it("always includes credential and passkey providers", () => {
resetEnv();
const providers = authService.providers.list();
expect(providers.credential).toBe("Password");
expect(providers.passkey).toBe("Passkey");
});
it("omits social providers when credentials are not configured", () => {
resetEnv();
const providers = authService.providers.list();
expect(providers.google).toBeUndefined();
expect(providers.github).toBeUndefined();
expect(providers.linkedin).toBeUndefined();
expect(providers.custom).toBeUndefined();
});
it("includes Google when both client id and secret are present", () => {
resetEnv();
envMock.GOOGLE_CLIENT_ID = "id";
envMock.GOOGLE_CLIENT_SECRET = "secret";
const providers = authService.providers.list();
expect(providers.google).toBe("Google");
});
it("does NOT include Google when only one of id/secret is set", () => {
resetEnv();
envMock.GOOGLE_CLIENT_ID = "id";
const providers = authService.providers.list();
expect(providers.google).toBeUndefined();
});
it("includes GitHub when both client id and secret are present", () => {
resetEnv();
envMock.GITHUB_CLIENT_ID = "id";
envMock.GITHUB_CLIENT_SECRET = "secret";
expect(authService.providers.list().github).toBe("GitHub");
});
it("includes LinkedIn when both client id and secret are present", () => {
resetEnv();
envMock.LINKEDIN_CLIENT_ID = "id";
envMock.LINKEDIN_CLIENT_SECRET = "secret";
expect(authService.providers.list().linkedin).toBe("LinkedIn");
});
it("labels the custom OAuth provider with OAUTH_PROVIDER_NAME when set", () => {
resetEnv();
envMock.OAUTH_CLIENT_ID = "id";
envMock.OAUTH_CLIENT_SECRET = "secret";
envMock.OAUTH_PROVIDER_NAME = "Acme SSO";
expect(authService.providers.list().custom).toBe("Acme SSO");
});
it("falls back to 'Custom OAuth' when OAUTH_PROVIDER_NAME is not set", () => {
resetEnv();
envMock.OAUTH_CLIENT_ID = "id";
envMock.OAUTH_CLIENT_SECRET = "secret";
expect(authService.providers.list().custom).toBe("Custom OAuth");
});
it("can register multiple social providers at once", () => {
resetEnv();
envMock.GOOGLE_CLIENT_ID = "g";
envMock.GOOGLE_CLIENT_SECRET = "g";
envMock.GITHUB_CLIENT_ID = "h";
envMock.GITHUB_CLIENT_SECRET = "h";
const providers = authService.providers.list();
expect(providers.google).toBe("Google");
expect(providers.github).toBe("GitHub");
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import { ORPCError } from "@orpc/client";
import { eq } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { getStorageService } from "../storage/service";
export type ProviderList = Partial<Record<AuthProvider, string>>;
const providers = {
list: (): ProviderList => {
const providers: ProviderList = { credential: "Password", passkey: "Passkey" };
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) providers.google = "Google";
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) providers.github = "GitHub";
if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) providers.linkedin = "LinkedIn";
if (env.OAUTH_CLIENT_ID && env.OAUTH_CLIENT_SECRET) providers.custom = env.OAUTH_PROVIDER_NAME ?? "Custom OAuth";
return providers;
},
};
export const authService = {
providers,
deleteAccount: async (input: { userId: string }): Promise<void> => {
if (!input.userId || input.userId.length === 0) return;
const storageService = getStorageService();
// Delete all user files in one call (pictures, screenshots, pdfs)
// The storage service delete method supports recursive deletion via prefix
try {
await storageService.delete(`uploads/${input.userId}`);
} catch {
// Ignore error and proceed with deleting user
}
try {
await db.delete(schema.user).where(eq(schema.user.id, input.userId));
} catch (err) {
console.error("Failed to delete user record:", err);
throw new ORPCError("INTERNAL_SERVER_ERROR");
}
},
};
+1
View File
@@ -0,0 +1 @@
export type { FeatureFlags } from "./service";
+25
View File
@@ -0,0 +1,25 @@
import type { FeatureFlags } from "./service";
import z from "zod";
import { publicProcedure } from "../../context";
import { flagsService } from "./service";
export const flagsRouter = {
get: publicProcedure
.route({
method: "GET",
path: "/flags",
tags: ["Feature Flags"],
operationId: "getFeatureFlags",
summary: "Get feature flags",
description:
"Returns the current feature flags for this Reactive Resume instance. Feature flags control instance-wide settings such as whether new user signups or email-based authentication are disabled. No authentication required.",
successDescription: "The current feature flags for this instance.",
})
.output(
z.object({
disableSignups: z.boolean().describe("Whether new user signups are disabled on this instance."),
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
}),
)
.handler((): FeatureFlags => flagsService.getFlags()),
};
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
FLAG_DISABLE_SIGNUPS: false,
FLAG_DISABLE_EMAIL_AUTH: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { flagsService } = await import("./service");
describe("flagsService.getFlags", () => {
it("reads disableSignups + disableEmailAuth from env", () => {
envMock.FLAG_DISABLE_SIGNUPS = false;
envMock.FLAG_DISABLE_EMAIL_AUTH = false;
expect(flagsService.getFlags()).toEqual({
disableSignups: false,
disableEmailAuth: false,
});
});
it("returns disableSignups=true when env flag is set", () => {
envMock.FLAG_DISABLE_SIGNUPS = true;
envMock.FLAG_DISABLE_EMAIL_AUTH = false;
expect(flagsService.getFlags()).toEqual({
disableSignups: true,
disableEmailAuth: false,
});
});
it("returns disableEmailAuth=true when env flag is set", () => {
envMock.FLAG_DISABLE_SIGNUPS = false;
envMock.FLAG_DISABLE_EMAIL_AUTH = true;
expect(flagsService.getFlags()).toEqual({
disableSignups: false,
disableEmailAuth: true,
});
});
it("reads the latest env values on every call (no stale cache)", () => {
envMock.FLAG_DISABLE_SIGNUPS = false;
const before = flagsService.getFlags();
envMock.FLAG_DISABLE_SIGNUPS = true;
const after = flagsService.getFlags();
expect(before.disableSignups).toBe(false);
expect(after.disableSignups).toBe(true);
});
});
@@ -0,0 +1,13 @@
import { env } from "@reactive-resume/env/server";
export type FeatureFlags = {
disableSignups: boolean;
disableEmailAuth: boolean;
};
export const flagsService = {
getFlags: (): FeatureFlags => ({
disableSignups: env.FLAG_DISABLE_SIGNUPS,
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
}),
};
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy";
describe("isOwner", () => {
it("returns true when viewer.id matches resume.userId", () => {
expect(isOwner({ userId: "u1", isPublic: false }, { id: "u1" })).toBe(true);
});
it("returns false when viewer.id differs", () => {
expect(isOwner({ userId: "u1", isPublic: false }, { id: "u2" })).toBe(false);
});
it("returns false when viewer is null (anonymous)", () => {
expect(isOwner({ userId: "u1", isPublic: false }, null)).toBe(false);
});
});
describe("assertCanView", () => {
it("does not throw when viewer is owner of private resume", () => {
expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u1" })).not.toThrow();
});
it("does not throw when resume is public regardless of viewer", () => {
expect(() => assertCanView({ userId: "u1", isPublic: true }, { id: "u2" })).not.toThrow();
expect(() => assertCanView({ userId: "u1", isPublic: true }, null)).not.toThrow();
});
it("throws NOT_FOUND for private resume viewed by non-owner", () => {
expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u2" })).toThrow();
});
it("throws NOT_FOUND for private resume viewed anonymously", () => {
expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow();
});
it("error code is NOT_FOUND (not FORBIDDEN) to prevent existence disclosure", () => {
try {
assertCanView({ userId: "u1", isPublic: false }, null);
expect.unreachable();
} catch (error: unknown) {
expect((error as { code?: string }).code).toBe("NOT_FOUND");
}
});
});
describe("redactResumeForViewer", () => {
it("returns the resume unchanged for owner", () => {
const resume = {
name: "My Dashboard Title",
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private" } },
};
expect(redactResumeForViewer(resume, true)).toBe(resume);
});
it("strips name to empty for non-owner", () => {
const resume = {
name: "Senior Eng @ Foo — final draft",
data: defaultResumeData,
};
const result = redactResumeForViewer(resume, false);
expect(result.name).toBe("");
});
it("strips metadata.notes for non-owner", () => {
const resume = {
name: "Title",
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private notes" } },
};
const result = redactResumeForViewer(resume, false);
expect(result.data.metadata.notes).toBe("");
});
it("preserves resume.data.basics.name (the person's name) for non-owner", () => {
const resume = {
name: "Dashboard title",
data: {
...defaultResumeData,
basics: { ...defaultResumeData.basics, name: "Alice Smith" },
},
};
const result = redactResumeForViewer(resume, false);
expect(result.data.basics.name).toBe("Alice Smith");
});
it("does not mutate the input", () => {
const resume = {
name: "Title",
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Notes" } },
};
const before = JSON.stringify(resume);
redactResumeForViewer(resume, false);
expect(JSON.stringify(resume)).toBe(before);
});
it("preserves additional resume fields", () => {
const resume = {
name: "Title",
data: defaultResumeData,
extraField: "extra",
};
const result = redactResumeForViewer(resume, false);
expect((result as typeof resume).extraField).toBe("extra");
});
});
describe("shouldCountForStatistics", () => {
it("returns false when viewer is the owner", () => {
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u1" })).toBe(false);
});
it("returns true for anonymous viewers", () => {
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, null)).toBe(true);
});
it("returns true for non-owner viewers", () => {
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u2" })).toBe(true);
});
});
@@ -0,0 +1,78 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/client";
/**
* Single source of truth for "who can view/edit a resume" and "what fields
* leak to non-owners on the public view path."
*
* Owner-only mutation methods (update / patch / delete / setPassword / …)
* intentionally do **not** call this module — their `WHERE userId = :owner`
* clauses already enforce ownership at the query level. The policy here
* documents that contract and gates the dual-role read path (`getBySlug`)
* where a non-owner viewer can legitimately read a public resume.
*/
type Resume = {
userId: string;
isPublic: boolean;
};
type Viewer = { id: string } | null;
export function isOwner(resume: Resume, viewer: Viewer): boolean {
return viewer !== null && viewer.id === resume.userId;
}
/**
* Throws `NOT_FOUND` (not `FORBIDDEN`) when the viewer is not allowed to see
* the resume — same response as a nonexistent resume so the API does not
* disclose existence of private resumes by id/slug.
*/
export function assertCanView(resume: Resume, viewer: Viewer): void {
if (isOwner(resume, viewer)) return;
if (resume.isPublic) return;
throw new ORPCError("NOT_FOUND");
}
/**
* Redact owner-only fields before serializing a resume to a non-owner viewer.
*
* Stripped on public view:
* - `resume.name` — the dashboard title chosen by the owner (often
* contains personal context like "Senior Eng @ Foo — final draft").
* - `resume.data.metadata.notes` — explicitly documented as "only visible
* to the author when editing" in the resume schema.
*
* Everything else (including `data.basics.name`, the person's name on the
* resume itself) is part of the public payload and is returned unchanged.
*
* Owner views pass through untouched.
*/
export function redactResumeForViewer<T extends { name: string; data: ResumeData }>(
resume: T,
viewerIsOwner: boolean,
): T {
if (viewerIsOwner) return resume;
return {
...resume,
name: "",
data: {
...resume.data,
metadata: {
...resume.data.metadata,
notes: "",
},
},
};
}
/**
* Owner self-views/downloads do not count toward the public statistics —
* the dashboard would otherwise inflate metrics every time the author
* previewed their own resume. Call sites that increment `views` /
* `downloads` should gate on this helper.
*/
export function shouldCountForStatistics(resume: Resume, viewer: Viewer): boolean {
return !isOwner(resume, viewer);
}
@@ -0,0 +1,70 @@
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({ APP_URL: "https://example.com" }));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
const { hasResumeAccess, grantResumeAccess } = await import("./access");
const signToken = (resumeId: string, passwordHash: string) =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
const requestHeadersWithCookie = (name: string, value: string) =>
new Headers({ Cookie: `other=value; ${name}=${value}; theme=dark` });
describe("hasResumeAccess", () => {
it("returns false when no passwordHash is supplied", () => {
expect(hasResumeAccess(new Headers(), "resume-1", null)).toBe(false);
});
it("returns false when no cookie is present", () => {
expect(hasResumeAccess(new Headers(), "resume-1", "hash")).toBe(false);
});
it("returns true for a cookie value that matches the expected signed token", () => {
const token = signToken("resume-1", "hash");
const headers = requestHeadersWithCookie("resume_access_resume-1", token);
expect(hasResumeAccess(headers, "resume-1", "hash")).toBe(true);
});
it("returns false for a cookie value that does not match the expected signed token", () => {
const headers = requestHeadersWithCookie("resume_access_resume-1", "not-the-right-token");
expect(hasResumeAccess(headers, "resume-1", "hash")).toBe(false);
});
it("returns false when the cookie has a different length than the expected token", () => {
const headers = requestHeadersWithCookie("resume_access_resume-1", "short");
expect(hasResumeAccess(headers, "resume-1", "hash")).toBe(false);
});
});
describe("grantResumeAccess", () => {
it("appends a signed Set-Cookie header scoped to the resume id with httpOnly + sameSite=lax + 10-minute TTL", () => {
const responseHeaders = new Headers();
grantResumeAccess(responseHeaders, "resume-42", "hash");
const cookie = responseHeaders.get("Set-Cookie");
expect(cookie).toContain(`resume_access_resume-42=${signToken("resume-42", "hash")}`);
expect(cookie).toContain("Path=/");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
expect(cookie).toContain("Max-Age=600");
});
it("only marks the cookie secure when APP_URL is https", () => {
envMock.APP_URL = "http://localhost:3000";
const localHeaders = new Headers();
grantResumeAccess(localHeaders, "r", "h");
expect(localHeaders.get("Set-Cookie")).not.toContain("Secure");
envMock.APP_URL = "https://example.com";
const productionHeaders = new Headers();
grantResumeAccess(productionHeaders, "r", "h");
expect(productionHeaders.get("Set-Cookie")).toContain("Secure");
});
});
@@ -0,0 +1,63 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { env } from "@reactive-resume/env/server";
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
const RESUME_ACCESS_TTL_SECONDS = 60 * 10; // 10 minutes
const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_PREFIX}_${resumeId}`;
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
const safeEquals = (value: string, expected: string) => {
const valueBuffer = Buffer.from(value);
const expectedBuffer = Buffer.from(expected);
if (valueBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(valueBuffer, expectedBuffer);
};
const parseCookieHeader = (cookieHeader: string | null): Map<string, string> => {
const cookies = new Map<string, string>();
if (!cookieHeader) return cookies;
for (const part of cookieHeader.split(";")) {
const [rawName, ...rawValue] = part.trim().split("=");
if (!rawName || rawValue.length === 0) continue;
cookies.set(rawName, rawValue.join("="));
}
return cookies;
};
const serializeCookie = (
name: string,
value: string,
options: { path: string; httpOnly: boolean; sameSite: "lax"; maxAge: number; secure: boolean },
) => {
const parts = [`${name}=${value}`, `Path=${options.path}`, `Max-Age=${options.maxAge}`, "SameSite=Lax"];
if (options.httpOnly) parts.push("HttpOnly");
if (options.secure) parts.push("Secure");
return parts.join("; ");
};
export const hasResumeAccess = (requestHeaders: Headers, resumeId: string, passwordHash: string | null) => {
if (!passwordHash) return false;
const cookieName = getResumeAccessCookieName(resumeId);
const cookieValue = parseCookieHeader(requestHeaders.get("cookie")).get(cookieName);
if (!cookieValue) return false;
const expected = signResumeAccessToken(resumeId, passwordHash);
return safeEquals(cookieValue, expected);
};
export const grantResumeAccess = (responseHeaders: Headers, resumeId: string, passwordHash: string) => {
const cookie = serializeCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: RESUME_ACCESS_TTL_SECONDS,
secure: env.APP_URL.startsWith("https"),
});
responseHeaders.append("Set-Cookie", cookie);
};
@@ -0,0 +1,23 @@
import z from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { protectedProcedure } from "../../context";
import { resumeService } from "./service";
export const analysisRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/analysis",
tags: ["Resume Analysis"],
operationId: "getResumeAnalysis",
summary: "Get latest resume analysis",
description:
"Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
successDescription: "The latest persisted resume analysis, or null if no analysis has been saved yet.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(storedResumeAnalysisSchema.nullable())
.handler(async ({ context, input }) => {
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
}),
};
+243
View File
@@ -0,0 +1,243 @@
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const crudRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resumes",
tags: ["Resumes"],
operationId: "listResumes",
summary: "List all resumes",
description:
"Returns a list of all resumes belonging to the authenticated user. Results can be filtered by tags and sorted by last updated date, creation date, or name. Resume data is not included in the response for performance; use the get endpoint to fetch full resume data. Requires authentication.",
successDescription: "A list of resumes with their metadata (without full resume data).",
})
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
.output(resumeDto.list.output)
.handler(async ({ input, context }) => {
return resumeService.list({
userId: context.user.id,
tags: input.tags,
sort: input.sort,
});
}),
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "getResume",
summary: "Get resume by ID",
description:
"Returns a single resume with its full data, identified by its unique ID. Only resumes belonging to the authenticated user can be retrieved. Requires authentication.",
successDescription: "The resume with its full data.",
})
.input(resumeDto.getById.input)
.output(resumeDto.getById.output)
.handler(async ({ context, input }) => {
return resumeService.getById({ id: input.id, userId: context.user.id });
}),
create: protectedProcedure
.route({
method: "POST",
path: "/resumes",
tags: ["Resumes"],
operationId: "createResume",
summary: "Create a new resume",
description:
"Creates a new resume with the given name, slug, and tags. Optionally initializes the resume with sample data by setting withSampleData to true. The slug must be unique across the user's resumes. Returns the ID of the newly created resume. Requires authentication.",
successDescription: "The ID of the newly created resume.",
})
.input(resumeDto.create.input)
.use(resumeMutationRateLimit)
.output(resumeDto.create.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.create({
name: input.name,
slug: input.slug,
tags: input.tags,
locale: context.locale,
userId: context.user.id,
...(input.withSampleData ? { data: sampleResumeData } : {}),
});
}),
import: protectedProcedure
.route({
method: "POST",
path: "/resumes/import",
tags: ["Resumes"],
operationId: "importResume",
summary: "Import a resume",
description:
"Creates a new resume from an existing ResumeData object (e.g. from a previously exported JSON file). A random name and slug are generated automatically. Returns the ID of the imported resume. Requires authentication.",
successDescription: "The ID of the imported resume.",
})
.input(resumeDto.import.input)
.use(resumeMutationRateLimit)
.output(resumeDto.import.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
const name = generateRandomName();
const slug = slugify(name);
return resumeService.create({
name,
slug,
tags: [],
data: input.data,
locale: context.locale,
userId: context.user.id,
});
}),
update: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "updateResume",
summary: "Update a resume",
description:
"Updates one or more fields of a resume identified by its ID. All fields are optional; only provided fields will be updated. Locked resumes cannot be updated. Requires authentication.",
successDescription: "The updated resume with its full data.",
})
.input(resumeDto.update.input)
.use(resumeMutationRateLimit)
.output(resumeDto.update.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.update({
id: input.id,
userId: context.user.id,
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
});
}),
patch: protectedProcedure
.route({
method: "PATCH",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "patchResume",
summary: "Patch resume data",
description:
"Applies JSON Patch (RFC 6902) operations to partially update a resume's data. This allows small, targeted changes (e.g. updating a single field) without sending the entire resume object. Locked resumes cannot be patched. Requires authentication.",
successDescription: "The patched resume with its full data.",
})
.input(resumeDto.patch.input)
.use(resumeMutationRateLimit)
.output(resumeDto.patch.output)
.errors({
INVALID_PATCH_OPERATIONS: {
message: "The patch operations are invalid or produced an invalid resume.",
status: 400,
},
RESUME_VERSION_CONFLICT: {
message: "The resume changed after this patch was generated.",
status: 409,
},
})
.handler(async ({ context, input }) => {
return resumeService.patch({
id: input.id,
userId: context.user.id,
operations: input.operations,
...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}),
});
}),
setLocked: protectedProcedure
.route({
method: "POST",
path: "/resumes/{id}/lock",
tags: ["Resumes"],
operationId: "setResumeLocked",
summary: "Set resume lock status",
description:
"Toggles the locked status of a resume. When locked, a resume cannot be updated, patched, or deleted. Useful for protecting finalized resumes from accidental edits. Requires authentication.",
successDescription: "The resume lock status was updated successfully.",
})
.input(resumeDto.setLocked.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setLocked.output)
.handler(async ({ context, input }) => {
return resumeService.setLocked({
id: input.id,
userId: context.user.id,
isLocked: input.isLocked,
});
}),
duplicate: protectedProcedure
.route({
method: "POST",
path: "/resumes/{id}/duplicate",
tags: ["Resumes"],
operationId: "duplicateResume",
summary: "Duplicate a resume",
description:
"Creates a copy of an existing resume with the same data. Optionally override the name, slug, and tags for the duplicate. If not provided, the original resume's name, slug, and tags are used. Returns the ID of the duplicated resume. Requires authentication.",
successDescription: "The ID of the duplicated resume.",
})
.input(resumeDto.duplicate.input)
.use(resumeMutationRateLimit)
.output(resumeDto.duplicate.output)
.handler(async ({ context, input }) => {
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
return resumeService.create({
userId: context.user.id,
name: input.name ?? original.name,
slug: input.slug ?? original.slug,
tags: input.tags ?? original.tags,
locale: context.locale,
data: original.data,
});
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "deleteResume",
summary: "Delete a resume",
description:
"Permanently deletes a resume and its associated files (screenshots, PDFs) from storage. Locked resumes cannot be deleted; unlock the resume first. Requires authentication.",
successDescription: "The resume and its associated files were deleted successfully.",
})
.input(resumeDto.delete.input)
.use(resumeMutationRateLimit)
.output(resumeDto.delete.output)
.handler(async ({ context, input }) => {
return resumeService.delete({ id: input.id, userId: context.user.id });
}),
};
@@ -0,0 +1,36 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { subscribeResumeUpdated } from "./events";
import { resumeService } from "./service";
export const updatesRouter = {
subscribe: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/updates",
tags: ["Resumes"],
operationId: "subscribeResumeUpdates",
summary: "Subscribe to resume updates",
description:
"Streams lightweight invalidation events when the specified resume changes. The event payload contains metadata only; clients should refetch the resume for canonical data.",
successDescription: "A stream of resume update invalidation events.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.handler(async function* ({ context, input, signal }) {
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
yield {
type: "resume.updated" as const,
resumeId: input.id,
userId: context.user.id,
updatedAt: resume.updatedAt.toISOString(),
mutation: "sync" as const,
};
yield* subscribeResumeUpdated({
resumeId: input.id,
userId: context.user.id,
...(signal ? { signal } : {}),
});
}),
};
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from "vitest";
const pool = vi.hoisted(() => ({
query: vi.fn().mockResolvedValue(undefined),
connect: vi.fn(),
}));
vi.mock("@reactive-resume/db/client", () => ({ getPool: () => pool }));
const { publishResumeUpdated, subscribeResumeUpdated } = await import("./events");
const exampleEvent = {
type: "resume.updated" as const,
resumeId: "r1",
userId: "u1",
updatedAt: "2024-01-01T00:00:00Z",
mutation: "patch" as const,
};
describe("publishResumeUpdated", () => {
it("issues a pg_notify with the channel and serialized event", async () => {
pool.query.mockClear();
await publishResumeUpdated(exampleEvent);
expect(pool.query).toHaveBeenCalledTimes(1);
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the query call exists before destructuring it.
const [sql, params] = pool.query.mock.calls[0]!;
expect(sql).toBe("SELECT pg_notify($1, $2)");
expect(params?.[0]).toBe("resume_updated");
expect(JSON.parse(params?.[1] as string)).toEqual(exampleEvent);
});
});
const makeFakeClient = () => {
type Listener = (notification: { channel?: string; payload?: string }) => void;
const listeners = new Set<Listener>();
const client = {
query: vi.fn().mockResolvedValue(undefined),
on: vi.fn((event: string, fn: Listener) => {
if (event === "notification") listeners.add(fn);
}),
off: vi.fn((event: string, fn: Listener) => {
if (event === "notification") listeners.delete(fn);
}),
release: vi.fn(),
__notify(channel: string, payload: string) {
for (const fn of listeners) fn({ channel, payload });
},
};
return client;
};
describe("subscribeResumeUpdated", () => {
it("yields events whose resumeId and userId match the subscription", async () => {
const client = makeFakeClient();
pool.connect.mockResolvedValueOnce(client);
const controller = new AbortController();
const iterator = subscribeResumeUpdated({
resumeId: "r1",
userId: "u1",
signal: controller.signal,
});
// Kick the generator so listeners are installed, then push a notification.
const firstP = iterator.next();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
client.__notify("resume_updated", JSON.stringify(exampleEvent));
const first = await firstP;
expect(first.done).toBe(false);
expect(first.value).toEqual(exampleEvent);
controller.abort();
const last = await iterator.next();
expect(last.done).toBe(true);
expect(client.query).toHaveBeenCalledWith("LISTEN resume_updated");
expect(client.query).toHaveBeenCalledWith("UNLISTEN resume_updated");
expect(client.release).toHaveBeenCalled();
});
it("ignores notifications for other resumes / users", async () => {
const client = makeFakeClient();
pool.connect.mockResolvedValueOnce(client);
const controller = new AbortController();
const iterator = subscribeResumeUpdated({
resumeId: "r1",
userId: "u1",
signal: controller.signal,
});
const resultP = iterator.next();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, resumeId: "other" }));
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, userId: "other" }));
client.__notify("resume_updated", JSON.stringify(exampleEvent));
const result = await resultP;
expect(result.value?.resumeId).toBe("r1");
controller.abort();
await iterator.next();
});
it("ignores malformed notifications and notifications on other channels", async () => {
const client = makeFakeClient();
pool.connect.mockResolvedValueOnce(client);
const controller = new AbortController();
const iterator = subscribeResumeUpdated({
resumeId: "r1",
userId: "u1",
signal: controller.signal,
});
const resultP = iterator.next();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Wrong channel.
client.__notify("other_channel", JSON.stringify(exampleEvent));
// Malformed JSON.
client.__notify("resume_updated", "{not-json");
// Missing required fields.
client.__notify("resume_updated", JSON.stringify({ type: "wrong" }));
// Valid event after the noise.
client.__notify("resume_updated", JSON.stringify(exampleEvent));
const result = await resultP;
expect(result.value).toEqual(exampleEvent);
controller.abort();
await iterator.next();
});
it("terminates immediately if signal is already aborted", async () => {
const client = makeFakeClient();
pool.connect.mockResolvedValueOnce(client);
const controller = new AbortController();
controller.abort();
const iterator = subscribeResumeUpdated({
resumeId: "r1",
userId: "u1",
signal: controller.signal,
});
const result = await iterator.next();
expect(result.done).toBe(true);
});
});
@@ -0,0 +1,99 @@
import { getPool } from "@reactive-resume/db/client";
const RESUME_UPDATED_CHANNEL = "resume_updated";
type PgNotification = {
channel?: string | undefined;
payload?: string | undefined;
};
export type ResumeUpdatedEvent = {
type: "resume.updated";
resumeId: string;
userId: string;
updatedAt: string;
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
};
type SubscribeResumeUpdatedInput = {
resumeId: string;
userId: string;
signal?: AbortSignal;
};
function isResumeUpdatedEvent(value: unknown): value is ResumeUpdatedEvent {
if (!value || typeof value !== "object") return false;
const event = value as Partial<ResumeUpdatedEvent>;
return (
event.type === "resume.updated" &&
typeof event.resumeId === "string" &&
typeof event.userId === "string" &&
typeof event.updatedAt === "string" &&
typeof event.mutation === "string"
);
}
export async function publishResumeUpdated(event: ResumeUpdatedEvent) {
await getPool().query("SELECT pg_notify($1, $2)", [RESUME_UPDATED_CHANNEL, JSON.stringify(event)]);
}
export async function* subscribeResumeUpdated({ resumeId, userId, signal }: SubscribeResumeUpdatedInput) {
const client = await getPool().connect();
const queue: ResumeUpdatedEvent[] = [];
let done = signal?.aborted ?? false;
let wake: (() => void) | undefined;
const resolveWake = () => {
wake?.();
wake = undefined;
};
const onAbort = () => {
done = true;
resolveWake();
};
const onNotification = (notification: PgNotification) => {
if (notification.channel !== RESUME_UPDATED_CHANNEL || !notification.payload) return;
try {
const event = JSON.parse(notification.payload) as unknown;
if (!isResumeUpdatedEvent(event)) return;
if (event.resumeId !== resumeId || event.userId !== userId) return;
queue.push(event);
resolveWake();
} catch {
// Ignore malformed notifications; the refetch path is invalidation-only.
}
};
signal?.addEventListener("abort", onAbort, { once: true });
client.on("notification", onNotification);
try {
await client.query(`LISTEN ${RESUME_UPDATED_CHANNEL}`);
while (!done) {
const event = queue.shift();
if (event) {
yield event;
continue;
}
await new Promise<void>((resolve) => {
wake = resolve;
});
}
} finally {
signal?.removeEventListener("abort", onAbort);
client.off("notification", onNotification);
try {
await client.query(`UNLISTEN ${RESUME_UPDATED_CHANNEL}`);
} finally {
client.release();
}
}
}
@@ -0,0 +1,48 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { createResumePdfFile } from "@reactive-resume/pdf/server";
import { generateFilename } from "@reactive-resume/utils/file";
import { protectedProcedure } from "../../context";
import { pdfExportRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const downloadResumePdfProcedure = protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/pdf",
tags: ["Resumes"],
operationId: "downloadResumePdf",
summary: "Download resume as PDF",
description:
"Generates a PDF for the specified resume and returns it as a forced download. Only resumes belonging to the authenticated user can be downloaded. Requires authentication.",
successDescription: "The generated resume PDF.",
outputStructure: "detailed",
})
.input(z.object({ id: z.string().describe("The ID of the resume.") }))
.output(
z.object({
headers: z.object({
"content-disposition": z.string(),
}),
body: z.file().mime("application/pdf"),
}),
)
.use(pdfExportRateLimit)
.handler(async ({ context, input }) => {
const resume = await resumeService.getById({ id: input.id, userId: context.user.id });
const filename = generateFilename(resume.name, "pdf");
try {
const body = await createResumePdfFile({ data: resume.data, filename });
return {
headers: {
"content-disposition": `attachment; filename="${filename}"`,
},
body,
};
} catch (error) {
console.error("[PDF API] Failed to render resume PDF", { resumeId: input.id, error });
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate resume PDF" });
}
});
@@ -0,0 +1,2 @@
export { downloadResumePdfProcedure } from "./export";
export { resumeService } from "./service";
@@ -0,0 +1,27 @@
import { analysisRouter } from "./analysis";
import { crudRouter } from "./crud";
import { updatesRouter } from "./event-router";
import { sharingRouter } from "./sharing";
import { resumeStatisticsRouter } from "./statistics";
import { tagsRouter } from "./tags";
export const resumeRouter = {
tags: tagsRouter,
statistics: resumeStatisticsRouter,
analysis: analysisRouter,
updates: updatesRouter,
list: crudRouter.list,
getById: crudRouter.getById,
getBySlug: sharingRouter.getBySlug,
create: crudRouter.create,
import: crudRouter.import,
update: crudRouter.update,
patch: crudRouter.patch,
setLocked: crudRouter.setLocked,
setPassword: sharingRouter.setPassword,
verifyPassword: sharingRouter.verifyPassword,
removePassword: sharingRouter.removePassword,
duplicate: crudRouter.duplicate,
delete: crudRouter.delete,
};
+574
View File
@@ -0,0 +1,574 @@
import type { JsonPatchOperation } from "@reactive-resume/resume/patch";
import type { StoredResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import type { ResumeUpdatedEvent } from "./events";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { applyResumePatches, ResumePatchError } from "@reactive-resume/resume/patch";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { getStorageService } from "../storage/service";
import { grantResumeAccess, hasResumeAccess } from "./access";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy";
import { publishResumeUpdated } from "./events";
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
function resumeVersionConflict(updatedAt: Date) {
return new ORPCError("RESUME_VERSION_CONFLICT", {
status: 409,
message: "The resume changed after this patch was generated.",
data: { updatedAt: updatedAt.toISOString() },
});
}
async function applyResumePatchTx(
client: DbOrTx,
input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date },
) {
const [existing] = await client
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked, updatedAt: schema.resume.updatedAt })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.for("update");
if (!existing) throw new ORPCError("NOT_FOUND");
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
if (input.expectedUpdatedAt && existing.updatedAt.getTime() !== input.expectedUpdatedAt.getTime()) {
throw resumeVersionConflict(existing.updatedAt);
}
let patchedData: ResumeData;
try {
patchedData = applyResumePatches(existing.data, input.operations);
} catch (error) {
if (error instanceof ResumePatchError) {
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
status: 400,
message: error.message,
data: { code: error.code, index: error.index, operation: error.operation },
});
}
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
status: 400,
message: error instanceof Error ? error.message : "Failed to apply patch operations",
});
}
const [resume] = await client
.update(schema.resume)
.set({ data: patchedData })
.where(
and(
eq(schema.resume.id, input.id),
eq(schema.resume.isLocked, false),
eq(schema.resume.userId, input.userId),
...(input.expectedUpdatedAt ? [eq(schema.resume.updatedAt, input.expectedUpdatedAt)] : []),
),
)
.returning({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
updatedAt: schema.resume.updatedAt,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) {
if (input.expectedUpdatedAt) throw resumeVersionConflict(existing.updatedAt);
throw new ORPCError("NOT_FOUND");
}
return resume;
}
const tags = {
list: async (input: { userId: string }) => {
const result = await db
.select({ tags: schema.resume.tags })
.from(schema.resume)
.where(eq(schema.resume.userId, input.userId));
const uniqueTags = new Set(result.flatMap((tag) => tag.tags));
const sortedTags = Array.from(uniqueTags).sort((a, b) => a.localeCompare(b));
return sortedTags;
},
};
const statistics = {
getById: async (input: { id: string; userId: string }) => {
const [statistics] = await db
.select({
isPublic: schema.resume.isPublic,
views: schema.resumeStatistics.views,
downloads: schema.resumeStatistics.downloads,
lastViewedAt: schema.resumeStatistics.lastViewedAt,
lastDownloadedAt: schema.resumeStatistics.lastDownloadedAt,
})
.from(schema.resumeStatistics)
.rightJoin(schema.resume, eq(schema.resumeStatistics.resumeId, schema.resume.id))
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!statistics) throw new ORPCError("NOT_FOUND");
return {
isPublic: statistics.isPublic,
views: statistics.views ?? 0,
downloads: statistics.downloads ?? 0,
lastViewedAt: statistics.lastViewedAt,
lastDownloadedAt: statistics.lastDownloadedAt,
};
},
increment: async (input: { id: string; views?: boolean; downloads?: boolean }) => {
const views = input.views ? 1 : 0;
const downloads = input.downloads ? 1 : 0;
const lastViewedAt = input.views ? sql`now()` : undefined;
const lastDownloadedAt = input.downloads ? sql`now()` : undefined;
await db
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
lastViewedAt,
lastDownloadedAt,
},
});
},
};
const analysis = {
getById: async (input: { id: string; userId: string }) => {
const [result] = await db
.select({ analysis: schema.resumeAnalysis.analysis })
.from(schema.resume)
.leftJoin(schema.resumeAnalysis, eq(schema.resumeAnalysis.resumeId, schema.resume.id))
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!result) throw new ORPCError("NOT_FOUND");
return result.analysis ?? null;
},
upsert: async (input: { id: string; userId: string; analysis: StoredResumeAnalysis }) => {
const [resume] = await db
.select({ id: schema.resume.id })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
await db
.insert(schema.resumeAnalysis)
.values({
resumeId: input.id,
analysis: input.analysis,
})
.onConflictDoUpdate({
target: [schema.resumeAnalysis.resumeId],
set: {
analysis: input.analysis,
},
});
return input.analysis;
},
};
function toSharedResumeResponse(
resume: {
id: string;
name: string;
slug: string;
tags: string[];
data: ResumeData;
isPublic: boolean;
isLocked: boolean;
},
hasPassword: boolean,
) {
return {
id: resume.id,
name: resume.name,
slug: resume.slug,
tags: resume.tags,
data: resume.data,
isPublic: resume.isPublic,
isLocked: resume.isLocked,
hasPassword,
};
}
async function notifyResumeUpdated(event: ResumeUpdatedEvent) {
try {
await publishResumeUpdated(event);
} catch (error) {
console.warn("Failed to publish resume.updated event:", error);
}
}
export const resumeService = {
tags,
statistics,
analysis,
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
createdAt: schema.resume.createdAt,
updatedAt: schema.resume.updatedAt,
})
.from(schema.resume)
.where(
and(
eq(schema.resume.userId, input.userId),
match(input.tags.length)
.with(0, () => undefined)
.otherwise(() => arrayContains(schema.resume.tags, input.tags)),
),
)
.orderBy(
match(input.sort)
.with("lastUpdatedAt", () => desc(schema.resume.updatedAt))
.with("createdAt", () => asc(schema.resume.createdAt))
.with("name", () => asc(schema.resume.name))
.exhaustive(),
);
},
getById: async (input: { id: string; userId: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
updatedAt: schema.resume.updatedAt,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
},
getBySlug: async (input: { username: string; slug: string; requestHeaders: Headers; currentUserId?: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
userId: schema.resume.userId,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
passwordHash: schema.resume.password,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(and(eq(schema.resume.slug, input.slug), eq(schema.user.username, input.username)));
if (!resume) throw new ORPCError("NOT_FOUND");
const viewer = input.currentUserId ? { id: input.currentUserId } : null;
assertCanView(resume, viewer);
if (resume.hasPassword && !hasResumeAccess(input.requestHeaders, resume.id, resume.passwordHash)) {
throw new ORPCError("NEED_PASSWORD", {
status: 401,
data: { username: input.username, slug: input.slug },
});
}
if (shouldCountForStatistics(resume, viewer)) {
await resumeService.statistics.increment({ id: resume.id, views: true });
}
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
},
create: async (input: {
userId: string;
name: string;
slug: string;
tags: string[];
locale: Locale;
data?: ResumeData;
}) => {
const id = generateId();
const data = input.data ?? defaultResumeData;
data.metadata.page.locale = input.locale;
try {
await db.insert(schema.resume).values({
id,
name: input.name,
slug: input.slug,
tags: input.tags,
userId: input.userId,
data,
});
await notifyResumeUpdated({
type: "resume.updated",
resumeId: id,
userId: input.userId,
updatedAt: new Date().toISOString(),
mutation: "create",
});
return id;
} catch (error) {
const constraint = get(error, "cause.constraint") as string | undefined;
if (constraint === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
console.error("Failed to create resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to create resume" });
}
},
update: async (input: {
id: string;
userId: string;
name?: string;
slug?: string;
tags?: string[];
data?: ResumeData;
isPublic?: boolean;
}) => {
const [resume] = await db
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (resume?.isLocked) throw new ORPCError("RESUME_LOCKED");
const updateData: Partial<typeof schema.resume.$inferSelect> = {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
};
try {
const [resume] = await db
.update(schema.resume)
.set(updateData)
.where(
and(
eq(schema.resume.id, input.id),
eq(schema.resume.isLocked, false),
eq(schema.resume.userId, input.userId),
),
)
.returning({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
updatedAt: schema.resume.updatedAt,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) throw new ORPCError("NOT_FOUND");
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "update",
});
return resume;
} catch (error) {
if (error instanceof ORPCError) throw error;
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
console.error("Failed to update resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update resume" });
}
},
patch: async (input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date }) => {
const resume = await applyResumePatchTx(db, input);
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "patch",
});
return resume;
},
patchInTransaction: applyResumePatchTx,
notifyResumePatched: async (input: { resumeId: string; userId: string; updatedAt: Date }) => {
await notifyResumeUpdated({
type: "resume.updated",
resumeId: input.resumeId,
userId: input.userId,
updatedAt: input.updatedAt.toISOString(),
mutation: "patch",
});
},
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
const [resume] = await db
.update(schema.resume)
.set({ isLocked: input.isLocked })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return;
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "lock",
});
},
setPassword: async (input: { id: string; userId: string; password: string }) => {
const hashedPassword = await hash(input.password, 10);
const [resume] = await db
.update(schema.resume)
.set({ password: hashedPassword })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return;
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "password",
});
},
verifyPassword: async (input: { slug: string; username: string; password: string; responseHeaders?: Headers }) => {
const [resume] = await db
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(
and(
isNotNull(schema.resume.password),
eq(schema.resume.slug, input.slug),
eq(schema.user.username, input.username),
),
);
if (!resume) throw new ORPCError("INVALID_PASSWORD", { status: 401 });
const passwordHash = resume.password as string;
const isValid = await compare(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD", { status: 401 });
if (input.responseHeaders) grantResumeAccess(input.responseHeaders, resume.id, passwordHash);
return true;
},
removePassword: async (input: { id: string; userId: string }) => {
const [resume] = await db
.update(schema.resume)
.set({ password: null })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return;
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
userId: input.userId,
updatedAt: resume.updatedAt.toISOString(),
mutation: "password",
});
},
delete: async (input: { id: string; userId: string }) => {
await db.transaction(async (tx) => {
const [resume] = await tx
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
await tx.delete(schema.resume).where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
});
// Clean up storage files after the DB transaction succeeds
const storageService = getStorageService();
await Promise.allSettled([
storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`),
storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`),
]);
await notifyResumeUpdated({
type: "resume.updated",
resumeId: input.id,
userId: input.userId,
updatedAt: new Date().toISOString(),
mutation: "delete",
});
},
};
+100
View File
@@ -0,0 +1,100 @@
import z from "zod";
import { protectedProcedure, publicProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const sharingRouter = {
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resumes/{username}/{slug}",
tags: ["Resume Sharing"],
operationId: "getResumeBySlug",
summary: "Get public resume by username and slug",
description:
"Returns a publicly shared resume identified by the owner's username and the resume's slug. If the resume is password-protected and the viewer has not yet verified the password, a 401 error with code NEED_PASSWORD is returned. No authentication required for public resumes; if authenticated as the owner, private resumes are also accessible.",
successDescription: "The public resume with its full data.",
})
.input(resumeDto.getBySlug.input)
.output(resumeDto.getBySlug.output)
.handler(async ({ input, context }) => {
return resumeService.getBySlug({
...input,
requestHeaders: context.reqHeaders,
...(context.user?.id ? { currentUserId: context.user.id } : {}),
});
}),
setPassword: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "setResumePassword",
summary: "Set resume password",
description:
"Sets or updates a password on a resume. When a password is set, viewers of the public resume must enter the password before the resume data is revealed. The password must be between 6 and 64 characters. Requires authentication.",
successDescription: "The resume password was set successfully.",
})
.input(resumeDto.setPassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setPassword.output)
.handler(async ({ context, input }) => {
return resumeService.setPassword({
id: input.id,
userId: context.user.id,
password: input.password,
});
}),
verifyPassword: publicProcedure
.route({
method: "POST",
path: "/resumes/{username}/{slug}/password/verify",
tags: ["Resume Sharing"],
operationId: "verifyResumePassword",
summary: "Verify resume password",
description:
"Verifies a password for a password-protected public resume. On success, the viewer is granted access to view the resume data for the duration of their session. No authentication required.",
successDescription: "The password was verified successfully and access has been granted.",
})
.input(
z.object({
username: z.string().min(1).describe("The username of the resume owner."),
slug: z.string().min(1).describe("The slug of the resume."),
password: z.string().min(1).describe("The password to verify."),
}),
)
.use(resumePasswordRateLimit)
.output(z.boolean())
.handler(async ({ context, input }): Promise<boolean> => {
return resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
...(context.resHeaders ? { responseHeaders: context.resHeaders } : {}),
});
}),
removePassword: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "removeResumePassword",
summary: "Remove resume password",
description:
"Removes password protection from a resume. After removal, the resume (if public) can be viewed without entering a password. Requires authentication.",
successDescription: "The resume password was removed successfully.",
})
.input(resumeDto.removePassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.removePassword.output)
.handler(async ({ context, input }) => {
return resumeService.removePassword({
id: input.id,
userId: context.user.id,
});
}),
};
@@ -0,0 +1,30 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { resumeService } from "./service";
export const resumeStatisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/statistics",
tags: ["Resume Statistics"],
operationId: "getResumeStatistics",
summary: "Get resume statistics",
description:
"Returns view and download statistics for the specified resume, including total counts and the timestamps of the last view and download. Requires authentication.",
successDescription: "The resume's view and download statistics.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(
z.object({
isPublic: z.boolean().describe("Whether the resume is currently public."),
views: z.number().describe("Total number of times the resume has been viewed."),
downloads: z.number().describe("Total number of times the resume has been downloaded."),
lastViewedAt: z.date().nullable().describe("Timestamp of the last view, or null if never viewed."),
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
}),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
};
+21
View File
@@ -0,0 +1,21 @@
import z from "zod";
import { protectedProcedure } from "../../context";
import { resumeService } from "./service";
export const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resumes/tags",
tags: ["Resumes"],
operationId: "listResumeTags",
summary: "List all resume tags",
description:
"Returns a sorted list of all unique tags across the authenticated user's resumes. Useful for populating tag filters in the dashboard. Requires authentication.",
successDescription: "A sorted array of unique tag strings.",
})
.output(z.array(z.string()))
.handler(async ({ context }) => {
return resumeService.tags.list({ userId: context.user.id });
}),
};
@@ -0,0 +1,63 @@
import z from "zod";
import { publicProcedure } from "../../context";
import { statisticsService } from "./service";
const userRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/users",
tags: ["Platform Statistics"],
operationId: "getUserCount",
summary: "Get total number of users",
description:
"Returns the total number of registered users on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
successDescription: "The total number of registered users.",
})
.output(z.number().describe("The total number of registered users."))
.handler(async (): Promise<number> => {
return await statisticsService.user.getCount();
}),
};
const resumeRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/resumes",
tags: ["Platform Statistics"],
operationId: "getResumeCount",
summary: "Get total number of resumes",
description:
"Returns the total number of resumes created on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.",
successDescription: "The total number of resumes created.",
})
.output(z.number().describe("The total number of resumes created."))
.handler(async (): Promise<number> => {
return await statisticsService.resume.getCount();
}),
};
const githubRouter = {
getStarCount: publicProcedure
.route({
method: "GET",
path: "/statistics/github/stars",
tags: ["Platform Statistics"],
operationId: "getGitHubStarCount",
summary: "Get GitHub star count",
description:
"Returns the number of GitHub stars for the Reactive Resume repository. The count is cached for up to 6 hours and falls back to a last-known value if the GitHub API is unavailable. No authentication required.",
successDescription: "The number of GitHub stars for the Reactive Resume repository.",
})
.output(z.number().describe("The number of GitHub stars."))
.handler(async (): Promise<number> => {
return await statisticsService.github.getStarCount();
}),
};
export const statisticsRouter = {
user: userRouter,
resume: resumeRouter,
github: githubRouter,
};
@@ -0,0 +1,110 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
// Filled in by beforeEach so caches do not bleed between tests.
LOCAL_STORAGE_PATH: "" as string,
}));
const dbResult = vi.hoisted(() => ({ count: 0 }));
const dbMock = vi.hoisted(() => {
const select = vi.fn();
select.mockReturnValue({ from: () => Promise.resolve([dbResult]) });
return { select };
});
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("@reactive-resume/db/schema", () => ({ user: { __table: "user" }, resume: { __table: "resume" } }));
vi.mock("drizzle-orm", () => ({ count: () => "count(*)" }));
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
fetchMock.mockReset();
dbMock.select.mockClear();
});
const { statisticsService } = await import("./service");
// Each test gets a unique LOCAL_STORAGE_PATH to avoid cross-test cache hits.
beforeEach(() => {
envMock.LOCAL_STORAGE_PATH = mkdtempSync(join(tmpdir(), "rr-statistics-test-"));
});
describe("statisticsService.user.getCount", () => {
it("returns the DB count when the fetcher succeeds", async () => {
dbResult.count = 42;
await expect(statisticsService.user.getCount()).resolves.toBe(42);
});
it("falls back to the last-known value when the DB throws", async () => {
dbMock.select.mockImplementationOnce(() => {
throw new Error("db down");
});
const value = await statisticsService.user.getCount();
// Last known is 978_528; we just check it's > 0 (don't hard-code the magic number).
expect(value).toBeGreaterThan(0);
});
});
describe("statisticsService.resume.getCount", () => {
it("returns the DB count for resume", async () => {
dbResult.count = 7;
await expect(statisticsService.resume.getCount()).resolves.toBe(7);
});
});
describe("statisticsService.github.getStarCount", () => {
it("returns the parsed stargazers_count when GitHub responds OK", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ stargazers_count: 12345 }),
});
const stars = await statisticsService.github.getStarCount();
expect(stars).toBe(12345);
});
it("falls back to last-known on non-OK responses (retries internally)", async () => {
fetchMock.mockResolvedValue({
ok: false,
json: async () => ({}),
});
const stars = await statisticsService.github.getStarCount();
expect(stars).toBeGreaterThan(0);
});
it("falls back to last-known when fetch throws", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
const stars = await statisticsService.github.getStarCount();
expect(stars).toBeGreaterThan(0);
});
it("rejects non-positive stargazers_count and falls back", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ stargazers_count: 0 }),
});
const stars = await statisticsService.github.getStarCount();
expect(stars).toBeGreaterThan(0);
});
it("rejects non-numeric stargazers_count and falls back", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ stargazers_count: "not a number" }),
});
const stars = await statisticsService.github.getStarCount();
expect(stars).toBeGreaterThan(0);
});
});
@@ -0,0 +1,129 @@
import fs from "node:fs/promises";
import { dirname, join } from "node:path";
import { count } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; // 6 hours
const GITHUB_API_URL = "https://api.github.com/repos/amruthpillai/reactive-resume";
const GITHUB_REQUEST_TIMEOUT_MS = 5_000;
const GITHUB_REQUEST_MAX_ATTEMPTS = 2;
const LAST_KNOWN = {
users: 978_528,
resumes: 1_336_307,
stars: 34_073,
} as const;
const getCachePath = (key: string) => join(getLocalDataDirectory(env.LOCAL_STORAGE_PATH), "statistics", `${key}.txt`);
const readCache = async (key: string): Promise<number | null> => {
try {
const filePath = getCachePath(key);
const [stats, contents] = await Promise.all([fs.stat(filePath), fs.readFile(filePath, "utf-8")]);
if (stats.mtimeMs < Date.now() - CACHE_DURATION_MS) return null;
const value = Number.parseInt(contents, 10);
return Number.isFinite(value) && value >= 0 ? value : null;
} catch {
return null;
}
};
const writeCache = async (key: string, value: number) => {
try {
const filePath = getCachePath(key);
await fs.mkdir(dirname(filePath), { recursive: true });
const contents = String(value);
try {
if ((await fs.readFile(filePath, "utf-8")) === contents) return;
} catch {
// Cache file does not exist yet.
}
await fs.writeFile(filePath, contents, "utf-8");
} catch {
// Ignore errors, cache is not critical
}
};
const getCachedCount = async (
key: string,
lastKnown: number,
fetcher: () => Promise<number | null>,
): Promise<number> => {
const cached = await readCache(key);
if (cached !== null) return cached;
try {
const value = await fetcher();
if (value !== null) {
await writeCache(key, value);
return value;
}
} catch {
// Ignore errors, use last known value
}
return lastKnown;
};
const getCountFromDatabase = async (table: typeof schema.user | typeof schema.resume): Promise<number | null> => {
const [result] = await db.select({ count: count() }).from(table);
if (!result) return null;
return result.count;
};
const fetchGitHubStarsOnce = async (): Promise<number | null> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(GITHUB_API_URL, {
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
},
});
if (!response.ok) return null;
const data = (await response.json()) as { stargazers_count?: unknown };
const stars = Number(data.stargazers_count);
return Number.isFinite(stars) && stars > 0 ? stars : null;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
};
const getGitHubStars = async (): Promise<number | null> => {
for (let attempt = 0; attempt < GITHUB_REQUEST_MAX_ATTEMPTS; attempt++) {
const stars = await fetchGitHubStarsOnce();
if (stars !== null) return stars;
}
return null;
};
export const statisticsService = {
user: {
getCount: () => {
return getCachedCount("users", LAST_KNOWN.users, () => getCountFromDatabase(schema.user));
},
},
resume: {
getCount: () => {
return getCachedCount("resumes", LAST_KNOWN.resumes, () => getCountFromDatabase(schema.resume));
},
},
github: {
getStarCount: () => {
return getCachedCount("stars", LAST_KNOWN.stars, getGitHubStars);
},
},
};
@@ -0,0 +1 @@
export { getStorageService } from "./service";
+110
View File
@@ -0,0 +1,110 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { protectedProcedure } from "../../context";
import { storageDeleteRateLimit, storageUploadRateLimit } from "../../middleware/rate-limit";
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "./service";
const storageService = getStorageService();
const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB");
const filenameSchema = z.object({
filename: z.string().min(1).describe("The path or filename of the file to delete."),
});
function normalizeKey(input: string): string {
return input.trim().replace(/^\/+/, "").split("/").filter(Boolean).join("/");
}
function isUnsafeStorageKey(key: string): boolean {
return key.split("/").some((segment) => segment === "." || segment === "..");
}
export const storageRouter = {
uploadFile: protectedProcedure
.route({
tags: ["Internal"],
operationId: "uploadFile",
summary: "Upload a file",
description:
"Uploads a file to storage. Images are automatically resized and converted to JPEG format. Maximum file size is 10MB. Requires authentication.",
successDescription: "The file was uploaded successfully.",
})
.input(fileSchema)
.use(storageUploadRateLimit)
.output(
z.object({
url: z.string().describe("The public URL to access the uploaded file."),
path: z.string().describe("The storage path of the uploaded file."),
contentType: z.string().describe("The MIME type of the uploaded file."),
}),
)
.handler(async ({ context, input: file }) => {
const originalMimeType = file.type;
const isImage = isImageFile(originalMimeType);
let data: Uint8Array;
let contentType: string;
if (isImage) {
const processed = await processImageForUpload(file);
data = processed.data;
contentType = processed.contentType;
} else {
const fileBuffer = await file.arrayBuffer();
data = new Uint8Array(fileBuffer);
contentType = originalMimeType;
}
const result = await uploadFile({
userId: context.user.id,
data,
contentType,
type: "picture",
});
return {
url: result.url,
path: result.key,
contentType,
};
}),
deleteFile: protectedProcedure
.route({
tags: ["Internal"],
operationId: "deleteFile",
summary: "Delete a file",
description:
"Deletes a file from storage by its filename or path. If the filename does not start with 'uploads/', the user's picture directory is assumed. Requires authentication.",
successDescription: "The file was deleted successfully.",
})
.input(filenameSchema)
.use(storageDeleteRateLimit)
.output(z.void())
.errors({
NOT_FOUND: {
message: "The specified file was not found in storage.",
status: 404,
},
FORBIDDEN: {
message: "You do not have permission to delete this file.",
status: 403,
},
})
.handler(async ({ context, input }): Promise<void> => {
const requestedKey = normalizeKey(input.filename);
const key = requestedKey.startsWith("uploads/")
? requestedKey
: normalizeKey(`uploads/${context.user.id}/pictures/${requestedKey}`);
const userPrefix = `uploads/${context.user.id}/`;
if (isUnsafeStorageKey(key) || !key.startsWith(userPrefix)) {
throw new ORPCError("FORBIDDEN");
}
const deleted = await storageService.delete(key);
if (!deleted) throw new ORPCError("NOT_FOUND");
}),
};
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
APP_URL: "https://example.com",
LOCAL_STORAGE_PATH: "",
S3_ACCESS_KEY_ID: undefined as string | undefined,
S3_SECRET_ACCESS_KEY: undefined as string | undefined,
S3_REGION: "us-east-1",
S3_ENDPOINT: undefined as string | undefined,
S3_BUCKET: undefined as string | undefined,
S3_FORCE_PATH_STYLE: false,
FLAG_DISABLE_IMAGE_PROCESSING: false,
}));
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
// sharp is exercised by processImageForUpload; keep it out of the import graph entirely
// because resolving it loads native bindings we can't rely on in CI.
vi.mock("sharp", () => {
const chain = {
resize: () => chain,
jpeg: () => chain,
rotate: () => chain,
toBuffer: async () => Buffer.from("processed"),
metadata: async () => ({ width: 100, height: 100 }),
};
return { default: () => chain };
});
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(),
PutObjectCommand: vi.fn(),
GetObjectCommand: vi.fn(),
DeleteObjectCommand: vi.fn(),
ListObjectsV2Command: vi.fn(),
}));
const { getStorageService, inferContentType, isImageFile, processImageForUpload } = await import("./service");
const makeFile = (bytes: Uint8Array, type = "image/png") =>
({
arrayBuffer: async () => bytes.buffer,
type,
}) as unknown as File;
describe("inferContentType", () => {
it("maps common image extensions to their MIME types", () => {
expect(inferContentType("photo.jpg")).toBe("image/jpeg");
expect(inferContentType("photo.jpeg")).toBe("image/jpeg");
expect(inferContentType("photo.png")).toBe("image/png");
expect(inferContentType("animated.gif")).toBe("image/gif");
expect(inferContentType("logo.svg")).toBe("image/svg+xml");
expect(inferContentType("photo.webp")).toBe("image/webp");
});
it("maps .pdf to application/pdf", () => {
expect(inferContentType("doc.pdf")).toBe("application/pdf");
});
it("is case-insensitive on the extension", () => {
expect(inferContentType("PHOTO.JPG")).toBe("image/jpeg");
expect(inferContentType("Document.PDF")).toBe("application/pdf");
});
it("falls back to application/octet-stream for unknown extensions", () => {
expect(inferContentType("data.xyz")).toBe("application/octet-stream");
expect(inferContentType("README")).toBe("application/octet-stream");
});
it("uses just the file extension regardless of path depth", () => {
expect(inferContentType("/nested/dir/file.png")).toBe("image/png");
});
});
describe("processImageForUpload", () => {
it("returns the file untouched when image processing is disabled", async () => {
envMock.FLAG_DISABLE_IMAGE_PROCESSING = true;
const file = makeFile(new Uint8Array([1, 2, 3, 4]), "image/png");
const result = await processImageForUpload(file);
expect(result.contentType).toBe("image/png");
expect(Array.from(result.data)).toEqual([1, 2, 3, 4]);
});
it("re-encodes to JPEG via sharp when processing is enabled", async () => {
envMock.FLAG_DISABLE_IMAGE_PROCESSING = false;
const file = makeFile(new Uint8Array([5, 6, 7, 8]), "image/png");
const result = await processImageForUpload(file);
expect(result.contentType).toBe("image/jpeg");
// Sharp mock returns "processed" — ensure we got something not equal to the input.
expect(result.data.length).toBeGreaterThan(0);
expect(Array.from(result.data)).not.toEqual([5, 6, 7, 8]);
});
});
describe("isImageFile", () => {
it("returns true for supported image mime types", () => {
for (const type of ["image/gif", "image/png", "image/jpeg", "image/webp"]) {
expect(isImageFile(type), type).toBe(true);
}
});
it("returns false for image/svg+xml (not in the upload allowlist)", () => {
expect(isImageFile("image/svg+xml")).toBe(false);
});
it("returns false for application/pdf and other non-image types", () => {
expect(isImageFile("application/pdf")).toBe(false);
expect(isImageFile("text/plain")).toBe(false);
expect(isImageFile("")).toBe(false);
});
});
describe("LocalStorageService", () => {
it("rejects private writes instead of silently storing them on the local filesystem", async () => {
await expect(
getStorageService().write({
key: "uploads/user/agent/thread/file.txt",
data: new TextEncoder().encode("private"),
contentType: "text/plain",
private: true,
}),
).rejects.toThrow("Private storage writes are not supported by the local filesystem backend.");
});
});
@@ -0,0 +1,404 @@
import fs from "node:fs/promises";
import { dirname, extname, join } from "node:path";
import {
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import sharp from "sharp";
import { env } from "@reactive-resume/env/server";
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
interface StorageWriteInput {
key: string;
data: Uint8Array;
contentType: string;
private?: boolean;
}
interface StorageReadResult {
data: Uint8Array;
size: number;
etag?: string;
lastModified?: Date;
contentType?: string;
}
interface StorageService {
list(prefix: string): Promise<string[]>;
write(input: StorageWriteInput): Promise<void>;
read(key: string): Promise<StorageReadResult | null>;
delete(key: string): Promise<boolean>;
healthcheck(): Promise<StorageHealthResult>;
}
interface StorageHealthResult {
status: "healthy" | "unhealthy";
type: "local" | "s3";
message: string;
error?: string;
}
const CONTENT_TYPE_MAP: Record<string, string> = {
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
};
const DEFAULT_CONTENT_TYPE = "application/octet-stream";
const IMAGE_MIME_TYPES = ["image/gif", "image/png", "image/jpeg", "image/webp"];
// Key builders for different upload types
function buildPictureKey(userId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pictures/${timestamp}.jpeg`;
}
function buildScreenshotKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/screenshots/${resumeId}/${timestamp}.jpeg`;
}
function buildPdfKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pdfs/${resumeId}/${timestamp}.pdf`;
}
function buildPublicUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const apiPath = normalizedPath.startsWith("/api/") ? normalizedPath : `/api${normalizedPath}`;
return new URL(apiPath, env.APP_URL).toString();
}
export function inferContentType(filename: string): string {
const extension = extname(filename).toLowerCase();
return CONTENT_TYPE_MAP[extension] ?? DEFAULT_CONTENT_TYPE;
}
export function isImageFile(mimeType: string): boolean {
return IMAGE_MIME_TYPES.includes(mimeType);
}
interface ProcessedImage {
data: Uint8Array;
contentType: string;
}
export async function processImageForUpload(file: File): Promise<ProcessedImage> {
const fileBuffer = await file.arrayBuffer();
if (env.FLAG_DISABLE_IMAGE_PROCESSING) {
return {
data: new Uint8Array(fileBuffer),
contentType: file.type,
};
}
const processedBuffer = await sharp(fileBuffer)
.resize(800, 800, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
return {
data: new Uint8Array(processedBuffer),
contentType: "image/jpeg",
};
}
class LocalStorageService implements StorageService {
private rootDirectory: string;
constructor() {
this.rootDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
}
async list(prefix: string): Promise<string[]> {
const fullPath = this.resolvePath(prefix);
try {
const files = await fs.readdir(fullPath, { recursive: true });
return files.map((file) => join(prefix, file));
} catch (error: unknown) {
// If directory doesn't exist, return empty array
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
async write({ key, data, private: isPrivate }: StorageWriteInput): Promise<void> {
if (isPrivate) {
throw new Error(
"Private storage writes are not supported by the local filesystem backend. Configure S3 to store private attachments.",
);
}
const fullPath = this.resolvePath(key);
await fs.mkdir(dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, data);
}
async read(key: string): Promise<StorageReadResult | null> {
const fullPath = this.resolvePath(key);
try {
const [arrayBuffer, stats] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]);
return {
data: arrayBuffer,
size: stats.size,
etag: `"${stats.size}-${stats.mtime.getTime()}"`,
lastModified: stats.mtime,
contentType: inferContentType(key),
};
} catch (error: unknown) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
async delete(key: string): Promise<boolean> {
const fullPath = this.resolvePath(key);
// Check if the path exists and whether it's a file or folder
try {
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
// Delete the directory and its contents recursively
await fs.rm(fullPath, { recursive: true });
return true;
}
await fs.unlink(fullPath);
return true;
} catch {
// Path does not exist
return false;
}
}
async healthcheck(): Promise<StorageHealthResult> {
try {
await fs.mkdir(this.rootDirectory, { recursive: true });
await fs.access(this.rootDirectory, fs.constants.R_OK | fs.constants.W_OK);
return {
type: "local",
status: "healthy",
message: "Local filesystem storage is accessible and has read/write permission.",
};
} catch (error: unknown) {
return {
type: "local",
status: "unhealthy",
message: "Local filesystem storage is not accessible or lacks sufficient permissions.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
private resolvePath(key: string): string {
const normalizedKey = key.replace(/^\/*/, "");
const segments = normalizedKey
.split(/[/\\]+/)
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
if (segments.length === 0) throw new Error("Invalid storage key");
return join(this.rootDirectory, ...segments);
}
}
class S3StorageService implements StorageService {
private readonly bucket: string;
private readonly accessKeyId: string;
private readonly secretAccessKey: string;
private readonly endpoint: string | undefined;
private readonly clientPromise: Promise<S3Client>;
constructor() {
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY || !env.S3_BUCKET) {
throw new Error("S3 credentials are not set");
}
this.bucket = env.S3_BUCKET;
this.accessKeyId = env.S3_ACCESS_KEY_ID;
this.secretAccessKey = env.S3_SECRET_ACCESS_KEY;
this.endpoint = env.S3_ENDPOINT;
this.clientPromise = this.createClient();
}
private async createClient(): Promise<S3Client> {
return new S3Client({
region: env.S3_REGION,
forcePathStyle: env.S3_FORCE_PATH_STYLE,
...(this.endpoint ? { endpoint: this.endpoint } : {}),
credentials: {
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey,
},
});
}
private async getClient(): Promise<S3Client> {
return this.clientPromise;
}
async list(prefix: string): Promise<string[]> {
const client = await this.getClient();
const command = new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
const response = await client.send(command);
if (!response.Contents) return [];
return response.Contents.map((object) => object.Key ?? "");
}
async write({ key, data, contentType, private: isPrivate }: StorageWriteInput): Promise<void> {
const client = await this.getClient();
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: data,
ACL: isPrivate ? "private" : "public-read",
ContentType: contentType,
});
await client.send(command);
}
async read(key: string): Promise<StorageReadResult | null> {
try {
const client = await this.getClient();
const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
const response = await client.send(command);
if (!response.Body) return null;
const arrayBuffer = await response.Body.transformToByteArray();
return {
data: arrayBuffer,
size: response.ContentLength ?? 0,
contentType: response.ContentType ?? inferContentType(key),
...(response.ETag !== undefined ? { etag: response.ETag } : {}),
...(response.LastModified !== undefined ? { lastModified: response.LastModified } : {}),
};
} catch {
return null;
}
}
async delete(keyOrPrefix: string): Promise<boolean> {
const client = await this.getClient();
// Use list to find all matching keys (handles both single file and folder/prefix)
const keys = await this.list(keyOrPrefix);
if (keys.length === 0) return false;
// Delete all matching keys using Promise.allSettled
const deleteCommands = keys.map((k) => new DeleteObjectCommand({ Bucket: this.bucket, Key: k }));
const results = await Promise.allSettled(deleteCommands.map((c) => client.send(c)));
// Return true if at least one deletion succeeded
return results.some((r) => r.status === "fulfilled");
}
async healthcheck(): Promise<StorageHealthResult> {
try {
const client = await this.getClient();
const putCommand = new PutObjectCommand({ Bucket: this.bucket, Key: "healthcheck", Body: "OK" });
await client.send(putCommand);
const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucket, Key: "healthcheck" });
await client.send(deleteCommand);
return {
type: "s3",
status: "healthy",
message: "S3 storage is accessible and credentials are valid.",
};
} catch (error: unknown) {
return {
type: "s3",
status: "unhealthy",
message: "Failed to connect to S3 storage or invalid credentials.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
function createStorageService(): StorageService {
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) {
return new S3StorageService();
}
return new LocalStorageService();
}
let cachedService: StorageService | null = null;
export function getStorageService(): StorageService {
if (cachedService) return cachedService;
cachedService = createStorageService();
return cachedService;
}
// High-level upload types
type UploadType = "picture" | "screenshot" | "pdf";
interface UploadFileInput {
userId: string;
data: Uint8Array;
contentType: string;
type: UploadType;
resumeId?: string;
}
interface UploadFileResult {
url: string;
key: string;
}
export async function uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const storageService = getStorageService();
let key: string;
switch (input.type) {
case "picture":
key = buildPictureKey(input.userId);
break;
case "screenshot":
if (!input.resumeId) throw new Error("resumeId is required for screenshot uploads");
key = buildScreenshotKey(input.userId, input.resumeId);
break;
case "pdf":
if (!input.resumeId) throw new Error("resumeId is required for pdf uploads");
key = buildPdfKey(input.userId, input.resumeId);
break;
}
await storageService.write({
key,
data: input.data,
contentType: input.contentType,
});
return {
key,
url: buildPublicUrl(key),
};
}