([]);
+ const [baseUrl, setBaseUrl] = useState("");
+
+ useEffect(() => {
+ async function init() {
+ const url = await getBaseUrl();
+ setBaseUrl(url);
+
+ // Auth check
+ const authResult = await sendMessage<{
+ type: string;
+ authenticated: boolean;
+ }>({ type: "GET_AUTH_STATUS" });
+
+ if (!authResult.authenticated) {
+ setAuthenticated(false);
+ setStatus("idle");
+ return;
+ }
+ setAuthenticated(true);
+
+ // Load history + profiles in parallel
+ const [historyResult, profilesResult] = await Promise.all([
+ sendMessage<{ type: string; history: ImportRecord[] }>({
+ type: "GET_IMPORT_HISTORY",
+ }),
+ sendMessage<{ type: string; profiles: Profile[] }>({
+ type: "GET_PROFILES",
+ }),
+ ]);
+
+ setImportHistory(historyResult.history ?? []);
+ const profs = profilesResult.profiles ?? [];
+ setProfiles(profs);
+
+ const active = profs.find((p) => p.isActive);
+ if (active) setSelectedProfileId(active.id);
+ else if (profs.length > 0) setSelectedProfileId(profs[0].id);
+
+ const profileId = active?.id ?? profs[0]?.id;
+ if (!profileId) {
+ setStatus("error");
+ setErrorMessage("No profile found. Create one in Shortlist first.");
+ return;
+ }
+
+ // Collect page content
+ const [tab] = await chrome.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ if (!tab?.id) {
+ setStatus("idle");
+ return;
+ }
+
+ let content: PageContent | null = null;
+
+ try {
+ const [injected] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: () => {
+ const el =
+ document.querySelector("[role='main'] article") ??
+ document.querySelector("[role='main']") ??
+ document.querySelector("main") ??
+ document.querySelector("article") ??
+ document.body;
+ const clone = el.cloneNode(true) as HTMLElement;
+ clone
+ .querySelectorAll(
+ "script, style, nav, footer, header, iframe, noscript, " +
+ "svg, img, video, audio, canvas, " +
+ "[role='navigation'], [role='banner'], [role='contentinfo'], " +
+ "[aria-hidden='true']",
+ )
+ .forEach((n) => n.remove());
+ return {
+ url: window.location.href,
+ html: clone.innerHTML.slice(0, 50000),
+ title: document.title,
+ };
+ },
+ });
+ if (injected?.result) content = injected.result;
+ } catch {
+ // Page is restricted (chrome://, about:, etc.)
+ }
+
+ if (!content || content.html.length < 50) {
+ setStatus("idle");
+ return;
+ }
+
+ setPageUrl(content.url);
+
+ // Extract via API
+ setStatus("extracting");
+ const extractResult = await sendMessage<{
+ type: string;
+ ok?: boolean;
+ data?: ExtractResult;
+ error?: string;
+ }>({
+ type: "EXTRACT_JOB",
+ html: content.html,
+ profileId,
+ });
+
+ if (extractResult.ok && extractResult.data) {
+ setExtracted(extractResult.data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ setErrorMessage(
+ extractResult.error ?? "Could not extract job details.",
+ );
+ }
+ }
+
+ init();
+ }, []);
+
+ // ── Import handler ───────────────────────────────────────────────────
+
+ const selectedProfileName =
+ profiles.find((p) => p.id === selectedProfileId)?.name ?? "Unknown";
+
+ function addToHistory(record: ImportRecord) {
+ setImportHistory((prev) => [record, ...prev].slice(0, 5));
+ }
+
+ async function handleImport() {
+ if (!selectedProfileId || !extracted) return;
+
+ setStatus("importing");
+ setErrorMessage("");
+
+ const data: ImportPayload = {
+ originalInput: pageUrl || extracted.url || "",
+ title: extracted.title,
+ company: extracted.company,
+ description: extracted.description,
+ location: extracted.location,
+ locationType: extracted.locationType,
+ url: extracted.url || pageUrl || null,
+ postedAt: extracted.postedAt,
+ jobType: extracted.jobType,
+ salaryMin: extracted.salaryMin,
+ salaryMax: extracted.salaryMax,
+ currency: extracted.currency,
+ skills: extracted.skills,
+ };
+
+ try {
+ const result = await sendMessage<{
+ type: string;
+ ok?: boolean;
+ jobId?: string;
+ error?: string;
+ status?: number;
+ }>({
+ type: "IMPORT_JOB",
+ profileId: selectedProfileId,
+ profileName: selectedProfileName,
+ data,
+ });
+
+ if (result.ok) {
+ setStatus("success");
+ setImportedJobId(result.jobId ?? null);
+ if (result.jobId) {
+ addToHistory({
+ jobId: result.jobId,
+ title: extracted.title,
+ company: extracted.company,
+ source: "CUSTOM",
+ importedAt: new Date().toISOString(),
+ profileName: selectedProfileName,
+ });
+ }
+ } else if (result.status === 409) {
+ setStatus("duplicate");
+ } else {
+ setStatus("error");
+ setErrorMessage(result.error ?? "Import failed");
+ }
+ } catch {
+ setStatus("error");
+ setErrorMessage("Something went wrong. Please try again.");
+ }
+ }
+
+ function handleEditInShortlist() {
+ const importUrl = pageUrl
+ ? `${baseUrl}/dashboard?import=${encodeURIComponent(pageUrl)}`
+ : `${baseUrl}/dashboard`;
+ chrome.tabs.create({ url: importUrl });
+ }
+
+ // ── Render ─────────────────────────────────────────────────────────────
+
+ if (authenticated === null) {
+ return (
+
+ );
+ }
+
+ if (!authenticated) {
+ return (
+
+
+
+
Sign in to Shortlist to import jobs.
+
chrome.tabs.create({ url: `${baseUrl}/sign-in` })}
+ >
+ Sign in to Shortlist
+
+
+
+ );
+ }
+
+ if (status === "success") {
+ return (
+
+
+
Job imported successfully!
+
+ {importedJobId && (
+
+ chrome.tabs.create({
+ url: `${baseUrl}/jobs/${importedJobId}`,
+ })
+ }
+ >
+ View in Shortlist
+
+ )}
+
+ chrome.tabs.create({ url: `${baseUrl}/dashboard` })
+ }
+ >
+ Open Dashboard
+
+
+
+
+ );
+ }
+
+ if (status === "duplicate") {
+ return (
+
+
+
+ This job has already been imported.
+
+
+ chrome.tabs.create({ url: `${baseUrl}/dashboard` })
+ }
+ >
+ Open Dashboard
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {status === "extracting" && (
+
+ Extracting job details...
+
+ )}
+
+ {extracted && status === "idle" && (
+ <>
+
+
{extracted.title}
+
{extracted.company}
+ {extracted.location && (
+
{extracted.location}
+ )}
+ {extracted.salaryMin && extracted.salaryMax && (
+
+ {extracted.currency ?? "$"}
+ {extracted.salaryMin.toLocaleString()} – {extracted.currency ?? "$"}
+ {extracted.salaryMax.toLocaleString()}
+
+ )}
+
+
+ {profiles.length > 1 && (
+
setSelectedProfileId(e.target.value)}
+ >
+ {profiles.map((p) => (
+
+ {p.name}
+ {p.isActive ? " (active)" : ""}
+
+ ))}
+
+ )}
+
+
+
+ Import to Shortlist
+
+
+ Edit in Shortlist
+
+
+ >
+ )}
+
+ {!extracted && status === "idle" && (
+
+
No job listing found on this page.
+
+ Import manually in Shortlist
+
+
+ )}
+
+ {status === "importing" && (
+
Importing...
+ )}
+
+ {status === "error" && (
+ <>
+
{errorMessage}
+
+ Try importing in Shortlist instead
+
+ >
+ )}
+
+
+
+ );
+}
+
+// ── Sub-components ───────────────────────────────────────────────────────
+
+function Header() {
+ return (
+
+ );
+}
+
+function ImportHistory({ history, baseUrl }: { history: ImportRecord[]; baseUrl: string }) {
+ if (history.length === 0) return null;
+
+ return (
+
+
Recent imports
+
+ {history.map((record) => (
+
+
+ chrome.tabs.create({
+ url: `${baseUrl}/jobs/${record.jobId}`,
+ })
+ }
+ >
+
+ {record.title}
+
+ {" "}@ {record.company}
+
+
+
+ {timeAgo(record.importedAt)}
+
+
+
+ ))}
+
+
+ );
+}
+
+// ── Mount ────────────────────────────────────────────────────────────────
+
+const root = document.getElementById("root");
+if (root) {
+ createRoot(root).render( );
+}
diff --git a/extensions/chrome/src/types.ts b/extensions/chrome/src/types.ts
new file mode 100644
index 0000000..f7a1c36
--- /dev/null
+++ b/extensions/chrome/src/types.ts
@@ -0,0 +1,83 @@
+// ── Import history ────────────────────────────────────────────────────────
+
+export interface ImportRecord {
+ jobId: string;
+ title: string;
+ company: string;
+ source: string;
+ importedAt: string;
+ profileName: string;
+}
+
+// ── Page content collected by content script ─────────────────────────────
+
+export interface PageContent {
+ url: string;
+ html: string;
+ title: string;
+}
+
+// ── Extract endpoint response ────────────────────────────────────────────
+
+export interface ExtractResult {
+ title: string;
+ company: string;
+ description: string;
+ location: string | null;
+ locationType: "REMOTE" | "HYBRID" | "ONSITE" | null;
+ url: string | null;
+ postedAt: string | null;
+ jobType: string | null;
+ salaryMin: number | null;
+ salaryMax: number | null;
+ currency: string | null;
+ skills: string[];
+}
+
+// ── Import endpoint payload ──────────────────────────────────────────────
+
+export interface ImportPayload {
+ originalInput: string;
+ title: string;
+ company: string;
+ description: string;
+ location?: string | null;
+ locationType?: string | null;
+ url?: string | null;
+ postedAt?: string | null;
+ jobType?: string | null;
+ salaryMin?: number | null;
+ salaryMax?: number | null;
+ currency?: string | null;
+ skills?: string[];
+}
+
+// ── Chrome message types ─────────────────────────────────────────────────
+
+export type Message =
+ // Auth
+ | { type: "GET_AUTH_STATUS" }
+ | { type: "AUTH_STATUS"; authenticated: boolean }
+
+ // Profiles
+ | { type: "GET_PROFILES" }
+ | {
+ type: "PROFILES";
+ profiles: Array<{ id: string; name: string; isActive: boolean }>;
+ }
+
+ // Page content (popup -> content script)
+ | { type: "GET_PAGE_CONTENT" }
+ | { type: "PAGE_CONTENT"; content: PageContent }
+
+ // Extract (popup -> service worker -> server)
+ | { type: "EXTRACT_JOB"; html: string; profileId: string }
+ | { type: "EXTRACT_RESULT"; ok: boolean; data?: ExtractResult; error?: string }
+
+ // Import (popup -> service worker -> server)
+ | { type: "IMPORT_JOB"; profileId: string; profileName: string; data: ImportPayload }
+ | { type: "IMPORT_RESULT"; ok: boolean; jobId?: string; error?: string }
+
+ // Import history
+ | { type: "GET_IMPORT_HISTORY" }
+ | { type: "IMPORT_HISTORY"; history: ImportRecord[] };
diff --git a/extensions/chrome/tsconfig.json b/extensions/chrome/tsconfig.json
new file mode 100644
index 0000000..e6df9e7
--- /dev/null
+++ b/extensions/chrome/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["chrome"]
+ },
+ "include": ["src"]
+}
diff --git a/extensions/chrome/vite.config.ts b/extensions/chrome/vite.config.ts
new file mode 100644
index 0000000..103d592
--- /dev/null
+++ b/extensions/chrome/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { crx } from "@crxjs/vite-plugin";
+import manifest from "./src/manifest.json";
+
+export default defineConfig({
+ plugins: [react(), crx({ manifest })],
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ },
+});
diff --git a/public/privacy-extension.html b/public/privacy-extension.html
new file mode 100644
index 0000000..fac0045
--- /dev/null
+++ b/public/privacy-extension.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+ Shortlist Job Importer — Privacy Policy
+
+
+
+ Shortlist Job Importer — Privacy Policy
+ Last updated: March 25, 2026
+
+ What the extension does
+ The Shortlist Job Importer Chrome extension helps you import job listings from any website into your Shortlist account. When you click the extension icon on a job posting page, it reads the page content, extracts job details, and saves them to your Shortlist feed.
+
+ Data we collect
+
+ Page content. When you click the extension icon, it reads the HTML content of the current page to extract job listing details (title, company, location, description, etc.). This content is sent to the Shortlist server for AI-powered extraction and stored alongside the imported job listing in your account so that extraction can be re-processed if our parsing improves.
+ Import history. The last 5 imported jobs are stored locally in your browser's extension storage for display in the popup. This data never leaves your device.
+
+
+ Data we do NOT collect
+
+ We do not collect browsing history or track which pages you visit.
+ We do not read page content unless you explicitly click the extension icon.
+ We do not store or transmit any credentials. Authentication uses your existing Shortlist session cookie.
+ We do not sell or share your data for advertising, profiling, or marketing purposes.
+
+
+ How we use your data
+ Page content is sent to the Shortlist server to extract structured job listing fields (title, company, description, etc.). To perform this extraction, a cleaned and truncated version of the page content is sent to a third-party AI service (currently OpenRouter/Anthropic) for processing. The AI service receives only the text needed for extraction — no account credentials, personal information, or browsing history are included in these requests.
+ Both the extracted data and the original page content are saved to your Shortlist account. The original content is retained so that job details can be re-extracted if our parsing logic improves. Your data is stored securely and is only accessible to you through your Shortlist account.
+
+ Third-party services
+ Shortlist uses the following third-party services to process your data:
+
+ OpenRouter / Anthropic: AI-powered extraction of job listing fields from page content. Only cleaned page text is sent — no credentials or personal data. See OpenRouter's privacy policy .
+ Clerk: Authentication provider. Manages your sign-in session. See Clerk's privacy policy .
+ Neon: Database provider. Stores your imported job listings and account data. See Neon's privacy policy .
+
+
+ Authentication
+ The extension authenticates with the Shortlist API using your existing browser session cookie. No passwords, API keys, or tokens are stored in the extension.
+
+ Permissions
+
+ activeTab: Read the current page when you click the extension icon.
+ scripting: Inject a content script to extract page content.
+ storage: Store your recent import history and preferences locally.
+ Host permission (shortlist.johnmoorman.com): Communicate with the Shortlist API.
+
+
+ Contact
+ For questions about this privacy policy, contact: john@johnmoorman.com
+
+
diff --git a/src/app/api/extension/profiles/route.ts b/src/app/api/extension/profiles/route.ts
new file mode 100644
index 0000000..b4eb7d8
--- /dev/null
+++ b/src/app/api/extension/profiles/route.ts
@@ -0,0 +1,19 @@
+import { auth } from "@clerk/nextjs/server";
+import { prisma } from "@/lib/prisma";
+
+export async function GET() {
+ const { userId } = await auth();
+ if (!userId) return new Response("Unauthorized", { status: 401 });
+
+ const profiles = await prisma.profile.findMany({
+ where: { userId },
+ select: {
+ id: true,
+ name: true,
+ isActive: true,
+ },
+ orderBy: { createdAt: "asc" },
+ });
+
+ return Response.json({ profiles });
+}
diff --git a/src/app/api/extension/status/route.ts b/src/app/api/extension/status/route.ts
new file mode 100644
index 0000000..8712c62
--- /dev/null
+++ b/src/app/api/extension/status/route.ts
@@ -0,0 +1,9 @@
+import { auth } from "@clerk/nextjs/server";
+
+export async function GET() {
+ const { userId } = await auth();
+ if (!userId) {
+ return Response.json({ authenticated: false }, { status: 401 });
+ }
+ return Response.json({ authenticated: true, userId });
+}
diff --git a/src/app/api/jobs/extract/route.ts b/src/app/api/jobs/extract/route.ts
index 43e005b..b859b3b 100644
--- a/src/app/api/jobs/extract/route.ts
+++ b/src/app/api/jobs/extract/route.ts
@@ -82,6 +82,15 @@ export async function POST(req: Request) {
const models = getModels(profile);
+ // Usage limit check
+ const usage = await prisma.usage.findUnique({ where: { userId } });
+ if (usage && usage.currentMonthInputTokens >= usage.monthlyLimitInputTokens) {
+ return Response.json(
+ { error: "Monthly AI usage limit reached." },
+ { status: 429 },
+ );
+ }
+
// Resolve input to clean text
let cleanedText: string;
const isUrl = URL_RE.test(input.trim());
@@ -115,6 +124,9 @@ export async function POST(req: Request) {
{ status: 422 },
);
}
+ } else if (/<[a-z][\s\S]*>/i.test(input)) {
+ // Input contains HTML (e.g. from the Chrome extension) — convert to markdown
+ cleanedText = td.turndown(input);
} else {
cleanedText = input;
}
@@ -123,13 +135,37 @@ export async function POST(req: Request) {
try {
const response = await openrouter.chat.completions.create({
model: models.extract,
- max_tokens: 250,
+ max_tokens: 500,
messages: [
{ role: "system", content: EXTRACTION_SYSTEM_PROMPT },
- { role: "user", content: cleanedText.slice(0, 12000) },
+ { role: "user", content: cleanedText.slice(0, 24000) },
],
});
+ const inputTokens = response.usage?.prompt_tokens ?? 0;
+ const outputTokens = response.usage?.completion_tokens ?? 0;
+
+ if (inputTokens > 0) {
+ await prisma.usage.upsert({
+ where: { userId },
+ create: {
+ userId,
+ totalInputTokens: inputTokens,
+ totalOutputTokens: outputTokens,
+ currentMonthInputTokens: inputTokens,
+ currentMonthOutputTokens: outputTokens,
+ analysisCallCount: 1,
+ },
+ update: {
+ totalInputTokens: { increment: inputTokens },
+ totalOutputTokens: { increment: outputTokens },
+ currentMonthInputTokens: { increment: inputTokens },
+ currentMonthOutputTokens: { increment: outputTokens },
+ analysisCallCount: { increment: 1 },
+ },
+ });
+ }
+
const text = response.choices[0]?.message?.content ?? "";
const result = parseAiResponse(text);
diff --git a/src/app/api/jobs/import/route.ts b/src/app/api/jobs/import/route.ts
index 8b18c9f..163f93e 100644
--- a/src/app/api/jobs/import/route.ts
+++ b/src/app/api/jobs/import/route.ts
@@ -3,7 +3,7 @@ import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { env } from "@/env";
import { importJobSchema } from "@/lib/validations";
-import type { LocationType, JobType } from "@prisma/client";
+import type { LocationType, JobType, ScraperSource } from "@prisma/client";
const URL_RE = /^https?:\/\//i;
@@ -35,22 +35,25 @@ export async function POST(req: Request) {
salaryMax,
currency,
skills,
+ source,
+ externalId: clientExternalId,
} = parsed.data;
const profile = await prisma.profile.findFirst({ where: { id: profileId, userId } });
if (!profile) return new Response("Profile not found", { status: 404 });
- // Determine a stable externalId for deduplication
+ // Determine a stable externalId for deduplication.
+ // Prefer client-supplied externalId (e.g. from Chrome extension scraping a known source),
+ // then fall back to URL-based or random ID for manual imports.
const isUrl = URL_RE.test(originalInput.trim());
- const externalId = isUrl
- ? originalInput.trim()
- : (url?.trim() || crypto.randomUUID());
+ const externalId = clientExternalId
+ ?? (isUrl ? originalInput.trim() : (url?.trim() || crypto.randomUUID()));
try {
const poolEntry = await prisma.jobPool.upsert({
- where: { source_externalId: { source: "CUSTOM", externalId } },
+ where: { source_externalId: { source: source as ScraperSource, externalId } },
create: {
- source: "CUSTOM",
+ source: source as ScraperSource,
externalId,
url: url || "",
title,
@@ -69,14 +72,25 @@ export async function POST(req: Request) {
update: {},
});
- const job = await prisma.job.upsert({
- where: { profileId_jobPoolId: { profileId, jobPoolId: poolEntry.id } },
- create: { profileId, jobPoolId: poolEntry.id, feedStatus: "NEW" },
- update: {},
+ // Check if this job already exists for this profile
+ const existing = await prisma.job.findUnique({
+ where: { profileId_jobPoolId: { profileId, jobPoolId: poolEntry.id } },
+ select: { id: true },
+ });
+
+ if (existing) {
+ return Response.json(
+ { error: "You've already imported this job listing.", job: { id: existing.id } },
+ { status: 409 },
+ );
+ }
+
+ const job = await prisma.job.create({
+ data: { profileId, jobPoolId: poolEntry.id, feedStatus: "NEW" },
include: { jobPool: true, application: { select: { status: true } } },
});
- // Fire analysis — fire-and-forget, reuse same host-based pattern as requestAnalysis
+ // Fire analysis — fire-and-forget
const h = await headers();
const host = h.get("host") ?? "";
const proto = host.startsWith("localhost") || host.startsWith("127.") ? "http" : "https";
diff --git a/src/lib/validations.ts b/src/lib/validations.ts
index 841e786..33a0c0e 100644
--- a/src/lib/validations.ts
+++ b/src/lib/validations.ts
@@ -137,6 +137,12 @@ export const importJobSchema = z.object({
salaryMax: z.number().int().positive().nullish(),
currency: z.string().max(10).nullish(),
skills: z.array(z.string()).optional(),
+ source: z.enum([
+ "LINKEDIN", "GREENHOUSE", "LEVER", "ASHBY", "USAJOBS", "ADZUNA",
+ "ARBEITNOW", "INDEED", "BERLIN_STARTUP_JOBS", "HONEYPOT", "YC_JOBS",
+ "NO_FLUFF_JOBS", "CUSTOM",
+ ]).default("CUSTOM"),
+ externalId: z.string().optional(),
});
// ── Custom job field update ────────────────────────────────────────────────
@@ -180,7 +186,6 @@ export const feedbackSchema = z.object({
export const deleteAccountSchema = z.object({
confirmation: z.literal("DELETE"),
});
-
// ── Custom model settings ────────────────────────────────────────────────
export const updateModelSettingsSchema = z.object({
profileId: z.string().cuid(),
diff --git a/src/middleware.ts b/src/middleware.ts
index 159955b..39d0cfd 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,5 +1,6 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
const isPublicRoute = createRouteMatcher([
"/",
@@ -15,15 +16,45 @@ const isPublicRoute = createRouteMatcher([
"/disabled",
]);
+const EXTENSION_CORS_PATHS = ["/api/extension/", "/api/jobs/"];
+
+function needsExtensionCors(req: NextRequest): string | null {
+ const origin = req.headers.get("origin");
+ if (!origin?.startsWith("chrome-extension://")) return null;
+ const path = req.nextUrl.pathname;
+ if (EXTENSION_CORS_PATHS.some((p) => path.startsWith(p))) return origin;
+ return null;
+}
+
+function withCorsHeaders(response: NextResponse, origin: string): NextResponse {
+ response.headers.set("Access-Control-Allow-Origin", origin);
+ response.headers.set("Access-Control-Allow-Credentials", "true");
+ response.headers.set("Access-Control-Allow-Headers", "Content-Type");
+ response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ return response;
+}
+
export default clerkMiddleware(async (auth, req) => {
if (process.env.NODE_ENV === "development") {
console.log(`[middleware] ${req.method} ${req.nextUrl.pathname}`);
}
+ const corsOrigin = needsExtensionCors(req);
+
+ if (corsOrigin && req.method === "OPTIONS") {
+ return withCorsHeaders(new NextResponse(null, { status: 204 }), corsOrigin);
+ }
+
if (isPublicRoute(req)) return NextResponse.next();
const { userId } = await auth();
if (!userId) {
+ if (corsOrigin) {
+ return withCorsHeaders(
+ NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
+ corsOrigin,
+ );
+ }
if (process.env.NODE_ENV === "development") {
console.log(`[middleware] Unauthenticated — redirecting to sign-in from: ${req.nextUrl.pathname}`);
}
@@ -76,7 +107,7 @@ export default clerkMiddleware(async (auth, req) => {
console.log(`[middleware] userId: ${userId}, onboarded: ${!!onboarded}, isOnboardingRoute: ${isOnboardingRoute}, path: ${req.nextUrl.pathname}`);
}
- if (!onboarded && !isOnboardingRoute) {
+ if (!onboarded && !isOnboardingRoute && !corsOrigin) {
// Cookie is missing — check DB to avoid trapping users on new devices.
// Prisma cannot run in Edge Runtime, so we call a thin internal API route.
try {
@@ -128,6 +159,7 @@ export default clerkMiddleware(async (auth, req) => {
headers: { cookie: req.headers.get("cookie") ?? "" },
}).catch(() => {});
}
+ if (corsOrigin) withCorsHeaders(response, corsOrigin);
return response;
});
diff --git a/tsconfig.json b/tsconfig.json
index ea6ad01..74ae27f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -38,6 +38,7 @@
"**/*.mts"
],
"exclude": [
- "node_modules"
+ "node_modules",
+ "extensions"
]
}