mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat: add AI agent workspace (#3062)
* chore(ai): remove local AI store now that providers live server-side
The Zustand-based useAIStore has been replaced by the server-side
aiProviders oRPC router (encrypted credentials persisted in DB).
Delete the dead store + tests, drop the ./store export, and remove
zustand/immer deps which are no longer referenced anywhere in
packages/ai/src/.
* feat(agent): archive/delete actions and read-only state for agent threads
- Backend: mark archived threads as read-only in threads.get and reject
messages.send with CONFLICT when the thread is archived.
- Frontend: render archived threads in the sidebar with muted styling and
an Archived badge; add a per-thread dropdown menu in the chat header
with Archive (non-destructive) and Delete (with confirmation); show a
read-only banner above the message list that disambiguates archived
vs. missing-resource causes; suppress the Retry and Stop buttons in
read-only mode.
- Tests: new packages/api/src/services/agent.test.ts covering the
archived-thread isReadOnly flag and the archived-thread send refusal.
* fix(agent): abort run on archive and verify ownership before deleting thread
- threads.archive: before flipping status, abort any in-flight run controller
and clear the active-run state on the thread; cleanup failures are logged
but do not block the status update.
- threads.delete: assert thread ownership via getThread before destructive
work so an authenticated user cannot wipe another user's attachment rows
by passing a foreign threadId.
Adds focused tests for both behaviors.
* feat(agent): display patch diffs and surface revert conflicts
Render apply_resume_patch tool messages with a status-aware card (applied/
reverted/conflicted), expandable operation list, and a Revert button that
correctly handles RESUME_VERSION_CONFLICT responses. Adds unit tests for
the inverse-patch builder and the agentService.actions.revert flow.
* chore(agent): remove out-of-scope attachment tests accidentally added in Task 6
The Task 6 commit (73ef1acca) accidentally re-introduced three attachment-
related tests that belong to a separate task:
- `buildAttachmentModelParts > converts text, image, supported binary, and
unsupported attachments into model parts`
- `agentService.messages.send > persists the user message with file UI parts
and links selected attachments to it` (was failing — the `ToolLoopAgent`
mock is not callable as a constructor)
- `agentService.messages.send > rejects attachments that are missing, foreign,
or already linked before persisting a message`
These were likely re-added during a stash recovery and were not requested
for Task 6, whose scope was limited to the `agentService.actions.revert`
flow. Remove them along with the helpers/fixtures (`buildAttachment`,
`buildActiveThread`, `selectWhereResult`, `selectOrderByResult`) that they
were the only consumers of. `selectLimitResult` is preserved because it is
used by the revert tests.
* chore(agent): configure runtime dependencies
* feat(db): add agent workspace schema
* feat(api): add agent backend services
* feat(web): add agent workspace UI
* chore(agent): remove legacy builder assistant
* test(agent): make agent stream mocks constructible
* chore(web): remove unused resume replacement hook
* feat(api): add unsafe AI base URL flag
* chore(dev): expose local services in compose
* fix(web): normalize resume preview gaps
* feat(api): improve agent tool handling
* feat(web): polish agent workspace UI
* chore: update dependencies
* fix(api,web): address PR review feedback for agent workspace
Security/correctness:
- Restrict AI provider URLs to http/https even in unsafe mode
- Stop exposing Redis on host network by default
- Make .env.local optional and drop app profile in compose.dev.yml
- Store agent attachments with private ACL on S3
- Reset provider test status when provider/model/baseURL changes
- Decouple non-agent AI endpoints from REDIS_URL requirement
- Fix JSON Patch add inverse for existing object members
- Wrap resume patch + agent action insert in db transaction
- Validate partialMessage at runtime and rate-limit attachment uploads
- Add unique index on agent_messages (thread_id, sequence)
UX/bugs:
- Mark agent thread route as ssr: false and guard SSE chunk parsing
- Show config-specific banner only on known configuration error
- Gate AI provider checks behind loading state in resume import
- Fix relative-time formatter blank gap between 45-59 seconds
- Clarify thread delete confirmation message
Polish:
- Raise ENCRYPTION_SECRET minimum to 32 characters
- Bucket AI rate limits by resumeId/threadId/messageId
- Trim form values before submitting AI provider config
- Use single key identifier and nullish-coalesce baseURL display
* fix: address ai agent review feedback
* fix: preserve mobile agent chat state
* docs: add ai agent workspace guides
* feat: introduce design system for Reactive Resume
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./store": "./src/store.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./prompts": "./src/prompts.ts",
|
||||
"./tools/patch-resume": "./src/tools/patch-resume.ts",
|
||||
@@ -24,10 +23,8 @@
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"immer": "^11.1.8",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAIStore } from "./store";
|
||||
|
||||
const reset = () => {
|
||||
useAIStore.setState({
|
||||
enabled: false,
|
||||
provider: "openai",
|
||||
model: "",
|
||||
apiKey: "",
|
||||
baseURL: "",
|
||||
testStatus: "unverified",
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(reset);
|
||||
|
||||
describe("useAIStore", () => {
|
||||
it("starts with provider=openai and disabled state", () => {
|
||||
const state = useAIStore.getState();
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("set() updates fields and resets verification when credential fields change", () => {
|
||||
useAIStore.setState({ testStatus: "success", enabled: true });
|
||||
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.apiKey = "new-key";
|
||||
});
|
||||
|
||||
const state = useAIStore.getState();
|
||||
expect(state.apiKey).toBe("new-key");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
expect(state.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("set() does NOT reset testStatus when changing non-credential fields", () => {
|
||||
useAIStore.setState({ testStatus: "success", enabled: true });
|
||||
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success"; // explicit no-op
|
||||
});
|
||||
|
||||
const state = useAIStore.getState();
|
||||
expect(state.testStatus).toBe("success");
|
||||
expect(state.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("canEnable() is true only when testStatus is success", () => {
|
||||
expect(useAIStore.getState().canEnable()).toBe(false);
|
||||
|
||||
useAIStore.setState({ testStatus: "success" });
|
||||
expect(useAIStore.getState().canEnable()).toBe(true);
|
||||
|
||||
useAIStore.setState({ testStatus: "failure" });
|
||||
expect(useAIStore.getState().canEnable()).toBe(false);
|
||||
});
|
||||
|
||||
it("setEnabled(true) refuses to enable when testStatus is not success", () => {
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("setEnabled(true) succeeds when testStatus is success", () => {
|
||||
useAIStore.setState({ testStatus: "success" });
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("setEnabled(false) always succeeds (regardless of testStatus)", () => {
|
||||
useAIStore.setState({ testStatus: "success", enabled: true });
|
||||
useAIStore.getState().setEnabled(false);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("reset() clears every field back to initial state", () => {
|
||||
useAIStore.setState({
|
||||
enabled: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-3",
|
||||
apiKey: "key",
|
||||
baseURL: "https://api.anthropic.com",
|
||||
testStatus: "success",
|
||||
});
|
||||
|
||||
useAIStore.getState().reset();
|
||||
|
||||
const state = useAIStore.getState();
|
||||
expect(state).toMatchObject({
|
||||
enabled: false,
|
||||
provider: "openai",
|
||||
model: "",
|
||||
apiKey: "",
|
||||
baseURL: "",
|
||||
testStatus: "unverified",
|
||||
});
|
||||
});
|
||||
|
||||
it("resets when only the provider changes", () => {
|
||||
useAIStore.setState({ testStatus: "success", enabled: true });
|
||||
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "gemini";
|
||||
});
|
||||
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("resets when only the baseURL changes", () => {
|
||||
useAIStore.setState({ testStatus: "success", enabled: true });
|
||||
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.baseURL = "https://custom.example";
|
||||
});
|
||||
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { WritableDraft } from "immer";
|
||||
import type { AIProvider } from "./types";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
|
||||
type TestStatus = "unverified" | "success" | "failure";
|
||||
|
||||
type AIStoreState = {
|
||||
enabled: boolean;
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
testStatus: TestStatus;
|
||||
};
|
||||
|
||||
type AIStoreActions = {
|
||||
canEnable: () => boolean;
|
||||
setEnabled: (value: boolean) => void;
|
||||
set: (fn: (draft: WritableDraft<AIStoreState>) => void) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
type AIStore = AIStoreState & AIStoreActions;
|
||||
|
||||
const initialState: AIStoreState = {
|
||||
enabled: false,
|
||||
provider: "openai",
|
||||
model: "",
|
||||
apiKey: "",
|
||||
baseURL: "",
|
||||
testStatus: "unverified",
|
||||
};
|
||||
|
||||
export const useAIStore = create<AIStore>()(
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
...initialState,
|
||||
set: (fn) => {
|
||||
set((draft) => {
|
||||
const prev = {
|
||||
provider: draft.provider,
|
||||
model: draft.model,
|
||||
apiKey: draft.apiKey,
|
||||
baseURL: draft.baseURL,
|
||||
};
|
||||
|
||||
fn(draft);
|
||||
|
||||
if (
|
||||
draft.provider !== prev.provider ||
|
||||
draft.model !== prev.model ||
|
||||
draft.apiKey !== prev.apiKey ||
|
||||
draft.baseURL !== prev.baseURL
|
||||
) {
|
||||
draft.testStatus = "unverified";
|
||||
draft.enabled = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
reset: () => set(() => initialState),
|
||||
canEnable: () => {
|
||||
const { testStatus } = get();
|
||||
return testStatus === "success";
|
||||
},
|
||||
setEnabled: (value: boolean) => {
|
||||
const canEnable = get().canEnable();
|
||||
if (value && !canEnable) return;
|
||||
set((draft) => {
|
||||
draft.enabled = value;
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "ai-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
enabled: state.enabled,
|
||||
provider: state.provider,
|
||||
model: state.model,
|
||||
apiKey: state.apiKey,
|
||||
baseURL: state.baseURL,
|
||||
testStatus: state.testStatus,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,6 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const AI_PROVIDERS = ["openai", "anthropic", "gemini", "vercel-ai-gateway", "openrouter", "ollama"] as const;
|
||||
const AI_PROVIDERS = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"vercel-ai-gateway",
|
||||
"openrouter",
|
||||
"ollama",
|
||||
"openai-compatible",
|
||||
] as const;
|
||||
|
||||
export type AIProvider = (typeof AI_PROVIDERS)[number];
|
||||
|
||||
@@ -13,4 +21,5 @@ export const AI_PROVIDER_DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
||||
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v3/ai",
|
||||
openrouter: "https://openrouter.ai/api/v1",
|
||||
ollama: "https://ollama.com/api",
|
||||
"openai-compatible": "",
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@ai-sdk/openai": "^3.0.63",
|
||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@orpc/client": "^1.14.3",
|
||||
"@orpc/experimental-ratelimit": "^1.14.3",
|
||||
"@orpc/server": "^1.14.3",
|
||||
@@ -33,21 +34,26 @@
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tanstack/react-start": "^1.167.65",
|
||||
"ai": "^6.0.180",
|
||||
"ai": "^6.0.182",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.11",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.46.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"ollama-ai-provider-v2": "^3.5.1",
|
||||
"react": "^19.2.6",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.34.5",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"undici": "^8.2.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260513.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
@@ -44,9 +44,15 @@ function getInputKeyPart(input: unknown): string {
|
||||
if (!input || typeof input !== "object") return "no-input";
|
||||
|
||||
const inputRecord = input as Record<string, unknown>;
|
||||
const id = inputRecord.id;
|
||||
|
||||
if (typeof id === "string" && id.trim()) return id;
|
||||
const fields = ["resumeId", "threadId", "conversationId", "messageId", "fileId", "id"] as const;
|
||||
for (const field of fields) {
|
||||
const value = inputRecord[field];
|
||||
if (typeof value !== "string") continue;
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue) return `${field}:${trimmedValue}`;
|
||||
}
|
||||
|
||||
const username = inputRecord.username;
|
||||
const slug = inputRecord.slug;
|
||||
@@ -87,9 +93,9 @@ export const pdfExportRateLimit = createRatelimitMiddleware<ContextWithHeaders,
|
||||
key: ({ context }, input) => `pdf-export:${getUserKey(context)}:${input.id}`,
|
||||
});
|
||||
|
||||
export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders, { provider: string }>({
|
||||
export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
|
||||
limiter: productionLimiter(aiLimiter),
|
||||
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`,
|
||||
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${getInputKeyPart(input)}`,
|
||||
});
|
||||
|
||||
export const jobsSearchRateLimit = createRatelimitMiddleware<ContextWithHeaders, { params: { query: string } }>({
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import z from "zod";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiRequestRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
|
||||
import { agentService } from "../services/agent";
|
||||
|
||||
function isAgentEnvironmentUnavailable(error: unknown) {
|
||||
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
|
||||
}
|
||||
|
||||
function throwUnavailable(): never {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
function base64ToUint8Array(value: string) {
|
||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||
}
|
||||
|
||||
function isUiMessage(value: unknown): value is UIMessage {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
|
||||
const message = value as Partial<UIMessage>;
|
||||
return (
|
||||
typeof message.id === "string" &&
|
||||
(message.role === "system" || message.role === "user" || message.role === "assistant") &&
|
||||
Array.isArray(message.parts)
|
||||
);
|
||||
}
|
||||
|
||||
const threadsRouter = {
|
||||
list: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/agent/threads",
|
||||
tags: ["Agent"],
|
||||
operationId: "listAgentThreads",
|
||||
summary: "List agent threads",
|
||||
})
|
||||
.handler(async ({ context }) => {
|
||||
try {
|
||||
return await agentService.threads.list({ userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/threads",
|
||||
tags: ["Agent"],
|
||||
operationId: "createAgentThread",
|
||||
summary: "Create agent thread",
|
||||
})
|
||||
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.threads.create({
|
||||
userId: context.user.id,
|
||||
locale: context.locale,
|
||||
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
|
||||
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/agent/threads/{id}",
|
||||
tags: ["Agent"],
|
||||
operationId: "getAgentThread",
|
||||
summary: "Get agent thread",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.threads.get({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
archive: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/threads/{id}/archive",
|
||||
tags: ["Agent"],
|
||||
operationId: "archiveAgentThread",
|
||||
summary: "Archive agent thread",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.threads.archive({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/agent/threads/{id}",
|
||||
tags: ["Agent"],
|
||||
operationId: "deleteAgentThread",
|
||||
summary: "Delete agent thread",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.threads.delete({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const messagesRouter = {
|
||||
send: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/messages/send",
|
||||
tags: ["Agent"],
|
||||
operationId: "sendAgentMessage",
|
||||
summary: "Send agent message",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string(),
|
||||
message: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }),
|
||||
attachmentIds: z.array(z.string().trim().min(1)).max(10).optional(),
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.messages.send({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
message: input.message,
|
||||
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
stop: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/messages/stop",
|
||||
tags: ["Agent"],
|
||||
operationId: "stopAgentMessage",
|
||||
summary: "Stop active agent run",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string(),
|
||||
partialMessage: z.custom<UIMessage>(isUiMessage, { message: "Invalid UI message." }).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.messages.stop({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
resume: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/agent/messages/resume",
|
||||
tags: ["Agent"],
|
||||
operationId: "resumeAgentMessages",
|
||||
summary: "Resume agent message stream",
|
||||
})
|
||||
.input(z.object({ threadId: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const attachmentsRouter = {
|
||||
create: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/attachments",
|
||||
tags: ["Agent"],
|
||||
operationId: "createAgentAttachment",
|
||||
summary: "Create agent attachment",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
threadId: z.string(),
|
||||
filename: z.string().trim().min(1),
|
||||
mediaType: z.string().trim().min(1),
|
||||
data: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.use(storageUploadRateLimit)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.attachments.create({
|
||||
userId: context.user.id,
|
||||
threadId: input.threadId,
|
||||
filename: input.filename,
|
||||
mediaType: input.mediaType,
|
||||
data: base64ToUint8Array(input.data),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/agent/attachments/{id}",
|
||||
tags: ["Agent"],
|
||||
operationId: "deleteAgentAttachment",
|
||||
summary: "Delete agent attachment",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const actionsRouter = {
|
||||
revert: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/agent/actions/{id}/revert",
|
||||
tags: ["Agent"],
|
||||
operationId: "revertAgentAction",
|
||||
summary: "Revert agent action",
|
||||
})
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
|
||||
} catch (error) {
|
||||
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export const agentRouter = {
|
||||
threads: threadsRouter,
|
||||
messages: messagesRouter,
|
||||
attachments: attachmentsRouter,
|
||||
actions: actionsRouter,
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { AiProviderResponse } from "../services/ai-providers";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { type } from "@orpc/server";
|
||||
import z from "zod";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiRequestRateLimit } from "../middleware/rate-limit";
|
||||
import { aiProvidersService } from "../services/ai-providers";
|
||||
|
||||
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." });
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -5,14 +5,12 @@ import { type } from "@orpc/server";
|
||||
import { AISDKError } from "ai";
|
||||
import { flattenError, ZodError, z } from "zod";
|
||||
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiRequestRateLimit } from "../middleware/rate-limit";
|
||||
import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai";
|
||||
import { aiService, fileInputSchema } from "../services/ai";
|
||||
import { aiProvidersService } from "../services/ai-providers";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
type AIProvider = z.infer<typeof aiCredentialsSchema.shape.provider>;
|
||||
|
||||
function isInvalidAiBaseUrlError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
|
||||
}
|
||||
@@ -21,6 +19,10 @@ 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." });
|
||||
}
|
||||
@@ -29,6 +31,12 @@ 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",
|
||||
@@ -36,35 +44,17 @@ function throwResumeStructureError(error: ZodError): never {
|
||||
});
|
||||
}
|
||||
|
||||
export const aiRouter = {
|
||||
testConnection: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/ai/test-connection",
|
||||
tags: ["AI"],
|
||||
operationId: "testAiConnection",
|
||||
summary: "Test AI provider connection",
|
||||
description:
|
||||
"Validates the connection to an AI provider by sending a simple test prompt. Requires the provider type, model name, API key, and an optional base URL. Supported providers: OpenAI, Anthropic, Google Gemini, Ollama, OpenRouter, and Vercel AI Gateway. Requires authentication.",
|
||||
successDescription: "The AI provider connection was successful.",
|
||||
})
|
||||
.input(z.object({ ...aiCredentialsSchema.shape }))
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
|
||||
})
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await aiService.testConnection(input);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
throw 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",
|
||||
@@ -76,16 +66,24 @@ export const aiRouter = {
|
||||
"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({ ...aiCredentialsSchema.shape, file: fileInputSchema }))
|
||||
.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 ({ input }): Promise<ResumeData> => {
|
||||
.handler(async ({ context, input }): Promise<ResumeData> => {
|
||||
try {
|
||||
return await aiService.parsePdf(input);
|
||||
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);
|
||||
@@ -106,7 +104,7 @@ export const aiRouter = {
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
...aiCredentialsSchema.shape,
|
||||
aiProviderId: z.string().optional(),
|
||||
file: fileInputSchema,
|
||||
mediaType: z.enum([
|
||||
"application/msword",
|
||||
@@ -119,10 +117,19 @@ export const aiRouter = {
|
||||
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 ({ input }) => {
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await aiService.parseDocx(input);
|
||||
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);
|
||||
@@ -142,20 +149,30 @@ export const aiRouter = {
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
aiProviderId?: string;
|
||||
messages: UIMessage[];
|
||||
resumeData: ResumeData;
|
||||
resumeUpdatedAt: Date;
|
||||
resumeId: string;
|
||||
}>(),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
return await aiService.chat(input);
|
||||
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;
|
||||
@@ -175,9 +192,8 @@ export const aiRouter = {
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
...aiCredentialsSchema.shape,
|
||||
aiProviderId: z.string().optional(),
|
||||
resumeId: z.string(),
|
||||
resumeData: resumeDataSchema,
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
@@ -188,12 +204,16 @@ export const aiRouter = {
|
||||
})
|
||||
.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: input.provider,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
baseURL: input.baseURL,
|
||||
resumeData: input.resumeData,
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseURL ?? "",
|
||||
resumeData: resume.data,
|
||||
});
|
||||
|
||||
return await resumeService.analysis.upsert({
|
||||
@@ -202,10 +222,11 @@ export const aiRouter = {
|
||||
analysis: {
|
||||
...analysis,
|
||||
updatedAt: new Date(),
|
||||
modelMeta: { provider: input.provider, model: input.model },
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { agentRouter } from "./agent";
|
||||
import { aiRouter } from "./ai";
|
||||
import { aiProvidersRouter } from "./ai-providers";
|
||||
import { authRouter } from "./auth";
|
||||
import { flagsRouter } from "./flags";
|
||||
import { resumeRouter } from "./resume";
|
||||
@@ -7,6 +9,8 @@ import { storageRouter } from "./storage";
|
||||
|
||||
export default {
|
||||
ai: aiRouter,
|
||||
aiProviders: aiProvidersRouter,
|
||||
agent: agentRouter,
|
||||
auth: authRouter,
|
||||
flags: flagsRouter,
|
||||
resume: resumeRouter,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createInverseResumePatches } from "./agent-patches";
|
||||
|
||||
function buildFixture(): ResumeData {
|
||||
const clone = JSON.parse(JSON.stringify(defaultResumeData)) as ResumeData;
|
||||
clone.basics.name = "Alice";
|
||||
clone.basics.email = "alice@example.com";
|
||||
clone.basics.customFields = [
|
||||
{ id: "field-1", icon: "phosphor", text: "first", link: "" },
|
||||
{ id: "field-2", icon: "phosphor", text: "second", link: "" },
|
||||
];
|
||||
return clone;
|
||||
}
|
||||
|
||||
describe("createInverseResumePatches", () => {
|
||||
it("inverts a single replace into a replace back to the original value", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [{ op: "replace", path: "/basics/name", value: "Bob" }];
|
||||
|
||||
const inverse = createInverseResumePatches(data, operations);
|
||||
|
||||
expect(inverse).toEqual([{ op: "replace", path: "/basics/name", value: "Alice" }]);
|
||||
});
|
||||
|
||||
it("inverts a single remove into an add carrying the original value", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [{ op: "remove", path: "/basics/customFields/0" }];
|
||||
|
||||
const inverse = createInverseResumePatches(data, operations);
|
||||
|
||||
expect(inverse).toEqual([
|
||||
{
|
||||
op: "add",
|
||||
path: "/basics/customFields/0",
|
||||
value: { id: "field-1", icon: "phosphor", text: "first", link: "" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("inverts a single add into a remove at the same path", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [
|
||||
{
|
||||
op: "add",
|
||||
path: "/basics/customFields/2",
|
||||
value: { id: "field-3", icon: "phosphor", text: "third", link: "" },
|
||||
},
|
||||
];
|
||||
|
||||
const inverse = createInverseResumePatches(data, operations);
|
||||
|
||||
expect(inverse).toEqual([{ op: "remove", path: "/basics/customFields/2" }]);
|
||||
});
|
||||
|
||||
it("inverts an array insert at an existing index into a remove at the same path", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [
|
||||
{
|
||||
op: "add",
|
||||
path: "/basics/customFields/1",
|
||||
value: { id: "field-inserted", icon: "phosphor", text: "inserted", link: "" },
|
||||
},
|
||||
];
|
||||
|
||||
const inverse = createInverseResumePatches(data, operations);
|
||||
|
||||
expect(inverse).toEqual([{ op: "remove", path: "/basics/customFields/1" }]);
|
||||
});
|
||||
|
||||
it("composes inverses in reverse order with each original value", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [
|
||||
{ op: "replace", path: "/basics/name", value: "Bob" },
|
||||
{ op: "replace", path: "/basics/email", value: "bob@example.com" },
|
||||
];
|
||||
|
||||
const inverse = createInverseResumePatches(data, operations);
|
||||
|
||||
expect(inverse).toEqual([
|
||||
{ op: "replace", path: "/basics/email", value: "alice@example.com" },
|
||||
{ op: "replace", path: "/basics/name", value: "Alice" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads downstream pointers against the working copy after upstream removals", () => {
|
||||
// Removing /basics/customFields/1 does not affect /basics/name.
|
||||
// The inverse builder reads /basics/name from the working copy after the
|
||||
// removal has been applied; that value must still be the original "Alice".
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [
|
||||
{ op: "remove", path: "/basics/customFields/1" },
|
||||
{ op: "replace", path: "/basics/name", value: "Bob" },
|
||||
];
|
||||
|
||||
const inverse = createInverseResumePatches(data, operations);
|
||||
|
||||
expect(inverse).toEqual([
|
||||
{ op: "replace", path: "/basics/name", value: "Alice" },
|
||||
{
|
||||
op: "add",
|
||||
path: "/basics/customFields/1",
|
||||
value: { id: "field-2", icon: "phosphor", text: "second", link: "" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws INVERTIBLE_PATCH_REQUIRED when a path ends with /- (array append)", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [
|
||||
{ op: "add", path: "/basics/customFields/-", value: { id: "x", icon: "phosphor", text: "x", link: "" } },
|
||||
];
|
||||
|
||||
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
|
||||
});
|
||||
|
||||
it("throws INVERTIBLE_PATCH_REQUIRED for move operations", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [{ op: "move", path: "/basics/email", from: "/basics/name" }];
|
||||
|
||||
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
|
||||
});
|
||||
|
||||
it("throws INVERTIBLE_PATCH_REQUIRED for copy operations", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [{ op: "copy", path: "/basics/email", from: "/basics/name" }];
|
||||
|
||||
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
|
||||
});
|
||||
|
||||
it("throws INVERTIBLE_PATCH_REQUIRED for test operations", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [{ op: "test", path: "/basics/name", value: "Alice" }];
|
||||
|
||||
expect(() => createInverseResumePatches(data, operations)).toThrow("INVERTIBLE_PATCH_REQUIRED");
|
||||
});
|
||||
|
||||
it("throws INVALID_PATCH_OPERATIONS when reading a non-existent path", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [{ op: "replace", path: "/does/not/exist", value: "x" }];
|
||||
|
||||
expect(() => createInverseResumePatches(data, operations)).toThrow("INVALID_PATCH_OPERATIONS");
|
||||
});
|
||||
|
||||
it("inverts an add at an existing object member into a replace with the prior value", () => {
|
||||
const data = buildFixture();
|
||||
const operations: JsonPatchOperation[] = [{ op: "add", path: "/basics/name", value: "Bob" }];
|
||||
|
||||
const inverse = createInverseResumePatches(data, operations);
|
||||
|
||||
expect(inverse).toEqual([{ op: "replace", path: "/basics/name", value: "Alice" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
|
||||
import { applyResumePatches } from "@reactive-resume/utils/resume/patch";
|
||||
|
||||
function decodePointerSegment(segment: string) {
|
||||
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
|
||||
}
|
||||
|
||||
function readPointer(document: unknown, pointer: string): unknown {
|
||||
if (pointer === "") return document;
|
||||
if (!pointer.startsWith("/")) throw new Error("INVALID_PATCH_OPERATIONS");
|
||||
|
||||
return pointer
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map(decodePointerSegment)
|
||||
.reduce<unknown>((current, segment) => {
|
||||
if (current == null || typeof current !== "object") throw new Error("INVALID_PATCH_OPERATIONS");
|
||||
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}, document);
|
||||
}
|
||||
|
||||
function pointerExists(document: unknown, pointer: string): boolean {
|
||||
if (pointer === "") return true;
|
||||
if (!pointer.startsWith("/")) return false;
|
||||
|
||||
const segments = pointer.slice(1).split("/").map(decodePointerSegment);
|
||||
let current: unknown = document;
|
||||
for (const segment of segments) {
|
||||
if (current == null || typeof current !== "object") return false;
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!Object.hasOwn(record, segment)) return false;
|
||||
current = record[segment];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getParentPointer(pointer: string) {
|
||||
const lastSlashIndex = pointer.lastIndexOf("/");
|
||||
return lastSlashIndex <= 0 ? "" : pointer.slice(0, lastSlashIndex);
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function createInverseResumePatches(data: ResumeData, operations: JsonPatchOperation[]): JsonPatchOperation[] {
|
||||
const working = cloneJson(data);
|
||||
const inverse: JsonPatchOperation[] = [];
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.path.endsWith("/-")) throw new Error("INVERTIBLE_PATCH_REQUIRED");
|
||||
|
||||
if (operation.op === "replace") {
|
||||
inverse.unshift({ op: "replace", path: operation.path, value: cloneJson(readPointer(working, operation.path)) });
|
||||
} else if (operation.op === "remove") {
|
||||
inverse.unshift({ op: "add", path: operation.path, value: cloneJson(readPointer(working, operation.path)) });
|
||||
} else if (operation.op === "add") {
|
||||
const parent = readPointer(working, getParentPointer(operation.path));
|
||||
|
||||
// JSON Patch "add" inserts into arrays, but overwrites existing object members.
|
||||
// Array inserts must be reverted with remove; object overwrites need replace.
|
||||
if (Array.isArray(parent)) {
|
||||
inverse.unshift({ op: "remove", path: operation.path });
|
||||
} else if (pointerExists(working, operation.path)) {
|
||||
inverse.unshift({
|
||||
op: "replace",
|
||||
path: operation.path,
|
||||
value: cloneJson(readPointer(working, operation.path)),
|
||||
});
|
||||
} else {
|
||||
inverse.unshift({ op: "remove", path: operation.path });
|
||||
}
|
||||
} else {
|
||||
throw new Error("INVERTIBLE_PATCH_REQUIRED");
|
||||
}
|
||||
|
||||
applyResumePatches(working, [operation]);
|
||||
}
|
||||
|
||||
return inverse;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentDraftResumeName, buildUniqueAgentDraftSlug } from "./agent-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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { claimActiveAgentRun, clearActiveAgentRunIfCurrent } from "./agent-run-state";
|
||||
import { createAgentStreamLifecycle, emptyAgentStream } from "./agent-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 });
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AIProvider } from "@reactive-resume/ai/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentInstructions, buildAgentTools } from "./agent-tools";
|
||||
|
||||
const handlers = {
|
||||
fetchUrl: async (url: string) => ({ url, title: null, content: "Fetched content" }),
|
||||
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");
|
||||
expect(tools).toHaveProperty("fetch_url");
|
||||
});
|
||||
|
||||
it("adds provider-native web search for OpenAI providers using the explicit default base URL", () => {
|
||||
const tools = buildTools("openai", { baseURL: "https://api.openai.com/v1" });
|
||||
|
||||
expect(tools).toHaveProperty("web_search");
|
||||
expect(tools).toHaveProperty("fetch_url");
|
||||
});
|
||||
|
||||
it("does not add provider-native web search for OpenAI providers with a custom base URL", () => {
|
||||
const tools = buildTools("openai", { baseURL: "https://openai-compatible.example.com/v1" });
|
||||
|
||||
expect(tools).not.toHaveProperty("web_search");
|
||||
expect(tools).toHaveProperty("fetch_url");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"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");
|
||||
expect(tools).toHaveProperty("fetch_url");
|
||||
});
|
||||
|
||||
it("does not add provider-native web search for unsupported OpenAI models", () => {
|
||||
const tools = buildTools("openai", { model: "custom-model" });
|
||||
|
||||
expect(tools).not.toHaveProperty("web_search");
|
||||
expect(tools).toHaveProperty("fetch_url");
|
||||
});
|
||||
|
||||
it.each<AIProvider>([
|
||||
"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");
|
||||
expect(tools).toHaveProperty("fetch_url");
|
||||
});
|
||||
|
||||
it("keeps instructions explicit about native search versus exact URL fetching", () => {
|
||||
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("Use web_search");
|
||||
expect(buildAgentInstructions({ hasProviderNativeSearch: true })).toContain("Use fetch_url");
|
||||
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).not.toContain("Use web_search");
|
||||
expect(buildAgentInstructions({ hasProviderNativeSearch: false })).toContain("Use fetch_url");
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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/utils/resume/patch";
|
||||
import { supportsProviderNativeWebSearch } from "./ai-capabilities";
|
||||
|
||||
type AgentProviderConfig = {
|
||||
provider: AIProvider;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
baseURL?: string | null;
|
||||
};
|
||||
|
||||
export 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: {
|
||||
fetchUrl: (url: string) => Promise<unknown>;
|
||||
readResume: () => Promise<unknown>;
|
||||
readAttachment: (attachmentId: string) => Promise<unknown>;
|
||||
applyResumePatch: (input: ApplyResumePatchToolInput) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export 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} Use fetch_url for user-provided public HTTPS URLs, exact pages, public job descriptions, or company pages.`;
|
||||
}
|
||||
|
||||
return `${baseInstructions} Use web_search for open-ended or current web research, such as finding recent company, industry, or role context. Use fetch_url for user-provided public HTTPS URLs, exact pages, public job descriptions, or company pages.`;
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
}),
|
||||
fetch_url: tool({
|
||||
description:
|
||||
"Fetch readable text from a public HTTPS URL, such as a job description. Private, local, and non-HTTPS URLs are blocked.",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: ({ url }) => input.handlers.fetchUrl(url),
|
||||
}),
|
||||
read_resume: tool({
|
||||
description: "Read the current working resume JSON and metadata.",
|
||||
inputSchema: z.object({}),
|
||||
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 revert the action later.",
|
||||
inputSchema: applyResumePatchToolInputSchema,
|
||||
execute: (toolInput) => input.handlers.applyResumePatch(toolInput),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
CLOUDFLARE_ACCOUNT_ID: "",
|
||||
CLOUDFLARE_API_TOKEN: "",
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
|
||||
}));
|
||||
|
||||
const dnsMock = vi.hoisted(() => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
const undiciMock = vi.hoisted(() => {
|
||||
class MockAgent {
|
||||
static instances: MockAgent[] = [];
|
||||
|
||||
close = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
constructor(readonly options: Record<string, unknown>) {
|
||||
MockAgent.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Agent: MockAgent,
|
||||
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init)),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
vi.mock("node:dns/promises", () => dnsMock);
|
||||
vi.mock("undici", () => ({
|
||||
Agent: undiciMock.Agent,
|
||||
fetch: undiciMock.fetch,
|
||||
}));
|
||||
|
||||
const { fetchUrlForAgent } = await import("./agent-url");
|
||||
|
||||
function textResponse(body: string, options: { contentType: string; url?: string; status?: number }) {
|
||||
return new Response(body, {
|
||||
status: options.status ?? 200,
|
||||
headers: { "content-type": options.contentType },
|
||||
}) as Response & { url: string };
|
||||
}
|
||||
|
||||
function responseWithUrl(body: string, options: { contentType: string; url?: string; status?: number }) {
|
||||
const response = textResponse(body, options);
|
||||
Object.defineProperty(response, "url", { value: options.url ?? "https://example.com/article" });
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("fetchUrlForAgent", () => {
|
||||
beforeEach(() => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "";
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
|
||||
vi.restoreAllMocks();
|
||||
dnsMock.lookup.mockReset();
|
||||
dnsMock.lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
undiciMock.Agent.instances.length = 0;
|
||||
undiciMock.fetch.mockReset();
|
||||
undiciMock.fetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
globalThis.fetch(input, init),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("extracts local HTML article text with Readability and strips navigation/script/style noise", async () => {
|
||||
const articleText =
|
||||
"This is the actual article body with enough useful detail for the AI agent to summarize accurately. ".repeat(4);
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
responseWithUrl(
|
||||
`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Head title should lose to article title</title>
|
||||
<style>.secret { color: red; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>Home Pricing Login</nav>
|
||||
<script>window.__tracking = "do not include";</script>
|
||||
<article>
|
||||
<h1>Readable Article Title</h1>
|
||||
<p>${articleText}</p>
|
||||
</article>
|
||||
<footer>Privacy Terms</footer>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
{ contentType: "text/html; charset=utf-8", url: "https://example.com/final/article" },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await fetchUrlForAgent("https://example.com/article");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "https://example.com/article",
|
||||
title: "Head title should lose to article title",
|
||||
content: expect.stringContaining("actual article body"),
|
||||
source: "local",
|
||||
});
|
||||
expect(result.content).toContain("Readable Article Title");
|
||||
expect(result.content).not.toContain("Home Pricing Login");
|
||||
expect(result.content).not.toContain("window.__tracking");
|
||||
expect(result.content).not.toContain("color: red");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/article",
|
||||
expect.objectContaining({
|
||||
redirect: "manual",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("compacts local plain text and JSON responses", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(responseWithUrl("one\n\n two\tthree", { contentType: "text/plain" }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ name: "Ada", role: "Engineer" }, null, 2), {
|
||||
contentType: "application/json",
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/plain.txt")).resolves.toMatchObject({
|
||||
content: "one two three",
|
||||
source: "local",
|
||||
title: null,
|
||||
});
|
||||
await expect(fetchUrlForAgent("https://example.com/data.json")).resolves.toMatchObject({
|
||||
content: '{ "name": "Ada", "role": "Engineer" }',
|
||||
source: "local",
|
||||
title: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to Cloudflare when local content type is unsupported and credentials exist", async () => {
|
||||
vi.useFakeTimers();
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "crawl-job-id" }), {
|
||||
contentType: "application/json",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ result: { status: "running", records: [] } }), {
|
||||
contentType: "application/json",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({
|
||||
result: { status: "completed", records: [{ markdown: "# Rendered\n\nCloudflare markdown" }] },
|
||||
}),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = fetchUrlForAgent("https://example.com/file.pdf");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
url: "https://example.com/file.pdf",
|
||||
content: "# Rendered Cloudflare markdown",
|
||||
source: "cloudflare",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
signal: expect.any(AbortSignal),
|
||||
body: JSON.stringify({
|
||||
url: "https://example.com/file.pdf",
|
||||
crawlPurposes: ["ai-input"],
|
||||
formats: ["markdown"],
|
||||
render: true,
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl/crawl-job-id?limit=1",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: expect.objectContaining({
|
||||
authorization: "Bearer api-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl/crawl-job-id?limit=1",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("submits the final validated redirect URL to Cloudflare when local extraction fails after redirects", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/file.pdf" } }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl("binary", { contentType: "application/pdf", url: "https://example.com/file.pdf" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "redirect-job-id" }), {
|
||||
contentType: "application/json",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({ result: { status: "completed", records: [{ markdown: "redirect fallback" }] } }),
|
||||
{
|
||||
contentType: "application/json",
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
|
||||
url: "https://example.com/file.pdf",
|
||||
content: "redirect fallback",
|
||||
source: "cloudflare",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('"url":"https://example.com/file.pdf"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks DNS resolutions to private addresses before local fetch or Cloudflare fallback", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
dnsMock.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://public.example.com/article")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
expect(dnsMock.lookup).toHaveBeenCalledWith("public.example.com", { all: true });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks DNS resolutions to special-use addresses before local fetch or Cloudflare fallback", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
for (const address of [
|
||||
"100.64.0.1",
|
||||
"192.0.2.1",
|
||||
"192.88.99.1",
|
||||
"198.18.0.1",
|
||||
"198.51.100.1",
|
||||
"203.0.113.1",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
"::",
|
||||
"::ffff:8.8.8.8",
|
||||
"::ffff:0808:0808",
|
||||
"64:ff9b::1",
|
||||
"100::1",
|
||||
"100:0:0:1::1",
|
||||
"2001::1",
|
||||
"2001:2::1",
|
||||
"2001:10::1",
|
||||
"2001:100::1",
|
||||
"ff02::1",
|
||||
"2001:db8::1",
|
||||
"3fff::1",
|
||||
"5f00::1",
|
||||
]) {
|
||||
dnsMock.lookup.mockResolvedValueOnce([{ address, family: address.includes(":") ? 6 : 4 }]);
|
||||
await expect(
|
||||
fetchUrlForAgent(`https://special-${address.replaceAll(":", "-")}.example.com/article`),
|
||||
).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
}
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 DNS resolutions to private addresses", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
for (const address of ["::ffff:10.0.0.1", "::ffff:127.0.0.1", "::ffff:169.254.169.254"]) {
|
||||
dnsMock.lookup.mockResolvedValueOnce([{ address, family: 6 }]);
|
||||
await expect(fetchUrlForAgent(`https://${address.replaceAll(":", "-")}.example.com/article`)).rejects.toThrow(
|
||||
"URL_NOT_FETCHABLE",
|
||||
);
|
||||
}
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a pinned dispatcher lookup for the validated DNS address", async () => {
|
||||
dnsMock.lookup.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(responseWithUrl("Pinned DNS response", { contentType: "text/plain" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://public.example.com/article")).resolves.toMatchObject({
|
||||
content: "Pinned DNS response",
|
||||
source: "local",
|
||||
});
|
||||
|
||||
const [agent] = undiciMock.Agent.instances;
|
||||
const connect = agent?.options.connect as
|
||||
| {
|
||||
autoSelectFamily?: boolean;
|
||||
lookup?: (
|
||||
hostname: string,
|
||||
options: unknown,
|
||||
callback: (
|
||||
error: Error | null,
|
||||
address: string | Array<{ address: string; family: number }>,
|
||||
family?: number,
|
||||
) => void,
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(connect?.autoSelectFamily).toBe(false);
|
||||
await expect(
|
||||
new Promise<{ address: string; family: number }>((resolve, reject) => {
|
||||
connect?.lookup?.("public.example.com", {}, (error, address, family) => {
|
||||
if (error) reject(error);
|
||||
else if (typeof address === "string" && family) resolve({ address, family });
|
||||
else reject(new Error("Expected single address lookup result"));
|
||||
});
|
||||
}),
|
||||
).resolves.toEqual({ address: "93.184.216.34", family: 4 });
|
||||
await expect(
|
||||
new Promise<Array<{ address: string; family: number }>>((resolve, reject) => {
|
||||
connect?.lookup?.("public.example.com", { all: true }, (error, addresses) => {
|
||||
if (error) reject(error);
|
||||
else if (Array.isArray(addresses)) resolve(addresses);
|
||||
else reject(new Error("Expected all-address lookup result"));
|
||||
});
|
||||
}),
|
||||
).resolves.toEqual([{ address: "93.184.216.34", family: 4 }]);
|
||||
expect(dnsMock.lookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("times out DNS validation before local fetch or Cloudflare fallback", async () => {
|
||||
vi.useFakeTimers();
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
dnsMock.lookup.mockReturnValueOnce(new Promise(() => undefined));
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = fetchUrlForAgent("https://slow-dns.example.com/article");
|
||||
const rejection = expect(result).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await rejection;
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates DNS for redirect targets before following them", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
dnsMock.lookup
|
||||
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
|
||||
.mockResolvedValueOnce([{ address: "fd00::1", family: 6 }]);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(null, { status: 302, headers: { location: "https://cdn.example.com/final" } }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/start")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
expect(dnsMock.lookup).toHaveBeenNthCalledWith(1, "example.com", { all: true });
|
||||
expect(dnsMock.lookup).toHaveBeenNthCalledWith(2, "cdn.example.com", { all: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the latest validated redirect URL for Cloudflare fallback after network errors", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/final" } }))
|
||||
.mockRejectedValueOnce(new Error("connect timeout"))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "network-job" }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({ result: { status: "completed", records: [{ markdown: "network fallback" }] } }),
|
||||
{
|
||||
contentType: "application/json",
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
|
||||
url: "https://example.com/final",
|
||||
content: "network fallback",
|
||||
source: "cloudflare",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"https://api.cloudflare.com/client/v4/accounts/account-id/browser-rendering/crawl",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('"url":"https://example.com/final"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels redirect, non-OK, unsupported, and oversized response bodies", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const redirectCancel = vi.fn();
|
||||
const nonOkCancel = vi.fn();
|
||||
const unsupportedCancel = vi.fn();
|
||||
const oversizedCancel = vi.fn();
|
||||
const oversizedBody = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(2 * 1024 * 1024 + 1));
|
||||
},
|
||||
cancel: oversizedCancel,
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new ReadableStream({ cancel: redirectCancel }), {
|
||||
status: 302,
|
||||
headers: { location: "https://example.com/redirected" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl("ok ".repeat(80), { contentType: "text/plain", url: "https://example.com/redirected" }),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(new ReadableStream({ cancel: nonOkCancel }), { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "non-ok-job" }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({ result: { status: "completed", records: [{ markdown: "non-ok fallback" }] } }),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new ReadableStream({ cancel: unsupportedCancel }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/pdf" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "unsupported-job" }), {
|
||||
contentType: "application/json",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({ result: { status: "completed", records: [{ markdown: "unsupported fallback" }] } }),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(oversizedBody, { status: 200, headers: { "content-type": "text/plain" } }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "oversized-job" }), {
|
||||
contentType: "application/json",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({ result: { status: "completed", records: [{ markdown: "oversized fallback" }] } }),
|
||||
{ contentType: "application/json" },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/redirect")).resolves.toMatchObject({ source: "local" });
|
||||
await expect(fetchUrlForAgent("https://example.com/non-ok")).resolves.toMatchObject({ content: "non-ok fallback" });
|
||||
await expect(fetchUrlForAgent("https://example.com/unsupported")).resolves.toMatchObject({
|
||||
content: "unsupported fallback",
|
||||
});
|
||||
await expect(fetchUrlForAgent("https://example.com/oversized")).resolves.toMatchObject({
|
||||
content: "oversized fallback",
|
||||
});
|
||||
|
||||
expect(redirectCancel).toHaveBeenCalled();
|
||||
expect(nonOkCancel).toHaveBeenCalled();
|
||||
expect(unsupportedCancel).toHaveBeenCalled();
|
||||
expect(oversizedCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels Cloudflare non-OK crawl creation and poll response bodies", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const createCancel = vi.fn();
|
||||
const pollCancel = vi.fn();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new ReadableStream({ cancel: createCancel }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(responseWithUrl("binary", { contentType: "application/pdf" }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "poll-job" }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new ReadableStream({ cancel: pollCancel }), {
|
||||
status: 503,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/create-fails")).rejects.toThrow("URL_FETCH_FAILED");
|
||||
await expect(fetchUrlForAgent("https://example.com/poll-fails")).rejects.toThrow("URL_FETCH_FAILED");
|
||||
|
||||
expect(createCancel).toHaveBeenCalled();
|
||||
expect(pollCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to Cloudflare when local Readability content is too small and credentials exist", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl("<html><head><title>Thin</title></head><body><article><p>Tiny.</p></article></body></html>", {
|
||||
contentType: "text/html",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "thin-job-id" }), {
|
||||
contentType: "application/json",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({
|
||||
result: { status: "completed", records: [{ markdown: "rendered fallback for thin page" }] },
|
||||
}),
|
||||
{
|
||||
contentType: "application/json",
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/thin")).resolves.toMatchObject({
|
||||
content: "rendered fallback for thin page",
|
||||
source: "cloudflare",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("parses supported Cloudflare markdown payload shapes", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "records-job" }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ result: { status: "completed", records: [{ markdown: "records shape" }] } }), {
|
||||
contentType: "application/json",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "markdown-job" }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ result: { markdown: "first shape" } }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(responseWithUrl("nope", { contentType: "application/octet-stream" }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "array-job" }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ result: [{ markdown: "array shape" }] }), { contentType: "application/json" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/records")).resolves.toMatchObject({ content: "records shape" });
|
||||
await expect(fetchUrlForAgent("https://example.com/markdown")).resolves.toMatchObject({ content: "first shape" });
|
||||
await expect(fetchUrlForAgent("https://example.com/array")).resolves.toMatchObject({ content: "array shape" });
|
||||
});
|
||||
|
||||
it("blocks private and non-HTTPS URLs before Cloudflare fallback", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("http://example.com/article")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
await expect(fetchUrlForAgent("https://localhost/internal")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
await expect(fetchUrlForAgent("https://10.0.0.5/internal")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks redirects to private and non-HTTPS URLs before following them or falling back to Cloudflare", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "https://localhost/internal" } }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "http://example.com/plain" } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/private-redirect")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
await expect(fetchUrlForAgent("https://example.com/http-redirect")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://example.com/private-redirect",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://example.com/http-redirect",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks redirects without Location before falling back to Cloudflare", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 302 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/missing-location")).rejects.toThrow("URL_NOT_FETCHABLE");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/missing-location",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves relative HTTPS redirects and uses the final URL for local Readability extraction", async () => {
|
||||
const articleText =
|
||||
"This redirected article has enough readable content for extraction after following a relative Location header. ".repeat(
|
||||
4,
|
||||
);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/final/article" } }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
`<html><head><title>Redirected</title></head><body><article><h1>Redirected</h1><p>${articleText}</p></article></body></html>`,
|
||||
{ contentType: "text/html", url: "https://example.com/final/article" },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/start")).resolves.toMatchObject({
|
||||
url: "https://example.com/final/article",
|
||||
content: expect.stringContaining("redirected article"),
|
||||
source: "local",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://example.com/final/article",
|
||||
expect.objectContaining({ redirect: "manual" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to Cloudflare when the local response exceeds the byte limit", async () => {
|
||||
envMock.CLOUDFLARE_ACCOUNT_ID = "account-id";
|
||||
envMock.CLOUDFLARE_API_TOKEN = "api-token";
|
||||
const oversized = "a".repeat(2 * 1024 * 1024 + 1);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(responseWithUrl(oversized, { contentType: "text/plain" }))
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(JSON.stringify({ success: true, result: "huge-job-id" }), { contentType: "application/json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
responseWithUrl(
|
||||
JSON.stringify({
|
||||
result: { status: "completed", records: [{ markdown: "fallback after oversized response" }] },
|
||||
}),
|
||||
{
|
||||
contentType: "application/json",
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchUrlForAgent("https://example.com/huge.txt")).resolves.toMatchObject({
|
||||
content: "fallback after oversized response",
|
||||
source: "cloudflare",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { Agent, fetch as undiciFetch } from "undici";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { isPrivateOrLoopbackHost } from "@reactive-resume/utils/url-security.node";
|
||||
import { assertFetchablePublicHttpsUrl } from "./ai-url-policy";
|
||||
|
||||
const MAX_FETCHED_TEXT_CHARS = 40_000;
|
||||
const MAX_LOCAL_FETCH_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_LOCAL_REDIRECTS = 5;
|
||||
const MAX_CLOUDFLARE_CRAWL_POLLS = 6;
|
||||
const CLOUDFLARE_CRAWL_POLL_DELAY_MS = 500;
|
||||
const LOCAL_FETCH_TIMEOUT_MS = 10_000;
|
||||
const DNS_LOOKUP_TIMEOUT_MS = 5_000;
|
||||
const CLOUDFLARE_CRAWL_CREATE_TIMEOUT_MS = 15_000;
|
||||
const CLOUDFLARE_CRAWL_POLL_TIMEOUT_MS = 10_000;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
type FetchedUrlResult = {
|
||||
url: string;
|
||||
title: string | null;
|
||||
content: string;
|
||||
source: "local" | "cloudflare";
|
||||
};
|
||||
|
||||
type FetchResponse = Awaited<ReturnType<typeof undiciFetch>>;
|
||||
type ResolvedAddress = { address: string; family: number };
|
||||
|
||||
class LocalFetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly fallbackUrl: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function compactText(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim().slice(0, MAX_FETCHED_TEXT_CHARS);
|
||||
}
|
||||
|
||||
function extractReadableHtml(html: string, url: string) {
|
||||
const dom = new JSDOM(html, { url });
|
||||
|
||||
try {
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
const content = compactText(article?.textContent ?? "");
|
||||
|
||||
if (content.length < 160) throw new Error("URL_READABILITY_FAILED");
|
||||
|
||||
return {
|
||||
title: article?.title ? compactText(article.title) : null,
|
||||
content,
|
||||
};
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readLimitedText(response: FetchResponse) {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return response.text();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
size += value.byteLength;
|
||||
if (size > MAX_LOCAL_FETCH_BYTES) {
|
||||
await reader.cancel("FETCHED_URL_TOO_LARGE");
|
||||
throw new Error("FETCHED_URL_TOO_LARGE");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
function timeoutSignal(ms: number) {
|
||||
if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number, errorCode: string) {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(errorCode)), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelResponseBody(response: FetchResponse) {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// Best effort cleanup only; the original fetch/extraction error should stay authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
function isRedirectResponse(response: { status: number }) {
|
||||
return REDIRECT_STATUSES.has(response.status);
|
||||
}
|
||||
|
||||
function resolveRedirectUrl(location: string, currentUrl: string) {
|
||||
try {
|
||||
return new URL(location, currentUrl).toString();
|
||||
} catch {
|
||||
throw new Error("URL_NOT_FETCHABLE");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertResolvesToPublicAddress(url: string) {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
let addresses: ResolvedAddress[];
|
||||
try {
|
||||
addresses = await withTimeout(lookup(hostname, { all: true }), DNS_LOOKUP_TIMEOUT_MS, "URL_NOT_FETCHABLE");
|
||||
} catch {
|
||||
throw new Error("URL_NOT_FETCHABLE");
|
||||
}
|
||||
|
||||
if (addresses.length === 0) throw new Error("URL_NOT_FETCHABLE");
|
||||
if (addresses.some(({ address }) => isPrivateOrLoopbackHost(address))) throw new Error("URL_NOT_FETCHABLE");
|
||||
|
||||
const [address] = addresses;
|
||||
if (!address) throw new Error("URL_NOT_FETCHABLE");
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
function createPinnedDispatcher(url: string, address: string, family: number) {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
return new Agent({
|
||||
connect: {
|
||||
autoSelectFamily: false,
|
||||
servername: hostname,
|
||||
lookup: (_hostname, options, callback) => {
|
||||
if (typeof options === "object" && options && "all" in options && options.all) {
|
||||
callback(null, [{ address, family }]);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, address, family);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchLocalResponse(inputUrl: string) {
|
||||
let url = assertFetchablePublicHttpsUrl(inputUrl);
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= MAX_LOCAL_REDIRECTS; redirectCount++) {
|
||||
const address = await assertResolvesToPublicAddress(url);
|
||||
const dispatcher = createPinnedDispatcher(url, address.address, address.family);
|
||||
let response: FetchResponse;
|
||||
|
||||
try {
|
||||
response = await undiciFetch(url, {
|
||||
dispatcher,
|
||||
redirect: "manual",
|
||||
signal: timeoutSignal(LOCAL_FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
accept: "text/html, text/plain;q=0.9, application/json;q=0.8",
|
||||
"user-agent": "ReactiveResumeAI/1.0",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
if (error instanceof Error && error.message === "URL_NOT_FETCHABLE") throw error;
|
||||
throw new LocalFetchError("URL_FETCH_FAILED", url);
|
||||
}
|
||||
|
||||
if (!isRedirectResponse(response)) return { response, url, dispatcher };
|
||||
|
||||
const location = response.headers.get("location");
|
||||
await cancelResponseBody(response);
|
||||
await dispatcher.close();
|
||||
if (!location) throw new Error("URL_NOT_FETCHABLE");
|
||||
if (redirectCount === MAX_LOCAL_REDIRECTS) throw new Error("URL_NOT_FETCHABLE");
|
||||
|
||||
url = assertFetchablePublicHttpsUrl(resolveRedirectUrl(location, url));
|
||||
}
|
||||
|
||||
throw new Error("URL_NOT_FETCHABLE");
|
||||
}
|
||||
|
||||
async function fetchLocally(url: string): Promise<FetchedUrlResult> {
|
||||
const { response, url: responseUrl, dispatcher } = await fetchLocalResponse(url);
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
await cancelResponseBody(response);
|
||||
throw new LocalFetchError("URL_FETCH_FAILED", responseUrl);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (
|
||||
!contentType.includes("text/html") &&
|
||||
!contentType.includes("text/plain") &&
|
||||
!contentType.includes("application/json")
|
||||
) {
|
||||
await cancelResponseBody(response);
|
||||
throw new LocalFetchError("URL_FETCH_UNSUPPORTED_CONTENT_TYPE", responseUrl);
|
||||
}
|
||||
|
||||
const raw = await readLimitedText(response);
|
||||
const isHtml = contentType.includes("text/html");
|
||||
const extracted = isHtml ? extractReadableHtml(raw, responseUrl) : { title: null, content: compactText(raw) };
|
||||
|
||||
return {
|
||||
url: responseUrl,
|
||||
title: extracted.title,
|
||||
content: extracted.content,
|
||||
source: "local",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof LocalFetchError) throw error;
|
||||
if (error instanceof Error) throw new LocalFetchError(error.message, responseUrl);
|
||||
throw new LocalFetchError("URL_READABILITY_FAILED", responseUrl);
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
function extractCloudflareMarkdown(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const result = record.result;
|
||||
|
||||
if (result && typeof result === "object") {
|
||||
const resultRecord = result as Record<string, unknown>;
|
||||
if (Array.isArray(resultRecord.records)) {
|
||||
const [first] = resultRecord.records;
|
||||
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
|
||||
if (typeof markdown === "string") return markdown;
|
||||
}
|
||||
if (typeof resultRecord.markdown === "string") return resultRecord.markdown;
|
||||
if (Array.isArray(resultRecord.pages)) {
|
||||
const [first] = resultRecord.pages;
|
||||
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
|
||||
if (typeof markdown === "string") return markdown;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
const [first] = result;
|
||||
const markdown = first && typeof first === "object" ? (first as Record<string, unknown>).markdown : null;
|
||||
if (typeof markdown === "string") return markdown;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCloudflareJobId(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = (payload as Record<string, unknown>).result;
|
||||
|
||||
return typeof result === "string" && result.trim() ? result : null;
|
||||
}
|
||||
|
||||
function extractCloudflareCrawlStatus(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = (payload as Record<string, unknown>).result;
|
||||
if (!result || typeof result !== "object") return null;
|
||||
const status = (result as Record<string, unknown>).status;
|
||||
|
||||
return typeof status === "string" ? status.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithCloudflare(url: string): Promise<FetchedUrlResult> {
|
||||
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) throw new Error("URL_READABILITY_FAILED");
|
||||
|
||||
const crawlUrl = `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/browser-rendering/crawl`;
|
||||
const headers = {
|
||||
authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
|
||||
};
|
||||
|
||||
const response = await undiciFetch(crawlUrl, {
|
||||
method: "POST",
|
||||
signal: timeoutSignal(CLOUDFLARE_CRAWL_CREATE_TIMEOUT_MS),
|
||||
headers: {
|
||||
...headers,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
crawlPurposes: ["ai-input"],
|
||||
formats: ["markdown"],
|
||||
render: true,
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await cancelResponseBody(response);
|
||||
throw new Error("URL_FETCH_FAILED");
|
||||
}
|
||||
|
||||
const jobId = extractCloudflareJobId(await response.json());
|
||||
if (!jobId) throw new Error("URL_READABILITY_FAILED");
|
||||
|
||||
let markdown: string | null = null;
|
||||
for (let attempt = 0; attempt < MAX_CLOUDFLARE_CRAWL_POLLS; attempt++) {
|
||||
const resultResponse = await undiciFetch(`${crawlUrl}/${encodeURIComponent(jobId)}?limit=1`, {
|
||||
headers,
|
||||
signal: timeoutSignal(CLOUDFLARE_CRAWL_POLL_TIMEOUT_MS),
|
||||
});
|
||||
if (!resultResponse.ok) {
|
||||
await cancelResponseBody(resultResponse);
|
||||
throw new Error("URL_FETCH_FAILED");
|
||||
}
|
||||
|
||||
const payload = await resultResponse.json();
|
||||
markdown = extractCloudflareMarkdown(payload);
|
||||
if (markdown) break;
|
||||
|
||||
const status = extractCloudflareCrawlStatus(payload);
|
||||
if (status !== "running" && status !== "queued") break;
|
||||
if (attempt < MAX_CLOUDFLARE_CRAWL_POLLS - 1) await wait(CLOUDFLARE_CRAWL_POLL_DELAY_MS);
|
||||
}
|
||||
|
||||
if (!markdown) throw new Error("URL_READABILITY_FAILED");
|
||||
|
||||
return {
|
||||
url,
|
||||
title: null,
|
||||
content: compactText(markdown),
|
||||
source: "cloudflare",
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchUrlForAgent(input: string): Promise<FetchedUrlResult> {
|
||||
const url = assertFetchablePublicHttpsUrl(input);
|
||||
|
||||
try {
|
||||
return await fetchLocally(url);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "URL_NOT_FETCHABLE") throw error;
|
||||
return fetchWithCloudflare(error instanceof LocalFetchError ? error.fallbackUrl : url);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isDirectOpenAIProvider, supportsOpenAIWebSearch } from "./ai-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("./ai-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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } 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 credentialMatchesFingerprint(input: { apiKey: string; salt: string; hash: string }) {
|
||||
const nextHash = fingerprintCredential(input.apiKey, input.salt);
|
||||
const current = Buffer.from(input.hash, "hex");
|
||||
const next = Buffer.from(nextHash, "hex");
|
||||
|
||||
return current.length === next.length && timingSafeEqual(current, next);
|
||||
}
|
||||
|
||||
export function redactEncryptedCredential(fields: StoredCredentialFields): RedactedCredentialFields {
|
||||
return {
|
||||
apiKeyFingerprint: fields.apiKeyHash,
|
||||
apiKeyPreview: fields.apiKeyPreview,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCredentialEncryptionConfigured() {
|
||||
return !!getEncryptionSecret();
|
||||
}
|
||||
|
||||
export 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");
|
||||
}
|
||||
@@ -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 { testConnection } from "./ai";
|
||||
import {
|
||||
assertCredentialEncryptionConfigured,
|
||||
decryptCredential,
|
||||
encryptCredential,
|
||||
redactEncryptedCredential,
|
||||
} from "./ai-credentials";
|
||||
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,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: false,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
const { assertFetchablePublicHttpsUrl, resolveAiBaseUrl } = await import("./ai-url-policy");
|
||||
|
||||
describe("AI provider base URL policy", () => {
|
||||
it("allows public HTTPS provider URLs", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
|
||||
|
||||
expect(resolveAiBaseUrl({ provider: "openai", baseURL: "https://api.openai.com/v1" })).toBe(
|
||||
"https://api.openai.com/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks private and non-HTTPS provider URLs by default", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = false;
|
||||
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://localhost:11434/v1" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://example.com/v1" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows private and non-HTTPS provider URLs when explicitly enabled", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
|
||||
|
||||
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "http://localhost:11434/v1" })).toBe(
|
||||
"http://localhost:11434/v1",
|
||||
);
|
||||
expect(resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "https://10.0.0.5/v1" })).toBe(
|
||||
"https://10.0.0.5/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-HTTP schemes even when unsafe provider URLs are enabled", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
|
||||
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "file:///etc/passwd" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
expect(() => resolveAiBaseUrl({ provider: "openai-compatible", baseURL: "ftp://example.com/v1" })).toThrow(
|
||||
"INVALID_AI_BASE_URL",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps URL-fetch tools public HTTPS only even when unsafe provider URLs are enabled", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
|
||||
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://localhost/internal-job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("http://example.com/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(assertFetchablePublicHttpsUrl("https://example.com/job")).toBe("https://example.com/job");
|
||||
});
|
||||
|
||||
it("blocks special-use IP literals for URL-fetch tools", () => {
|
||||
envMock.FLAG_ALLOW_UNSAFE_AI_BASE_URL = true;
|
||||
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://100.64.0.1/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://192.0.2.1/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://192.88.99.1/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://198.18.0.1/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://198.51.100.1/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://203.0.113.1/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://224.0.0.1/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[::]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[::ffff:8.8.8.8]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[::ffff:0808:0808]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[64:ff9b::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[100::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[100:0:0:1::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[2001::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[2001:100::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[2001:2::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[2001:10::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[ff02::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[2001:db8::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[3fff::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
expect(() => assertFetchablePublicHttpsUrl("https://[5f00::1]/job")).toThrow("URL_NOT_FETCHABLE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
export function assertFetchablePublicHttpsUrl(input: string) {
|
||||
return assertSafeUrl(input, "URL_NOT_FETCHABLE");
|
||||
}
|
||||
@@ -26,11 +26,11 @@ import {
|
||||
resumePatchProposalToolInputSchema,
|
||||
resumePatchProposalToolOutputSchema,
|
||||
} from "@reactive-resume/ai/tools/patch-proposal";
|
||||
import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
||||
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
||||
import { applyResumePatches } from "@reactive-resume/utils/resume/patch";
|
||||
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node";
|
||||
import { supportsProviderNativeWebSearch } from "./ai-capabilities";
|
||||
import { resolveAiBaseUrl } from "./ai-url-policy";
|
||||
|
||||
const aiExtractionTemplate = buildAiExtractionTemplate();
|
||||
|
||||
@@ -73,26 +73,9 @@ type GetModelInput = {
|
||||
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
|
||||
|
||||
function resolveBaseUrl(input: GetModelInput): string {
|
||||
const baseURL = input.baseURL?.trim() || AI_PROVIDER_DEFAULT_BASE_URLS[input.provider];
|
||||
|
||||
if (!baseURL) throw new Error("INVALID_AI_BASE_URL");
|
||||
|
||||
const parsedBaseURL = parseUrl(baseURL);
|
||||
if (!parsedBaseURL) throw new Error("INVALID_AI_BASE_URL");
|
||||
if (parsedBaseURL.username || parsedBaseURL.password) throw new Error("INVALID_AI_BASE_URL");
|
||||
|
||||
if (!env.FLAG_ALLOW_UNSAFE_AI_BASE_URL) {
|
||||
if (parsedBaseURL.protocol !== "https:") throw new Error("INVALID_AI_BASE_URL");
|
||||
if (isPrivateOrLoopbackHost(parsedBaseURL.hostname)) throw new Error("INVALID_AI_BASE_URL");
|
||||
}
|
||||
|
||||
return parsedBaseURL.toString();
|
||||
}
|
||||
|
||||
function getModel(input: GetModelInput) {
|
||||
export function getModel(input: GetModelInput) {
|
||||
const { provider, model, apiKey } = input;
|
||||
const baseURL = resolveBaseUrl(input);
|
||||
const baseURL = resolveAiBaseUrl(input);
|
||||
|
||||
return match(provider)
|
||||
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
|
||||
@@ -100,6 +83,9 @@ function getModel(input: GetModelInput) {
|
||||
.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",
|
||||
@@ -112,6 +98,12 @@ function getModel(input: GetModelInput) {
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function getAgentModel(input: GetModelInput) {
|
||||
if (!supportsProviderNativeWebSearch(input)) return getModel(input);
|
||||
|
||||
return createOpenAI({ apiKey: input.apiKey, baseURL: resolveAiBaseUrl(input) }).responses(input.model);
|
||||
}
|
||||
|
||||
export const aiCredentialsSchema = z.object({
|
||||
provider: aiProviderSchema,
|
||||
model: z.string().trim().min(1),
|
||||
@@ -126,7 +118,7 @@ export const fileInputSchema = z.object({
|
||||
|
||||
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
|
||||
|
||||
async function testConnection(input: TestConnectionInput): Promise<boolean> {
|
||||
export async function testConnection(input: TestConnectionInput): Promise<boolean> {
|
||||
const RESPONSE_OK = "1";
|
||||
|
||||
const result = await generateText({
|
||||
|
||||
@@ -23,6 +23,82 @@ import {
|
||||
import { publishResumeUpdated } from "./resume-events";
|
||||
import { getStorageService } from "./storage";
|
||||
|
||||
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
|
||||
@@ -366,59 +442,7 @@ export const resumeService = {
|
||||
},
|
||||
|
||||
patch: async (input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date }) => {
|
||||
const [existing] = await db
|
||||
.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)));
|
||||
|
||||
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 new ORPCError("RESUME_VERSION_CONFLICT", {
|
||||
status: 409,
|
||||
message: "The resume changed after this patch was generated.",
|
||||
data: { updatedAt: existing.updatedAt.toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
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 db
|
||||
.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)),
|
||||
)
|
||||
.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");
|
||||
const resume = await applyResumePatchTx(db, input);
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
@@ -431,6 +455,18 @@ export const resumeService = {
|
||||
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)
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("@aws-sdk/client-s3", () => ({
|
||||
ListObjectsV2Command: vi.fn(),
|
||||
}));
|
||||
|
||||
const { inferContentType, isImageFile, processImageForUpload } = await import("./storage");
|
||||
const { getStorageService, inferContentType, isImageFile, processImageForUpload } = await import("./storage");
|
||||
|
||||
const makeFile = (bytes: Uint8Array, type = "image/png") =>
|
||||
({
|
||||
@@ -111,3 +111,16 @@ describe("isImageFile", () => {
|
||||
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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ interface StorageWriteInput {
|
||||
key: string;
|
||||
data: Uint8Array;
|
||||
contentType: string;
|
||||
private?: boolean;
|
||||
}
|
||||
|
||||
interface StorageReadResult {
|
||||
@@ -135,7 +136,13 @@ class LocalStorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
async write({ key, data }: StorageWriteInput): Promise<void> {
|
||||
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 });
|
||||
@@ -258,13 +265,13 @@ class S3StorageService implements StorageService {
|
||||
return response.Contents.map((object) => object.Key ?? "");
|
||||
}
|
||||
|
||||
async write({ key, data, contentType }: StorageWriteInput): Promise<void> {
|
||||
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: "public-read",
|
||||
ACL: isPrivate ? "private" : "public-read",
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
twoFactors: r.many.twoFactor(),
|
||||
passkeys: r.many.passkey(),
|
||||
resumes: r.many.resume(),
|
||||
aiProviders: r.many.aiProvider(),
|
||||
agentThreads: r.many.agentThread(),
|
||||
agentMessages: r.many.agentMessage(),
|
||||
agentAttachments: r.many.agentAttachment(),
|
||||
agentActions: r.many.agentAction(),
|
||||
apiKeys: r.many.apikey(),
|
||||
oauthClients: r.many.oauthClient(),
|
||||
oauthRefreshTokens: r.many.oauthRefreshToken(),
|
||||
@@ -59,6 +64,18 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
from: r.resume.id,
|
||||
to: r.resumeAnalysis.resumeId,
|
||||
}),
|
||||
sourceAgentThreads: r.many.agentThread({
|
||||
from: r.resume.id,
|
||||
to: r.agentThread.sourceResumeId,
|
||||
}),
|
||||
workingAgentThreads: r.many.agentThread({
|
||||
from: r.resume.id,
|
||||
to: r.agentThread.workingResumeId,
|
||||
}),
|
||||
agentActions: r.many.agentAction({
|
||||
from: r.resume.id,
|
||||
to: r.agentAction.resumeId,
|
||||
}),
|
||||
},
|
||||
resumeStatistics: {
|
||||
resume: r.one.resume({
|
||||
@@ -72,6 +89,81 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
to: r.resume.id,
|
||||
}),
|
||||
},
|
||||
aiProvider: {
|
||||
user: r.one.user({
|
||||
from: r.aiProvider.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
threads: r.many.agentThread({
|
||||
from: r.aiProvider.id,
|
||||
to: r.agentThread.aiProviderId,
|
||||
}),
|
||||
},
|
||||
agentThread: {
|
||||
user: r.one.user({
|
||||
from: r.agentThread.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
aiProvider: r.one.aiProvider({
|
||||
from: r.agentThread.aiProviderId,
|
||||
to: r.aiProvider.id,
|
||||
}),
|
||||
sourceResume: r.one.resume({
|
||||
from: r.agentThread.sourceResumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
workingResume: r.one.resume({
|
||||
from: r.agentThread.workingResumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
messages: r.many.agentMessage(),
|
||||
attachments: r.many.agentAttachment(),
|
||||
actions: r.many.agentAction(),
|
||||
},
|
||||
agentMessage: {
|
||||
user: r.one.user({
|
||||
from: r.agentMessage.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
thread: r.one.agentThread({
|
||||
from: r.agentMessage.threadId,
|
||||
to: r.agentThread.id,
|
||||
}),
|
||||
attachments: r.many.agentAttachment(),
|
||||
actions: r.many.agentAction(),
|
||||
},
|
||||
agentAttachment: {
|
||||
user: r.one.user({
|
||||
from: r.agentAttachment.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
thread: r.one.agentThread({
|
||||
from: r.agentAttachment.threadId,
|
||||
to: r.agentThread.id,
|
||||
}),
|
||||
message: r.one.agentMessage({
|
||||
from: r.agentAttachment.messageId,
|
||||
to: r.agentMessage.id,
|
||||
}),
|
||||
},
|
||||
agentAction: {
|
||||
user: r.one.user({
|
||||
from: r.agentAction.userId,
|
||||
to: r.user.id,
|
||||
}),
|
||||
thread: r.one.agentThread({
|
||||
from: r.agentAction.threadId,
|
||||
to: r.agentThread.id,
|
||||
}),
|
||||
message: r.one.agentMessage({
|
||||
from: r.agentAction.messageId,
|
||||
to: r.agentMessage.id,
|
||||
}),
|
||||
resume: r.one.resume({
|
||||
from: r.agentAction.resumeId,
|
||||
to: r.resume.id,
|
||||
}),
|
||||
},
|
||||
apikey: {
|
||||
user: r.one.user({
|
||||
from: r.apikey.referenceId,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTableColumns, getTableName } from "drizzle-orm";
|
||||
import { agentAction, agentAttachment, agentMessage, agentThread, aiProvider } from "./agent";
|
||||
|
||||
describe("aiProvider table definition", () => {
|
||||
it("is named ai_providers and stores encrypted credentials metadata", () => {
|
||||
expect(getTableName(aiProvider)).toBe("ai_providers");
|
||||
|
||||
const columns = getTableColumns(aiProvider);
|
||||
for (const name of [
|
||||
"id",
|
||||
"userId",
|
||||
"label",
|
||||
"provider",
|
||||
"model",
|
||||
"baseUrl",
|
||||
"encryptedApiKey",
|
||||
"apiKeySalt",
|
||||
"apiKeyHash",
|
||||
"apiKeyPreview",
|
||||
"testStatus",
|
||||
"lastTestedAt",
|
||||
"lastUsedAt",
|
||||
"enabled",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]) {
|
||||
expect(columns[name as keyof typeof columns], name).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent workspace table definitions", () => {
|
||||
it("declares threads, messages, attachments, and actions", () => {
|
||||
expect(getTableName(agentThread)).toBe("agent_threads");
|
||||
expect(getTableName(agentMessage)).toBe("agent_messages");
|
||||
expect(getTableName(agentAttachment)).toBe("agent_attachments");
|
||||
expect(getTableName(agentAction)).toBe("agent_actions");
|
||||
|
||||
expect(getTableColumns(agentThread).workingResumeId).toBeDefined();
|
||||
expect(getTableColumns(agentThread).lastMessageAt).toBeDefined();
|
||||
expect(getTableColumns(agentMessage).uiMessage).toBeDefined();
|
||||
expect(getTableColumns(agentAttachment).storageKey).toBeDefined();
|
||||
expect(getTableColumns(agentAction).inverseOperations).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { JsonPatchOperation } from "@reactive-resume/utils/resume/patch";
|
||||
import * as pg from "drizzle-orm/pg-core";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { user } from "./auth";
|
||||
import { resume } from "./resume";
|
||||
|
||||
export type AgentUiMessage = Record<string, unknown>;
|
||||
|
||||
export const aiProvider = pg.pgTable(
|
||||
"ai_providers",
|
||||
{
|
||||
id: pg
|
||||
.text("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
userId: pg
|
||||
.text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
label: pg.text("label").notNull(),
|
||||
provider: pg.text("provider").notNull(),
|
||||
model: pg.text("model").notNull(),
|
||||
baseUrl: pg.text("base_url"),
|
||||
encryptedApiKey: pg.text("encrypted_api_key").notNull(),
|
||||
apiKeySalt: pg.text("api_key_salt").notNull(),
|
||||
apiKeyHash: pg.text("api_key_hash").notNull(),
|
||||
apiKeyPreview: pg.text("api_key_preview").notNull(),
|
||||
testStatus: pg.text("test_status").notNull().default("untested"),
|
||||
testError: pg.text("test_error"),
|
||||
lastTestedAt: pg.timestamp("last_tested_at", { withTimezone: true }),
|
||||
lastUsedAt: pg.timestamp("last_used_at", { withTimezone: true }),
|
||||
enabled: pg.boolean("enabled").notNull().default(false),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
},
|
||||
(t) => [
|
||||
pg.index().on(t.userId, t.enabled),
|
||||
pg.index().on(t.userId, t.lastUsedAt.desc()),
|
||||
pg.index().on(t.userId, t.createdAt.asc()),
|
||||
],
|
||||
);
|
||||
|
||||
export const agentThread = pg.pgTable(
|
||||
"agent_threads",
|
||||
{
|
||||
id: pg
|
||||
.text("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
userId: pg
|
||||
.text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
aiProviderId: pg.text("ai_provider_id").references(() => aiProvider.id, { onDelete: "set null" }),
|
||||
sourceResumeId: pg.text("source_resume_id").references(() => resume.id, { onDelete: "set null" }),
|
||||
workingResumeId: pg.text("working_resume_id").references(() => resume.id, { onDelete: "set null" }),
|
||||
title: pg.text("title").notNull(),
|
||||
status: pg.text("status").notNull().default("active"),
|
||||
activeRunId: pg.text("active_run_id"),
|
||||
activeStreamId: pg.text("active_stream_id"),
|
||||
activeRunStartedAt: pg.timestamp("active_run_started_at", { withTimezone: true }),
|
||||
lastMessageAt: pg.timestamp("last_message_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
archivedAt: pg.timestamp("archived_at", { withTimezone: true }),
|
||||
deletedAt: pg.timestamp("deleted_at", { withTimezone: true }),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
},
|
||||
(t) => [
|
||||
pg.index().on(t.userId, t.status, t.lastMessageAt.desc()),
|
||||
pg.index().on(t.workingResumeId),
|
||||
pg.index().on(t.aiProviderId),
|
||||
],
|
||||
);
|
||||
|
||||
export const agentMessage = pg.pgTable(
|
||||
"agent_messages",
|
||||
{
|
||||
id: pg
|
||||
.text("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
userId: pg
|
||||
.text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
threadId: pg
|
||||
.text("thread_id")
|
||||
.notNull()
|
||||
.references(() => agentThread.id, { onDelete: "cascade" }),
|
||||
role: pg.text("role").notNull(),
|
||||
status: pg.text("status").notNull().default("completed"),
|
||||
sequence: pg.integer("sequence").notNull(),
|
||||
uiMessage: pg.jsonb("ui_message").notNull().$type<AgentUiMessage>(),
|
||||
error: pg.text("error"),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
},
|
||||
(t) => [pg.uniqueIndex().on(t.threadId, t.sequence), pg.index().on(t.userId, t.createdAt.desc())],
|
||||
);
|
||||
|
||||
export const agentAttachment = pg.pgTable(
|
||||
"agent_attachments",
|
||||
{
|
||||
id: pg
|
||||
.text("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
userId: pg
|
||||
.text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
threadId: pg
|
||||
.text("thread_id")
|
||||
.notNull()
|
||||
.references(() => agentThread.id, { onDelete: "cascade" }),
|
||||
messageId: pg.text("message_id").references(() => agentMessage.id, { onDelete: "set null" }),
|
||||
storageKey: pg.text("storage_key").notNull(),
|
||||
filename: pg.text("filename").notNull(),
|
||||
mediaType: pg.text("media_type").notNull(),
|
||||
size: pg.integer("size").notNull(),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [pg.index().on(t.threadId), pg.index().on(t.messageId), pg.index().on(t.userId)],
|
||||
);
|
||||
|
||||
export const agentAction = pg.pgTable(
|
||||
"agent_actions",
|
||||
{
|
||||
id: pg
|
||||
.text("id")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => generateId()),
|
||||
userId: pg
|
||||
.text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
threadId: pg
|
||||
.text("thread_id")
|
||||
.notNull()
|
||||
.references(() => agentThread.id, { onDelete: "cascade" }),
|
||||
messageId: pg.text("message_id").references(() => agentMessage.id, { onDelete: "set null" }),
|
||||
resumeId: pg.text("resume_id").references(() => resume.id, { onDelete: "set null" }),
|
||||
kind: pg.text("kind").notNull(),
|
||||
status: pg.text("status").notNull().default("applied"),
|
||||
title: pg.text("title").notNull(),
|
||||
summary: pg.text("summary"),
|
||||
operations: pg.jsonb("operations").notNull().$type<JsonPatchOperation[]>(),
|
||||
inverseOperations: pg.jsonb("inverse_operations").notNull().$type<JsonPatchOperation[]>(),
|
||||
baseUpdatedAt: pg.timestamp("base_updated_at", { withTimezone: true }).notNull(),
|
||||
appliedUpdatedAt: pg.timestamp("applied_updated_at", { withTimezone: true }).notNull(),
|
||||
revertedAt: pg.timestamp("reverted_at", { withTimezone: true }),
|
||||
revertMessage: pg.text("revert_message"),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
},
|
||||
(t) => [pg.index().on(t.threadId, t.createdAt.desc()), pg.index().on(t.resumeId), pg.index().on(t.messageId)],
|
||||
);
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./agent";
|
||||
export * from "./auth";
|
||||
export * from "./resume";
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"nodemailer": "^8.0.7",
|
||||
"react": "^19.2.6",
|
||||
"react-email": "^6.1.3"
|
||||
"react-email": "^6.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-email/ui": "^6.1.3",
|
||||
"@react-email/ui": "^6.1.4",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
Vendored
+6
@@ -66,6 +66,12 @@ export const env = createEnv({
|
||||
S3_BUCKET: z.string().min(1).optional(),
|
||||
S3_FORCE_PATH_STYLE: z.stringbool().default(false),
|
||||
|
||||
// AI Agent Workspace (optional until the agent feature is used)
|
||||
REDIS_URL: z.url({ protocol: /redis(s)?/ }).optional(),
|
||||
ENCRYPTION_SECRET: z.string().min(32, "ENCRYPTION_SECRET must be at least 32 characters").optional(),
|
||||
CLOUDFLARE_ACCOUNT_ID: z.string().min(1).optional(),
|
||||
CLOUDFLARE_API_TOKEN: z.string().min(1).optional(),
|
||||
|
||||
// Feature Flags
|
||||
FLAG_DISABLE_SIGNUPS: z.stringbool().default(false),
|
||||
FLAG_DISABLE_EMAIL_AUTH: z.stringbool().default(false),
|
||||
|
||||
@@ -8,6 +8,57 @@ import {
|
||||
} from "./url-security.node";
|
||||
|
||||
describe("isPrivateOrLoopbackHost", () => {
|
||||
it.each([
|
||||
"0.0.0.0",
|
||||
"10.0.0.1",
|
||||
"100.64.0.1",
|
||||
"100.127.255.255",
|
||||
"127.0.0.1",
|
||||
"169.254.1.1",
|
||||
"172.16.0.1",
|
||||
"172.31.255.255",
|
||||
"192.0.0.1",
|
||||
"192.0.2.1",
|
||||
"192.88.99.1",
|
||||
"192.168.0.1",
|
||||
"198.18.0.1",
|
||||
"198.19.255.255",
|
||||
"198.51.100.1",
|
||||
"203.0.113.1",
|
||||
"224.0.0.1",
|
||||
"240.0.0.1",
|
||||
"255.255.255.255",
|
||||
])("matches non-public/special-use IPv4 address %s", (address) => {
|
||||
expect(isPrivateOrLoopbackHost(address)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"::",
|
||||
"::1",
|
||||
"::ffff:8.8.8.8",
|
||||
"::ffff:0808:0808",
|
||||
"64:ff9b::1",
|
||||
"64:ff9b:1::1",
|
||||
"100::1",
|
||||
"100:0:0:1::1",
|
||||
"2001::1",
|
||||
"2001:2::1",
|
||||
"2001:10::1",
|
||||
"2001:db8::1",
|
||||
"2002::1",
|
||||
"3fff::1",
|
||||
"5f00::1",
|
||||
"fc00::1",
|
||||
"fd12::1",
|
||||
"fe80::1",
|
||||
"fe81::1",
|
||||
"febf::1",
|
||||
"ff00::1",
|
||||
"ff02::1",
|
||||
])("matches non-public/special-use IPv6 address %s", (address) => {
|
||||
expect(isPrivateOrLoopbackHost(address)).toBe(true);
|
||||
});
|
||||
|
||||
describe("loopback hostnames", () => {
|
||||
it("matches localhost", () => {
|
||||
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
|
||||
@@ -63,9 +114,43 @@ describe("isPrivateOrLoopbackHost", () => {
|
||||
expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 192.0.0.0/24", () => {
|
||||
expect(isPrivateOrLoopbackHost("192.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches documentation IPv4 ranges", () => {
|
||||
expect(isPrivateOrLoopbackHost("192.0.2.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("198.51.100.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("203.0.113.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches deprecated 6to4 relay anycast", () => {
|
||||
expect(isPrivateOrLoopbackHost("192.88.99.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 100.64.0.0/10 (CGNAT)", () => {
|
||||
expect(isPrivateOrLoopbackHost("100.64.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("100.127.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 198.18.0.0/15 (benchmarking)", () => {
|
||||
expect(isPrivateOrLoopbackHost("198.18.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("198.19.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches multicast, reserved, and broadcast IPv4 ranges", () => {
|
||||
expect(isPrivateOrLoopbackHost("224.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("240.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("255.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match public IPs", () => {
|
||||
expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("1.1.1.1")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("93.184.216.34")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("192.31.196.1")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("192.52.193.1")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("192.175.48.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,10 +162,58 @@ describe("isPrivateOrLoopbackHost", () => {
|
||||
|
||||
it("matches link-local fe80::/10", () => {
|
||||
expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("fe81::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("febf::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches unspecified and multicast IPv6 ranges", () => {
|
||||
expect(isPrivateOrLoopbackHost("::")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("ff00::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("ff02::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches NAT64 discard-only, benchmarking, and 6to4 IPv6 ranges", () => {
|
||||
expect(isPrivateOrLoopbackHost("64:ff9b::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("64:ff9b:1::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("100::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("100:0:0:1::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("2001::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("2001:2::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("2001:10::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("2002::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("3fff::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("5f00::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches documentation IPv6 2001:db8::/32", () => {
|
||||
expect(isPrivateOrLoopbackHost("2001:db8::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match IPv6 addresses outside fe80::/10", () => {
|
||||
expect(isPrivateOrLoopbackHost("fec0::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match global IPv6", () => {
|
||||
expect(isPrivateOrLoopbackHost("2001:db8::1")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("2606:4700:4700::1111")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches IPv4-mapped IPv6 private and loopback addresses", () => {
|
||||
expect(isPrivateOrLoopbackHost("::ffff:10.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("::ffff:169.254.169.254")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("[::ffff:192.168.1.1]")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("::ffff:7f00:1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("::ffff:0a00:1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("[::ffff:7f00:1]")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches IPv4-mapped IPv6 public addresses because the mapped range is special-purpose", () => {
|
||||
expect(isPrivateOrLoopbackHost("::ffff:8.8.8.8")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("::ffff:0808:0808")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the broad 2001::/23 IETF protocol assignments range", () => {
|
||||
expect(isPrivateOrLoopbackHost("2001:100::1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,131 @@ function stripIpv6Brackets(hostname: string): string {
|
||||
return hostname.replace(/^\[/, "").replace(/\]$/, "");
|
||||
}
|
||||
|
||||
function normalizeIpv4MappedIpv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
const mapped = normalized.match(/^::ffff:(?<address>.+)$/)?.groups?.address;
|
||||
if (!mapped) return normalized;
|
||||
|
||||
if (isIP(mapped) === 4) return mapped;
|
||||
|
||||
const hexMatch = mapped.match(/^(?<high>[0-9a-f]{1,4}):(?<low>[0-9a-f]{1,4})$/);
|
||||
if (!hexMatch?.groups) return normalized;
|
||||
|
||||
const { high: highHex, low: lowHex } = hexMatch.groups;
|
||||
if (!highHex || !lowHex) return normalized;
|
||||
|
||||
const high = Number.parseInt(highHex, 16);
|
||||
const low = Number.parseInt(lowHex, 16);
|
||||
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return normalized;
|
||||
|
||||
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||
}
|
||||
|
||||
function isIpv4MappedIpv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
|
||||
return normalized.startsWith("::ffff:");
|
||||
}
|
||||
|
||||
const blockedIpv4Cidrs: Array<[number, number]> = [
|
||||
[knownIpv4ToNumber("0.0.0.0"), 8],
|
||||
[knownIpv4ToNumber("10.0.0.0"), 8],
|
||||
[knownIpv4ToNumber("100.64.0.0"), 10],
|
||||
[knownIpv4ToNumber("127.0.0.0"), 8],
|
||||
[knownIpv4ToNumber("169.254.0.0"), 16],
|
||||
[knownIpv4ToNumber("172.16.0.0"), 12],
|
||||
[knownIpv4ToNumber("192.0.0.0"), 24],
|
||||
[knownIpv4ToNumber("192.0.2.0"), 24],
|
||||
[knownIpv4ToNumber("192.88.99.0"), 24],
|
||||
[knownIpv4ToNumber("192.168.0.0"), 16],
|
||||
[knownIpv4ToNumber("198.18.0.0"), 15],
|
||||
[knownIpv4ToNumber("198.51.100.0"), 24],
|
||||
[knownIpv4ToNumber("203.0.113.0"), 24],
|
||||
[knownIpv4ToNumber("224.0.0.0"), 4],
|
||||
[knownIpv4ToNumber("240.0.0.0"), 4],
|
||||
];
|
||||
|
||||
const blockedIpv6Cidrs: Array<[bigint, number]> = [
|
||||
[knownIpv6ToBigInt("::"), 128],
|
||||
[knownIpv6ToBigInt("::1"), 128],
|
||||
[knownIpv6ToBigInt("::ffff:0:0"), 96],
|
||||
[knownIpv6ToBigInt("64:ff9b::"), 96],
|
||||
[knownIpv6ToBigInt("64:ff9b:1::"), 48],
|
||||
[knownIpv6ToBigInt("100::"), 64],
|
||||
[knownIpv6ToBigInt("100:0:0:1::"), 64],
|
||||
[knownIpv6ToBigInt("2001::"), 23],
|
||||
[knownIpv6ToBigInt("2001:2::"), 48],
|
||||
[knownIpv6ToBigInt("2001:10::"), 28],
|
||||
[knownIpv6ToBigInt("2001:db8::"), 32],
|
||||
[knownIpv6ToBigInt("2002::"), 16],
|
||||
[knownIpv6ToBigInt("3fff::"), 20],
|
||||
[knownIpv6ToBigInt("5f00::"), 16],
|
||||
[knownIpv6ToBigInt("fc00::"), 7],
|
||||
[knownIpv6ToBigInt("fe80::"), 10],
|
||||
[knownIpv6ToBigInt("ff00::"), 8],
|
||||
];
|
||||
|
||||
function ipv4ToNumber(hostname: string) {
|
||||
const octets = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) return null;
|
||||
|
||||
return (((octets[0] ?? 0) << 24) | ((octets[1] ?? 0) << 16) | ((octets[2] ?? 0) << 8) | (octets[3] ?? 0)) >>> 0;
|
||||
}
|
||||
|
||||
function knownIpv4ToNumber(hostname: string) {
|
||||
const value = ipv4ToNumber(hostname);
|
||||
if (value === null) throw new Error(`Invalid IPv4 CIDR base: ${hostname}`);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isIpv4InCidr(address: number, base: number, prefix: number) {
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
||||
|
||||
return (address & mask) === (base & mask);
|
||||
}
|
||||
|
||||
function expandIpv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
const [head = "", tail = ""] = normalized.split("::", 2);
|
||||
const headParts = head ? head.split(":") : [];
|
||||
const tailParts = tail ? tail.split(":") : [];
|
||||
const missing = 8 - headParts.length - tailParts.length;
|
||||
if (missing < 0) return null;
|
||||
|
||||
const parts = [...headParts, ...Array.from({ length: missing }, () => "0"), ...tailParts];
|
||||
if (parts.length !== 8) return null;
|
||||
|
||||
const hextets = parts.map((part) => {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
|
||||
return Number.parseInt(part, 16);
|
||||
});
|
||||
|
||||
return hextets.every((part) => part !== null && part >= 0 && part <= 0xffff) ? (hextets as number[]) : null;
|
||||
}
|
||||
|
||||
function ipv6ToBigInt(hostname: string) {
|
||||
const hextets = expandIpv6(hostname);
|
||||
if (!hextets) return null;
|
||||
|
||||
return hextets.reduce((value, hextet) => (value << 16n) | BigInt(hextet), 0n);
|
||||
}
|
||||
|
||||
function knownIpv6ToBigInt(hostname: string) {
|
||||
const value = ipv6ToBigInt(hostname);
|
||||
if (value === null) throw new Error(`Invalid IPv6 CIDR base: ${hostname}`);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isIpv6InCidr(address: bigint, base: bigint, prefix: number) {
|
||||
const bits = 128n;
|
||||
const hostBits = bits - BigInt(prefix);
|
||||
const mask = prefix === 0 ? 0n : ((1n << bits) - 1n) ^ ((1n << hostBits) - 1n);
|
||||
|
||||
return (address & mask) === (base & mask);
|
||||
}
|
||||
|
||||
function isLoopbackOrLocalHostname(hostname: string) {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
return (
|
||||
@@ -16,28 +141,23 @@ function isLoopbackOrLocalHostname(hostname: string) {
|
||||
}
|
||||
|
||||
function isPrivateIPv4(hostname: string) {
|
||||
const [first = 0, second = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (Number.isNaN(first) || Number.isNaN(second)) return false;
|
||||
const address = ipv4ToNumber(hostname);
|
||||
if (address === null) return false;
|
||||
|
||||
if (first === 10) return true;
|
||||
if (first === 127) return true;
|
||||
if (first === 169 && second === 254) return true;
|
||||
if (first === 172 && second >= 16 && second <= 31) return true;
|
||||
if (first === 192 && second === 168) return true;
|
||||
if (first === 0) return true;
|
||||
|
||||
return false;
|
||||
return blockedIpv4Cidrs.some(([base, prefix]) => isIpv4InCidr(address, base ?? 0, prefix));
|
||||
}
|
||||
|
||||
function isPrivateIPv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
return (
|
||||
normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:")
|
||||
);
|
||||
const address = ipv6ToBigInt(hostname);
|
||||
if (address === null) return false;
|
||||
|
||||
return blockedIpv6Cidrs.some(([base, prefix]) => isIpv6InCidr(address, base, prefix));
|
||||
}
|
||||
|
||||
export function isPrivateOrLoopbackHost(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
if (isIpv4MappedIpv6(hostname)) return true;
|
||||
|
||||
const normalized = normalizeIpv4MappedIpv6(hostname);
|
||||
if (isLoopbackOrLocalHostname(normalized)) return true;
|
||||
|
||||
const ipVersion = isIP(normalized);
|
||||
|
||||
Reference in New Issue
Block a user