diff --git a/.env.example b/.env.example
index 5ae395c..71c4fee 100644
--- a/.env.example
+++ b/.env.example
@@ -22,6 +22,7 @@ MODEL=google/gemini-3-flash-preview
# Path is absolute or relative to the orchestrator process cwd (often `orchestrator/` when using `npm run dev` there).
# Takes precedence over Settings → local path. PDF export still uses RxResume when enabled.
# Example (monorepo): hand-authored v5 JSON may live under `data/resumes/` (that folder is gitignored by default).
+# If you use seeded search profiles with `resumeLocalPath` + login auto-activate, leave this unset so Settings → local path wins.
# JOBOPS_LOCAL_RESUME_PATH=../data/resumes/ilia-dobkin.json
# RXResume credentials for PDF generation
@@ -32,6 +33,14 @@ RXRESUME_PASSWORD=your_password_here
# Optional: Basic Auth for write access
# the app is fully unauthenticated if this isn't set, which is the default
# When set, all write actions (POST/PATCH/DELETE) require Basic Auth.
+# Optional second user (e.g. paired with a second search profile / `basicAuthUser` in profile JSON):
+# BASIC_AUTH_USER_2=
+# BASIC_AUTH_PASSWORD_2=
+# Example local pairing with DB-seeded profiles (change passwords before exposing the UI):
+# BASIC_AUTH_USER=ilia
+# BASIC_AUTH_PASSWORD=changeme-ilia
+# BASIC_AUTH_USER_2=cherepaha
+# BASIC_AUTH_PASSWORD_2=changeme-cherepaha
BASIC_AUTH_USER=
BASIC_AUTH_PASSWORD=
diff --git a/orchestrator/src/client/App.test.tsx b/orchestrator/src/client/App.test.tsx
index 682891d..e27bdef 100644
--- a/orchestrator/src/client/App.test.tsx
+++ b/orchestrator/src/client/App.test.tsx
@@ -1,7 +1,7 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { useDemoInfo } from "./hooks/useDemoInfo";
@@ -58,13 +58,29 @@ vi.mock("./pages/VisaSponsorsPage", () => ({
VisaSponsorsPage: () => null,
}));
+const originalFetch = globalThis.fetch;
+
describe("App demo banner", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- localStorage.clear();
+ afterAll(() => {
+ globalThis.fetch = originalFetch;
});
- it("shows a waitlist link in demo mode", () => {
+ beforeEach(() => {
+ vi.mocked(useDemoInfo).mockClear();
+ localStorage.clear();
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : String(input);
+ if (url.includes("/api/auth/basic-status")) {
+ return new Response(
+ JSON.stringify({ ok: true, data: { basicAuthEnabled: false } }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ return new Response("not found", { status: 404 });
+ });
+ });
+
+ it("shows a waitlist link in demo mode", async () => {
vi.mocked(useDemoInfo).mockReturnValue({
demoMode: true,
resetCadenceHours: 6,
@@ -80,14 +96,14 @@ describe("App demo banner", () => {
,
);
- const link = screen.getByRole("link", { name: "try.jobops.app" });
+ const link = await screen.findByRole("link", { name: "try.jobops.app" });
expect(link).toHaveAttribute(
"href",
"https://try.jobops.app?utm_source=demo&utm_medium=banner&utm_campaign=waitlist",
);
});
- it("does not render the demo banner waitlist link when demo mode is disabled", () => {
+ it("does not render the demo banner waitlist link when demo mode is disabled", async () => {
vi.mocked(useDemoInfo).mockReturnValue({
demoMode: false,
resetCadenceHours: 6,
@@ -103,10 +119,12 @@ describe("App demo banner", () => {
,
);
- expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull();
+ await waitFor(() => {
+ expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull();
+ });
});
- it("lets the user dismiss the waitlist banner and keeps it hidden", () => {
+ it("lets the user dismiss the waitlist banner and keeps it hidden", async () => {
vi.mocked(useDemoInfo).mockReturnValue({
demoMode: true,
resetCadenceHours: 6,
@@ -123,7 +141,9 @@ describe("App demo banner", () => {
);
fireEvent.click(
- screen.getByRole("button", { name: /dismiss demo waitlist banner/i }),
+ await screen.findByRole("button", {
+ name: /dismiss demo waitlist banner/i,
+ }),
);
expect(screen.queryByRole("link", { name: "try.jobops.app" })).toBeNull();
diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx
index a2f22c5..db13c70 100644
--- a/orchestrator/src/client/App.tsx
+++ b/orchestrator/src/client/App.tsx
@@ -9,6 +9,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/sonner";
+import { BasicAuthAppGate } from "./components/BasicAuthAppGate";
import { BasicAuthPrompt } from "./components/BasicAuthPrompt";
import { OnboardingGate } from "./components/OnboardingGate";
import { useDemoInfo } from "./hooks/useDemoInfo";
@@ -66,96 +67,104 @@ export const App: React.FC = () => {
return (
<>
-
- {demoInfo?.demoMode && !demoWaitlistBannerDismissed && (
-
-
+ )}
+ {demoInfo?.demoMode && (
+
+ Demo mode: integrations are simulated and data resets every{" "}
+ {demoInfo.resetCadenceHours} hours.
+
+ )}
+
+
+
+
+
+ {/* Backwards-compatibility redirects */}
+ {REDIRECTS.map(({ from, to }) => (
+ }
+ />
+ ))}
-
+ {/* Application routes */}
+ } />
+ }
+ />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ }
+ />
+
+
+
+
+
+
+
+
>
);
};
diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts
index bf326e7..4829538 100644
--- a/orchestrator/src/client/api/client.ts
+++ b/orchestrator/src/client/api/client.ts
@@ -121,12 +121,28 @@ export function setBasicAuthPromptHandler(
export function clearBasicAuthCredentials(): void {
cachedBasicAuthCredentials = null;
+ clearBasicAuthSessionStorage();
+}
+
+/** Clears stored Basic Auth and reloads so you can sign in as a different user (separate job data). */
+export function signOutBasicAuthAndReload(): void {
+ clearBasicAuthCredentials();
+ window.location.reload();
+}
+
+export function setCachedBasicAuthCredentials(
+ credentials: BasicAuthCredentials | null,
+): void {
+ cachedBasicAuthCredentials = credentials;
+ if (credentials) persistBasicAuthToSessionStorage(credentials);
+ else clearBasicAuthSessionStorage();
}
export function __resetApiClientAuthForTests(): void {
basicAuthPromptHandler = null;
basicAuthPromptInFlight = null;
cachedBasicAuthCredentials = null;
+ clearBasicAuthSessionStorage();
}
function normalizeApiResponse
(
@@ -172,10 +188,63 @@ function describeAction(endpoint: string, method?: string): string {
return "This action ran in demo simulation mode.";
}
-function encodeBasicAuth(credentials: BasicAuthCredentials): string {
+export function encodeBasicAuthHeaderValue(
+ credentials: BasicAuthCredentials,
+): string {
return `Basic ${btoa(`${credentials.username}:${credentials.password}`)}`;
}
+const BASIC_AUTH_SESSION_STORAGE_KEY = "jobops_basic_auth_v1";
+
+/** Read persisted credentials without updating the in-memory API client cache. */
+export function peekBasicAuthSessionCredentials(): BasicAuthCredentials | null {
+ return readBasicAuthFromSessionStorage();
+}
+
+/** In-memory session (after prompt) or persisted session (after reload). */
+export function getActiveBasicAuthCredentials(): BasicAuthCredentials | null {
+ if (cachedBasicAuthCredentials) return cachedBasicAuthCredentials;
+ return readBasicAuthFromSessionStorage();
+}
+
+function readBasicAuthFromSessionStorage(): BasicAuthCredentials | null {
+ try {
+ const raw = sessionStorage.getItem(BASIC_AUTH_SESSION_STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object") return null;
+ const rec = parsed as Record;
+ const username =
+ typeof rec.username === "string" ? rec.username.trim() : "";
+ const password = typeof rec.password === "string" ? rec.password : "";
+ if (!username || !password) return null;
+ return { username, password };
+ } catch {
+ return null;
+ }
+}
+
+function persistBasicAuthToSessionStorage(
+ credentials: BasicAuthCredentials,
+): void {
+ try {
+ sessionStorage.setItem(
+ BASIC_AUTH_SESSION_STORAGE_KEY,
+ JSON.stringify(credentials),
+ );
+ } catch {
+ // Ignore quota / private mode.
+ }
+}
+
+function clearBasicAuthSessionStorage(): void {
+ try {
+ sessionStorage.removeItem(BASIC_AUTH_SESSION_STORAGE_KEY);
+ } catch {
+ // Ignore.
+ }
+}
+
function normalizeHeaders(headers?: HeadersInit): Record {
if (!headers) return {};
if (headers instanceof Headers) {
@@ -248,6 +317,152 @@ async function requestBasicAuthCredentials(
return basicAuthPromptInFlight;
}
+/** Opens the Basic Auth dialog (when mounted) without waiting for a write 401 first. */
+export async function promptBasicAuthDialog(
+ request?: Partial>,
+): Promise {
+ return requestBasicAuthCredentials({
+ endpoint: request?.endpoint ?? "/auth/verify",
+ method: request?.method ?? "POST",
+ attempt: 1,
+ usernameHint: request?.usernameHint,
+ errorMessage: request?.errorMessage,
+ });
+}
+
+export type BasicAuthBasicStatusResponse = {
+ basicAuthEnabled: boolean;
+};
+
+export async function getBasicAuthBasicStatus(): Promise {
+ const response = await fetch(`${API_BASE}/auth/basic-status`);
+ const text = await response.text();
+ let payload: unknown;
+ try {
+ payload = JSON.parse(text);
+ } catch {
+ throw new ApiClientError(
+ `Server error (${response.status}): Expected JSON.`,
+ { status: response.status },
+ );
+ }
+ const parsed = normalizeApiResponse(payload);
+ if ("ok" in parsed && parsed.ok) {
+ return parsed.data as BasicAuthBasicStatusResponse;
+ }
+ throw new ApiClientError("Failed to read Basic Auth status", {
+ status: response.status,
+ });
+}
+
+async function verifyBasicAuthCredentials(
+ credentials: BasicAuthCredentials,
+): Promise {
+ const response = await fetch(`${API_BASE}/auth/verify`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: encodeBasicAuthHeaderValue(credentials),
+ },
+ body: "{}",
+ });
+ if (response.status === 401) return false;
+ const text = await response.text();
+ try {
+ const payload = JSON.parse(text) as { ok?: boolean };
+ return payload.ok === true;
+ } catch {
+ return false;
+ }
+}
+
+export async function activateSearchProfileForBasicAuthUser(
+ username: string,
+ credentials: BasicAuthCredentials,
+): Promise {
+ const auth = encodeBasicAuthHeaderValue(credentials);
+ const listResponse = await fetch(`${API_BASE}/profiles`);
+ const listText = await listResponse.text();
+ let listPayload: {
+ ok?: boolean;
+ data?: Array<{ id: string; data: { basicAuthUser?: string | null } }>;
+ };
+ try {
+ listPayload = JSON.parse(listText) as typeof listPayload;
+ } catch {
+ return;
+ }
+ if (!listPayload.ok || !Array.isArray(listPayload.data)) return;
+ const match = listPayload.data.find(
+ (row) => (row.data?.basicAuthUser ?? "").trim() === username.trim(),
+ );
+ if (!match) return;
+ await fetch(`${API_BASE}/profiles/${match.id}/activate`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: auth,
+ },
+ body: "{}",
+ });
+}
+
+/**
+ * When Basic Auth is enabled: restore session credentials, verify, optionally activate
+ * the search profile tied to `basicAuthUser`. Returns true when no interactive prompt is needed.
+ */
+export async function tryBootstrapBasicAuthFromSessionOnly(
+ preloaded?: BasicAuthBasicStatusResponse,
+): Promise {
+ const status = preloaded ?? (await getBasicAuthBasicStatus());
+ if (!status.basicAuthEnabled) return true;
+
+ const fromSession = readBasicAuthFromSessionStorage();
+ if (!fromSession) return false;
+
+ const ok = await verifyBasicAuthCredentials(fromSession);
+ if (!ok) {
+ clearBasicAuthSessionStorage();
+ return false;
+ }
+ setCachedBasicAuthCredentials(fromSession);
+ await activateSearchProfileForBasicAuthUser(
+ fromSession.username,
+ fromSession,
+ );
+ return true;
+}
+
+/** Prompt, verify, persist credentials, and activate matching search profile. */
+export async function bootstrapBasicAuthWithPromptOnly(): Promise {
+ const credentials = await promptBasicAuthDialog();
+ if (!credentials) return;
+ const verified = await verifyBasicAuthCredentials(credentials);
+ if (!verified) {
+ const retry = await promptBasicAuthDialog({
+ errorMessage: "Invalid credentials. Please try again.",
+ });
+ if (!retry) return;
+ const ok2 = await verifyBasicAuthCredentials(retry);
+ if (!ok2) return;
+ setCachedBasicAuthCredentials(retry);
+ await activateSearchProfileForBasicAuthUser(retry.username, retry);
+ return;
+ }
+ setCachedBasicAuthCredentials(credentials);
+ await activateSearchProfileForBasicAuthUser(
+ credentials.username,
+ credentials,
+ );
+}
+
+export async function bootstrapBasicAuthOnLoad(
+ preloaded?: BasicAuthBasicStatusResponse,
+): Promise {
+ if (await tryBootstrapBasicAuthFromSessionOnly(preloaded)) return;
+ await bootstrapBasicAuthWithPromptOnly();
+}
+
async function fetchAndParse(
endpoint: string,
options: RequestInit | undefined,
@@ -288,7 +503,7 @@ async function fetchApi(
): Promise {
const method = (options?.method || "GET").toUpperCase();
let authHeader = cachedBasicAuthCredentials
- ? encodeBasicAuth(cachedBasicAuthCredentials)
+ ? encodeBasicAuthHeaderValue(cachedBasicAuthCredentials)
: undefined;
let authAttempt = 0;
let usernameHint = cachedBasicAuthCredentials?.username;
@@ -319,9 +534,9 @@ async function fetchApi(
if (!credentials) {
throw toApiError(response, parsed);
}
- cachedBasicAuthCredentials = credentials;
+ setCachedBasicAuthCredentials(credentials);
usernameHint = credentials.username;
- authHeader = encodeBasicAuth(credentials);
+ authHeader = encodeBasicAuthHeaderValue(credentials);
authAttempt += 1;
continue;
}
@@ -481,7 +696,9 @@ async function streamSseEvents(
"Content-Type": "application/json",
};
if (cachedBasicAuthCredentials) {
- headers.Authorization = encodeBasicAuth(cachedBasicAuthCredentials);
+ headers.Authorization = encodeBasicAuthHeaderValue(
+ cachedBasicAuthCredentials,
+ );
}
const response = await fetch(`${API_BASE}${endpoint}`, {
diff --git a/orchestrator/src/client/components/BasicAuthAppGate.tsx b/orchestrator/src/client/components/BasicAuthAppGate.tsx
new file mode 100644
index 0000000..46a4108
--- /dev/null
+++ b/orchestrator/src/client/components/BasicAuthAppGate.tsx
@@ -0,0 +1,78 @@
+import * as api from "@client/api/client";
+import { Loader2 } from "lucide-react";
+import React from "react";
+
+type Phase = "loading" | "prompt" | "ready";
+
+/**
+ * Delays mounting children until Basic Auth is satisfied (or disabled), so hooks do not
+ * fire unauthenticated writes first. The login dialog is shown during the `prompt` phase.
+ */
+export const BasicAuthAppGate: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const [phase, setPhase] = React.useState("loading");
+
+ React.useEffect(() => {
+ let cancelled = false;
+
+ void (async () => {
+ try {
+ const status = await api.getBasicAuthBasicStatus();
+ if (cancelled) return;
+ if (!status.basicAuthEnabled) {
+ setPhase("ready");
+ return;
+ }
+
+ const restored = await api.tryBootstrapBasicAuthFromSessionOnly(status);
+ if (cancelled) return;
+ if (restored) {
+ setPhase("ready");
+ return;
+ }
+
+ setPhase("prompt");
+ await api.bootstrapBasicAuthWithPromptOnly();
+ if (cancelled) return;
+ setPhase("ready");
+ } catch {
+ if (!cancelled) setPhase("ready");
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (phase === "loading") {
+ return (
+
+
+
+ Checking authentication…
+
+
+ );
+ }
+
+ if (phase === "prompt") {
+ return (
+
+
Sign in
+
Use the dialog above to enter your username and password.
+
+ After you sign in, open the menu (☰) → Account → "Sign out /
+ switch user" to log in as someone else. Job lists are separate
+ per login.
+
+
+ );
+ }
+
+ return <>{children}>;
+};
diff --git a/orchestrator/src/client/components/ManualImportFlow.tsx b/orchestrator/src/client/components/ManualImportFlow.tsx
index 21d573e..aa1f534 100644
--- a/orchestrator/src/client/components/ManualImportFlow.tsx
+++ b/orchestrator/src/client/components/ManualImportFlow.tsx
@@ -1,4 +1,8 @@
import * as api from "@client/api";
+import {
+ MANUAL_IMPORT_SAMPLE_SDET_JD,
+ MANUAL_IMPORT_SAMPLE_SENIOR_QA_JD,
+} from "@client/lib/manual-import-samples";
import type { ManualJobDraft } from "@shared/types.js";
import {
ArrowLeft,
@@ -331,6 +335,36 @@ export const ManualImportFlow: React.FC = ({
placeholder="Paste the full job description here, or enter a URL above to fetch it..."
className="min-h-[200px] font-mono text-sm leading-relaxed"
/>
+
+ {
+ setRawDescription(MANUAL_IMPORT_SAMPLE_SDET_JD);
+ setError(null);
+ }}
+ >
+ Sample JD — SDET / automation
+
+ {
+ setRawDescription(MANUAL_IMPORT_SAMPLE_SENIOR_QA_JD);
+ setError(null);
+ }}
+ >
+ Sample JD — Senior QA / Guidewire
+
+
+
+ Synthetic postings for quick manual-import testing (Ilia vs
+ Cherepaha-style roles).
+
{error && (
diff --git a/orchestrator/src/client/components/layout.tsx b/orchestrator/src/client/components/layout.tsx
index 4ddc8cb..663dd99 100644
--- a/orchestrator/src/client/components/layout.tsx
+++ b/orchestrator/src/client/components/layout.tsx
@@ -2,13 +2,15 @@
* Shared layout components for consistent page structure.
*/
-import { ExternalLink, type LucideIcon, Menu } from "lucide-react";
+import { signOutBasicAuthAndReload } from "@client/api/client";
+import { ExternalLink, LogOut, type LucideIcon, Menu } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
@@ -23,6 +25,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
+import { useBasicAuthNavSession } from "../hooks/useBasicAuthNavSession";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { isNavActive, NAV_LINKS } from "./navigation";
import { StatusBadgeIndicator } from "./StatusIndicator";
@@ -60,6 +63,7 @@ export const PageHeader: React.FC = ({
const navOpen = controlledNavOpen ?? internalNavOpen;
const setNavOpen = onNavOpenChange ?? setInternalNavOpen;
const { version, updateAvailable } = useVersionCheck();
+ const basicAuthSession = useBasicAuthNavSession();
const handleNavClick = (to: string, activePaths?: string[]) => {
if (isNavActive(location.pathname, to, activePaths)) {
@@ -103,6 +107,45 @@ export const PageHeader: React.FC = ({
))}
+ {basicAuthSession.kind === "active" && (
+ <>
+
+
+
+ Account
+
+
+ Signed in as{" "}
+
+ {basicAuthSession.username ?? "…"}
+
+ . Jobs and pipeline runs for this deployment are{" "}
+
+ only visible to this login
+
+ — sign out to use another user (e.g.{" "}
+ ilia vs{" "}
+
+ cherepaha
+
+ ).
+
+
{
+ setNavOpen(false);
+ signOutBasicAuthAndReload();
+ }}
+ >
+
+ Sign out / switch user
+
+
+ >
+ )}
{showVersionFooter && (
diff --git a/orchestrator/src/client/hooks/useBasicAuthNavSession.ts b/orchestrator/src/client/hooks/useBasicAuthNavSession.ts
new file mode 100644
index 0000000..14e9f84
--- /dev/null
+++ b/orchestrator/src/client/hooks/useBasicAuthNavSession.ts
@@ -0,0 +1,38 @@
+import {
+ getBasicAuthBasicStatus,
+ peekBasicAuthSessionCredentials,
+} from "@client/api/client";
+import { useEffect, useState } from "react";
+
+export type BasicAuthNavSession =
+ | { kind: "inactive" }
+ | { kind: "active"; username: string | null };
+
+/** When Basic Auth is on, exposes the username from session storage for nav UI. */
+export function useBasicAuthNavSession(): BasicAuthNavSession {
+ const [state, setState] = useState({ kind: "inactive" });
+
+ useEffect(() => {
+ let cancelled = false;
+ const run = async () => {
+ try {
+ const status = await getBasicAuthBasicStatus();
+ if (cancelled) return;
+ if (!status.basicAuthEnabled) {
+ setState({ kind: "inactive" });
+ return;
+ }
+ const creds = peekBasicAuthSessionCredentials();
+ setState({ kind: "active", username: creds?.username ?? null });
+ } catch {
+ if (!cancelled) setState({ kind: "inactive" });
+ }
+ };
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return state;
+}
diff --git a/orchestrator/src/client/lib/manual-import-samples.ts b/orchestrator/src/client/lib/manual-import-samples.ts
new file mode 100644
index 0000000..c97ed78
--- /dev/null
+++ b/orchestrator/src/client/lib/manual-import-samples.ts
@@ -0,0 +1,38 @@
+/**
+ * Paste-ready job descriptions for local manual-import testing (Run → Manual).
+ * Titles/employers are synthetic; bodies are representative of each persona.
+ */
+
+export const MANUAL_IMPORT_SAMPLE_SDET_JD = `Software Development Engineer in Test (SDET)
+Employer: Northwind Labs (sample)
+Location: Remote (Canada)
+Type: Full-time
+
+We are hiring an SDET to own Playwright and API test automation for a regulated web platform. You will design end-to-end suites, stabilize flaky tests, and integrate runs into GitHub Actions. Strong TypeScript, contract testing with OpenAPI, and CI/CD ownership are required.
+
+Responsibilities:
+- Build and maintain Playwright and REST API tests; review failures with developers.
+- Improve test data and environment readiness; cut feedback time for releases.
+- Partner with QA and platform on observability and performance smoke checks.
+
+Requirements:
+- 5+ years test automation; TypeScript or JavaScript at production depth.
+- Experience with CI (GitHub Actions or similar), Docker, and relational DB assertions.
+`;
+
+export const MANUAL_IMPORT_SAMPLE_SENIOR_QA_JD = `Senior QA Analyst — Guidewire & integrations
+Employer: Lakeshore Mutual (sample)
+Location: Toronto, ON (hybrid)
+Type: Contract
+
+Seeking a senior QA analyst to lead SIT/UAT and regression for Guidewire PolicyCenter and BillingCenter releases on AWS, plus Salesforce integration validation. Heavy API testing (Postman/SoapUI), SQL/Oracle data checks, and AODA/WCAG 2.0 AA accessibility sign-off.
+
+Responsibilities:
+- Author test strategies, traceability, and defect triage with BAs and vendors.
+- Execute REST/SOAP API and ETL validation; support Oracle migration cutovers.
+- Run accessibility checks with NVDA/Axe; document evidence for audit.
+
+Requirements:
+- 8+ years QA in insurance or large enterprise; Guidewire PC/BC/CC exposure strongly preferred.
+- Azure DevOps or Jira for test management; Jenkins or similar for automation hooks.
+`;
diff --git a/orchestrator/src/client/lib/queryKeys.ts b/orchestrator/src/client/lib/queryKeys.ts
index 2d3f678..4accc0b 100644
--- a/orchestrator/src/client/lib/queryKeys.ts
+++ b/orchestrator/src/client/lib/queryKeys.ts
@@ -1,6 +1,10 @@
import type { JobStatus, PostApplicationProvider } from "@shared/types";
export const queryKeys = {
+ searchProfiles: {
+ all: ["search-profiles"] as const,
+ list: () => [...queryKeys.searchProfiles.all, "list"] as const,
+ },
settings: {
all: ["settings"] as const,
current: () => [...queryKeys.settings.all, "current"] as const,
diff --git a/orchestrator/src/client/lib/sse.ts b/orchestrator/src/client/lib/sse.ts
index 27b50c9..98233e7 100644
--- a/orchestrator/src/client/lib/sse.ts
+++ b/orchestrator/src/client/lib/sse.ts
@@ -1,13 +1,106 @@
+import {
+ encodeBasicAuthHeaderValue,
+ getActiveBasicAuthCredentials,
+} from "@client/api/client";
+
interface EventSourceSubscriptionHandlers {
onOpen?: () => void;
onMessage: (payload: T) => void;
onError?: () => void;
}
+function buildAuthHeaders(): Record | null {
+ const creds = getActiveBasicAuthCredentials();
+ if (!creds) return null;
+ return { Authorization: encodeBasicAuthHeaderValue(creds) };
+}
+
+/**
+ * Consume `data: …` frames from an SSE byte stream (RFC 8895-style `data:` lines).
+ * Returns unconsumed tail after the last complete `\n\n` delimiter.
+ */
+function consumeSseText(
+ buffer: string,
+ onJsonLine: (line: string) => void,
+): string {
+ let rest = buffer;
+ while (true) {
+ const sep = rest.indexOf("\n\n");
+ if (sep === -1) return rest;
+ const frame = rest.slice(0, sep);
+ rest = rest.slice(sep + 2);
+ const trimmed = frame.trim();
+ if (!trimmed || trimmed.startsWith(":")) continue;
+ const dataLines = frame
+ .split("\n")
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => line.replace(/^data:\s?/, "").trim())
+ .filter(Boolean);
+ if (dataLines.length === 0) continue;
+ onJsonLine(dataLines.join("\n"));
+ }
+}
+
+function subscribeViaFetch(
+ url: string,
+ handlers: EventSourceSubscriptionHandlers,
+ authHeaders: Record,
+): () => void {
+ const abort = new AbortController();
+ let cancelled = false;
+
+ const run = async () => {
+ try {
+ const response = await fetch(url, {
+ headers: {
+ Accept: "text/event-stream",
+ ...authHeaders,
+ },
+ signal: abort.signal,
+ credentials: "same-origin",
+ });
+ if (!response.ok || !response.body) {
+ handlers.onError?.();
+ return;
+ }
+ handlers.onOpen?.();
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ while (!cancelled) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ buffer = consumeSseText(buffer, (jsonLine) => {
+ try {
+ handlers.onMessage(JSON.parse(jsonLine) as T);
+ } catch {
+ // Ignore malformed frames
+ }
+ });
+ }
+ } catch {
+ if (!cancelled) handlers.onError?.();
+ }
+ };
+
+ void run();
+
+ return () => {
+ cancelled = true;
+ abort.abort();
+ };
+}
+
export function subscribeToEventSource(
url: string,
handlers: EventSourceSubscriptionHandlers,
): () => void {
+ const authHeaders = buildAuthHeaders();
+ if (authHeaders) {
+ return subscribeViaFetch(url, handlers, authHeaders);
+ }
+
const eventSource = new EventSource(url);
eventSource.onopen = () => {
diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
index e68ae00..6cafe61 100644
--- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx
+++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
@@ -178,6 +178,10 @@ vi.mock("./orchestrator/OrchestratorSummary", () => ({
OrchestratorSummary: () =>
,
}));
+vi.mock("./orchestrator/ProfileQuickSwitch", () => ({
+ ProfileQuickSwitch: () => null,
+}));
+
vi.mock("./orchestrator/JobCommandBar", () => ({
JobCommandBar: ({
onSelectJob,
diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx
index 7581ddf..68dc3d0 100644
--- a/orchestrator/src/client/pages/OrchestratorPage.tsx
+++ b/orchestrator/src/client/pages/OrchestratorPage.tsx
@@ -15,6 +15,7 @@ import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
+import { ProfileQuickSwitch } from "./orchestrator/ProfileQuickSwitch";
import { RunModeModal } from "./orchestrator/RunModeModal";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useJobSelectionActions } from "./orchestrator/useJobSelectionActions";
@@ -470,6 +471,8 @@ export const OrchestratorPage: React.FC = () => {
isPipelineRunning={isPipelineRunning}
/>
+
+
{/* Main content: tabs/filters -> list/detail */}
{
isLoading={isLoading}
jobs={jobs}
activeJobs={activeJobs}
+ unfilteredTabCount={counts[activeTab]}
+ onResetFilters={resetFilters}
selectedJobId={selectedJobId}
selectedJobIds={selectedJobIds}
activeTab={activeTab}
diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx
index 3b896e2..a1d7d02 100644
--- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx
+++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx
@@ -457,4 +457,34 @@ describe("AutomaticRunTab", () => {
);
});
});
+
+ it("enables automatic run when search terms are empty but profile has target roles", () => {
+ const base = createAppSettings();
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Start run now" }),
+ ).not.toBeDisabled();
+ expect(screen.getByText("Platform engineer")).toBeInTheDocument();
+ });
});
diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
index 75ecd11..8cd1592 100644
--- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
+++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx
@@ -36,6 +36,7 @@ import {
type AutomaticRunValues,
calculateAutomaticEstimate,
loadAutomaticRunMemory,
+ mergeDiscoverySearchTerms,
normalizeWorkplaceTypes,
parseCityLocationsInput,
parseCityLocationsSetting,
@@ -229,6 +230,21 @@ export const AutomaticRunTab: React.FC = ({
settings?.workplaceTypes?.value,
);
+ const savedSearchTerms =
+ settings?.searchTerms?.value
+ ?.map((t) => t.trim())
+ .filter((t): t is string => Boolean(t)) ?? [];
+ const profileTargetRoles =
+ settings?.jobSearchProfile?.value?.targetRoles
+ ?.map((t) => t.trim())
+ .filter((t): t is string => Boolean(t)) ?? [];
+ const initialSearchTerms =
+ savedSearchTerms.length > 0
+ ? savedSearchTerms
+ : profileTargetRoles.length > 0
+ ? profileTargetRoles
+ : DEFAULT_VALUES.searchTerms;
+
reset({
topN: String(topN),
minSuitabilityScore: String(minSuitabilityScore),
@@ -237,7 +253,7 @@ export const AutomaticRunTab: React.FC = ({
cityLocations: rememberedLocations,
cityLocationDraft: "",
workplaceTypes: rememberedWorkplaceTypes,
- searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms,
+ searchTerms: initialSearchTerms,
searchTermDraft: "",
});
setAdvancedOpen(false);
@@ -319,13 +335,22 @@ export const AutomaticRunTab: React.FC = ({
pipelineSources,
]);
+ const mergedDiscoveryTerms = useMemo(
+ () =>
+ mergeDiscoverySearchTerms(
+ values.searchTerms,
+ settings?.jobSearchProfile?.value?.targetRoles,
+ ),
+ [values.searchTerms, settings?.jobSearchProfile?.value?.targetRoles],
+ );
+
const estimate = useMemo(
() =>
calculateAutomaticEstimate({
- values,
+ values: { ...values, searchTerms: mergedDiscoveryTerms },
sources: compatiblePipelineSources,
}),
- [values, compatiblePipelineSources],
+ [values, mergedDiscoveryTerms, compatiblePipelineSources],
);
const activePreset = useMemo(
@@ -337,7 +362,7 @@ export const AutomaticRunTab: React.FC = ({
isPipelineRunning ||
isSaving ||
compatiblePipelineSources.length === 0 ||
- values.searchTerms.length === 0 ||
+ mergedDiscoveryTerms.length === 0 ||
workplaceTypeSelectionInvalid;
const toggleWorkplaceType = (
diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
index 42c31f5..24bd631 100644
--- a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
+++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
@@ -10,6 +10,7 @@ describe("JobListPanel", () => {
isLoading
jobs={[]}
activeJobs={[]}
+ unfilteredTabCount={0}
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
@@ -28,6 +29,7 @@ describe("JobListPanel", () => {
isLoading={false}
jobs={[]}
activeJobs={[]}
+ unfilteredTabCount={0}
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
@@ -43,6 +45,33 @@ describe("JobListPanel", () => {
).toBeInTheDocument();
});
+ it("explains when filters hide jobs that exist in the tab", () => {
+ const onReset = vi.fn();
+ const jobs = [
+ createJob({ id: "job-1", title: "Backend Engineer" }),
+ createJob({ id: "job-2", title: "Other" }),
+ ];
+ render(
+ ,
+ );
+
+ expect(screen.getByText("No jobs match your filters")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
+ expect(onReset).toHaveBeenCalledTimes(1);
+ });
+
it("renders jobs and notifies when a job is selected", () => {
const onSelectJob = vi.fn();
const onToggleSelectJob = vi.fn();
@@ -61,6 +90,7 @@ describe("JobListPanel", () => {
isLoading={false}
jobs={jobs}
activeJobs={jobs}
+ unfilteredTabCount={2}
selectedJobId="job-1"
selectedJobIds={new Set()}
activeTab="ready"
@@ -91,6 +121,7 @@ describe("JobListPanel", () => {
isLoading={false}
jobs={jobs}
activeJobs={jobs}
+ unfilteredTabCount={2}
selectedJobId="job-1"
selectedJobIds={new Set(["job-1"])}
activeTab="ready"
@@ -114,6 +145,7 @@ describe("JobListPanel", () => {
isLoading={false}
jobs={jobs}
activeJobs={jobs}
+ unfilteredTabCount={1}
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
@@ -132,6 +164,7 @@ describe("JobListPanel", () => {
isLoading={false}
jobs={jobs}
activeJobs={jobs}
+ unfilteredTabCount={1}
selectedJobId="job-1"
selectedJobIds={new Set()}
activeTab="ready"
@@ -150,6 +183,7 @@ describe("JobListPanel", () => {
isLoading={false}
jobs={jobs}
activeJobs={jobs}
+ unfilteredTabCount={1}
selectedJobId={null}
selectedJobIds={new Set(["job-1"])}
activeTab="ready"
diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx
index a7b4621..489f6f1 100644
--- a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx
+++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx
@@ -1,6 +1,7 @@
import type { JobListItem } from "@shared/types.js";
import { Loader2 } from "lucide-react";
import type React from "react";
+import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { FilterTab } from "./constants";
@@ -11,6 +12,9 @@ interface JobListPanelProps {
isLoading: boolean;
jobs: JobListItem[];
activeJobs: JobListItem[];
+ /** Jobs in this tab before list filters (search, sources, etc.). */
+ unfilteredTabCount: number;
+ onResetFilters?: () => void;
selectedJobId: string | null;
selectedJobIds: Set;
activeTab: FilterTab;
@@ -23,6 +27,8 @@ export const JobListPanel: React.FC = ({
isLoading,
jobs,
activeJobs,
+ unfilteredTabCount,
+ onResetFilters,
selectedJobId,
selectedJobIds,
activeTab,
@@ -37,11 +43,27 @@ export const JobListPanel: React.FC = ({
Loading jobs...
) : activeJobs.length === 0 ? (
-
-
No jobs found
+
+
+ {unfilteredTabCount > 0
+ ? "No jobs match your filters"
+ : "No jobs found"}
+
- {emptyStateCopy[activeTab]}
+ {unfilteredTabCount > 0
+ ? `There ${unfilteredTabCount === 1 ? "is" : "are"} ${unfilteredTabCount.toLocaleString()} job${unfilteredTabCount === 1 ? "" : "s"} in this tab before filters. Clear filters or adjust search.`
+ : emptyStateCopy[activeTab]}
+ {unfilteredTabCount > 0 && onResetFilters ? (
+
+ Clear filters
+
+ ) : null}
) : (
diff --git a/orchestrator/src/client/pages/orchestrator/ProfileQuickSwitch.tsx b/orchestrator/src/client/pages/orchestrator/ProfileQuickSwitch.tsx
new file mode 100644
index 0000000..2157322
--- /dev/null
+++ b/orchestrator/src/client/pages/orchestrator/ProfileQuickSwitch.tsx
@@ -0,0 +1,111 @@
+import * as api from "@client/api";
+import { useSettings } from "@client/hooks/useSettings";
+import { queryKeys } from "@client/lib/queryKeys";
+import type { SearchProfile } from "@shared/types";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import type React from "react";
+import { useMemo } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+/**
+ * Search profile strip on the Jobs page: quick link to Settings, or inline
+ * switch when you have more than one profile for this login.
+ */
+export const ProfileQuickSwitch: React.FC = () => {
+ const queryClient = useQueryClient();
+ const { settings, refreshSettings } = useSettings();
+
+ const { data: profiles = [], isLoading } = useQuery
({
+ queryKey: queryKeys.searchProfiles.list(),
+ queryFn: api.listProfiles,
+ });
+
+ const activeId = useMemo(() => {
+ const sid = settings?.activeProfileId?.trim() ?? "";
+ if (sid && profiles.some((p) => p.id === sid)) return sid;
+ return profiles[0]?.id ?? "";
+ }, [settings?.activeProfileId, profiles]);
+
+ const activateMutation = useMutation({
+ mutationFn: (id: string) => api.activateProfile(id),
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: queryKeys.jobs.all }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.profile.all }),
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.searchProfiles.all,
+ }),
+ ]);
+ await refreshSettings();
+ toast.success("Search profile updated");
+ },
+ onError: (err: unknown) => {
+ const message =
+ err instanceof Error ? err.message : "Failed to activate profile";
+ toast.error(message);
+ },
+ });
+
+ if (isLoading && profiles.length === 0) {
+ return (
+
+
+ Profiles…
+
+ );
+ }
+
+ if (profiles.length === 0) return null;
+
+ if (profiles.length === 1) {
+ const p = profiles[0] as SearchProfile;
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Search profile
+
+
{
+ if (!id || id === activeId) return;
+ activateMutation.mutate(id);
+ }}
+ disabled={activateMutation.isPending}
+ >
+
+
+
+
+ {profiles.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+ Open Settings
+
+
+ );
+};
diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts
index 6ab2723..0a98220 100644
--- a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts
+++ b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts
@@ -3,6 +3,7 @@ import {
AUTOMATIC_PRESETS,
calculateAutomaticEstimate,
deriveExtractorLimits,
+ mergeDiscoverySearchTerms,
parseSearchTermsInput,
} from "./automatic-run";
@@ -92,6 +93,19 @@ describe("automatic-run utilities", () => {
]);
});
+ it("merges profile target roles after saved terms without duplicates", () => {
+ expect(
+ mergeDiscoverySearchTerms(
+ ["Backend Engineer", " "],
+ ["SRE", "backend engineer", " Platform"],
+ ),
+ ).toEqual(["Backend Engineer", "SRE", "Platform"]);
+ });
+
+ it("treats empty saved terms as eligible for profile-only roles", () => {
+ expect(mergeDiscoverySearchTerms([], ["Rust", " "])).toEqual(["Rust"]);
+ });
+
it("includes adzuna in estimate caps", () => {
const estimate = calculateAutomaticEstimate({
values: {
diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.ts
index 9a8519a..cb10738 100644
--- a/orchestrator/src/client/pages/orchestrator/automatic-run.ts
+++ b/orchestrator/src/client/pages/orchestrator/automatic-run.ts
@@ -4,6 +4,29 @@ import {
} from "@shared/search-cities.js";
import type { JobSource } from "@shared/types";
+/**
+ * Same merge order as server discovery (`discover-jobs`): explicit search terms
+ * first, then profile target roles that are not already present (case-insensitive).
+ */
+export function mergeDiscoverySearchTerms(
+ savedTerms: string[],
+ profileTargetRoles: string[] | null | undefined,
+): string[] {
+ const normalized = savedTerms.map((t) => t.trim()).filter(Boolean);
+ const existingLower = new Set(normalized.map((t) => t.toLowerCase()));
+ const out = [...normalized];
+ for (const role of profileTargetRoles ?? []) {
+ if (typeof role !== "string") continue;
+ const trimmed = role.trim();
+ if (!trimmed) continue;
+ const key = trimmed.toLowerCase();
+ if (existingLower.has(key)) continue;
+ out.push(trimmed);
+ existingLower.add(key);
+ }
+ return out;
+}
+
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
export type WorkplaceType = "remote" | "hybrid" | "onsite";
export const WORKPLACE_TYPE_OPTIONS: WorkplaceType[] = [
diff --git a/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts
index 16d7ec1..38a409d 100644
--- a/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts
+++ b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts
@@ -11,6 +11,7 @@ import { trackProductEvent } from "@/lib/analytics";
import type { AutomaticRunValues } from "./automatic-run";
import {
deriveExtractorLimits,
+ mergeDiscoverySearchTerms,
serializeCityLocationsSetting,
} from "./automatic-run";
import type { RunMode } from "./run-mode";
@@ -57,7 +58,7 @@ export function usePipelineControls(
const [runMode, setRunMode] = useState("automatic");
const [isCancelling, setIsCancelling] = useState(false);
- const { refreshSettings } = useSettings();
+ const { settings, refreshSettings } = useSettings();
useEffect(() => {
if (!pipelineTerminalEvent) return;
@@ -168,9 +169,20 @@ export function usePipelineControls(
return;
}
+ const mergedSearchTerms = mergeDiscoverySearchTerms(
+ values.searchTerms,
+ settings?.jobSearchProfile?.value?.targetRoles,
+ );
+ if (mergedSearchTerms.length === 0) {
+ toast.error(
+ "Add at least one search term, or set target roles on your job search profile.",
+ );
+ return;
+ }
+
const limits = deriveExtractorLimits({
budget: values.runBudget,
- searchTerms: values.searchTerms,
+ searchTerms: mergedSearchTerms,
sources: compatibleSources,
});
const hasJobSpySite = compatibleSources.some(
@@ -210,12 +222,12 @@ export function usePipelineControls(
mode: "automatic",
country: values.country,
hasCityLocations: values.cityLocations.length > 0,
- searchTermsCount: values.searchTerms.length,
+ searchTermsCount: mergedSearchTerms.length,
},
});
setIsRunModeModalOpen(false);
},
- [pipelineSources, refreshSettings, startPipelineRun],
+ [pipelineSources, refreshSettings, settings, startPipelineRun],
);
const handleManualImported = useCallback(
diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx
index 93531a1..1f1f0ff 100644
--- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx
+++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx
@@ -67,5 +67,11 @@ describe("EnvironmentSettingsSection", () => {
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
expect(screen.getByText("Security")).toBeInTheDocument();
expect(screen.queryByText("RxResume")).not.toBeInTheDocument();
+
+ expect(
+ screen.getByRole("button", {
+ name: /sign out \/ switch user/i,
+ }),
+ ).toBeInTheDocument();
});
});
diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx
index 5ad5f5b..559fbb1 100644
--- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx
+++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx
@@ -1,3 +1,4 @@
+import { signOutBasicAuthAndReload } from "@client/api/client";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { EnvSettingsValues } from "@client/pages/settings/types";
import { formatSecretHint } from "@client/pages/settings/utils";
@@ -9,6 +10,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
+import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
@@ -27,10 +29,14 @@ export const EnvironmentSettingsSection: React.FC<
watch,
formState: { errors },
} = useFormContext();
- const { private: privateValues } = values;
+ const { private: privateValues, basicAuthActive } = values;
const isBasicAuthEnabled = watch("enableBasicAuth");
+ const handleSignOutBasicAuth = () => {
+ signOutBasicAuthAndReload();
+ };
+
return (
@@ -151,6 +157,24 @@ export const EnvironmentSettingsSection: React.FC<
/>
)}
+
+ {basicAuthActive && (
+
+
+ Credentials live in session storage. Sign out to switch login;
+ each user only sees jobs stored under their profile.
+
+
+ Sign out / switch user
+
+
+ )}
diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts
index a834c01..8b8544b 100644
--- a/orchestrator/src/server/api/routes.ts
+++ b/orchestrator/src/server/api/routes.ts
@@ -3,6 +3,7 @@
*/
import { Router } from "express";
+import { authRouter } from "./routes/auth";
import { backupRouter } from "./routes/backup";
import { databaseRouter } from "./routes/database";
import { demoRouter } from "./routes/demo";
@@ -22,6 +23,7 @@ import { webhookRouter } from "./routes/webhook";
export const apiRouter = Router();
+apiRouter.use("/auth", authRouter);
apiRouter.use("/jobs", jobsRouter);
apiRouter.use("/jobs/:id/chat", ghostwriterRouter);
apiRouter.use("/demo", demoRouter);
diff --git a/orchestrator/src/server/api/routes/auth.ts b/orchestrator/src/server/api/routes/auth.ts
new file mode 100644
index 0000000..fc5667b
--- /dev/null
+++ b/orchestrator/src/server/api/routes/auth.ts
@@ -0,0 +1,25 @@
+import { asyncRoute, ok } from "@infra/http";
+import { isBasicAuthEnabled } from "@server/infra/basic-auth-credentials";
+import { type Request, type Response, Router } from "express";
+
+export const authRouter = Router();
+
+/**
+ * GET /api/auth/basic-status — public; whether write operations require Basic Auth.
+ */
+authRouter.get(
+ "/basic-status",
+ asyncRoute(async (_req: Request, res: Response) => {
+ return ok(res, { basicAuthEnabled: isBasicAuthEnabled() });
+ }),
+);
+
+/**
+ * POST /api/auth/verify — requires Basic Auth when enabled; confirms credentials.
+ */
+authRouter.post(
+ "/verify",
+ asyncRoute(async (_req: Request, res: Response) => {
+ return ok(res, { valid: true });
+ }),
+);
diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts
index 4d0e113..011bc52 100644
--- a/orchestrator/src/server/api/routes/jobs.ts
+++ b/orchestrator/src/server/api/routes/jobs.ts
@@ -180,8 +180,27 @@ const updateJobSchema = z.object({
});
function isJobUrlConflictError(error: unknown): boolean {
- if (!(error instanceof Error)) return false;
- return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message);
+ let current: unknown = error;
+ for (let depth = 0; depth < 6 && current; depth += 1) {
+ if (current instanceof Error) {
+ const msg = current.message;
+ if (
+ /UNIQUE constraint failed: jobs\.job_url/i.test(msg) ||
+ /UNIQUE constraint failed.*idx_jobs_owner_profile_job_url/i.test(msg) ||
+ /UNIQUE constraint failed:.*owner_profile_id.*job_url/i.test(msg)
+ ) {
+ return true;
+ }
+ }
+ current =
+ typeof current === "object" &&
+ current !== null &&
+ "cause" in current &&
+ (current as { cause?: unknown }).cause !== undefined
+ ? (current as { cause: unknown }).cause
+ : null;
+ }
+ return false;
}
const transitionStageSchema = z.object({
@@ -357,7 +376,11 @@ async function executeJobActionForJob(
});
}
- const updated = await jobsRepo.updateJob(jobId, { status: "skipped" });
+ const updated = await jobsRepo.updateJob(
+ jobId,
+ { status: "skipped" },
+ job.ownerProfileId,
+ );
if (!updated) {
throw new AppError({
status: 404,
@@ -451,11 +474,15 @@ async function executeJobActionForJob(
profile,
);
- const updated = await jobsRepo.updateJob(job.id, {
- suitabilityScore: score,
- suitabilityReason: reason,
- suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
- });
+ const updated = await jobsRepo.updateJob(
+ job.id,
+ {
+ suitabilityScore: score,
+ suitabilityReason: reason,
+ suitabilityAnalysis: analysis ? JSON.stringify(analysis) : undefined,
+ },
+ job.ownerProfileId,
+ );
if (!updated) {
throw new AppError({
status: 404,
@@ -503,7 +530,11 @@ async function executeJobActionForJob(
searchProfile,
);
- const updated = await jobsRepo.updateJob(job.id, { coverLetter });
+ const updated = await jobsRepo.updateJob(
+ job.id,
+ { coverLetter },
+ job.ownerProfileId,
+ );
if (!updated) {
throw new AppError({
status: 404,
@@ -1053,10 +1084,18 @@ jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
const closedAt = input.outcome
? (input.closedAt ?? Math.floor(Date.now() / 1000))
: null;
- const job = await jobsRepo.updateJob(req.params.id, {
- outcome: input.outcome,
- closedAt,
- });
+ const existing = await jobsRepo.getJobById(req.params.id);
+ if (!existing) {
+ return fail(res, notFound("Job not found"));
+ }
+ const job = await jobsRepo.updateJob(
+ req.params.id,
+ {
+ outcome: input.outcome,
+ closedAt,
+ },
+ existing.ownerProfileId,
+ );
if (!job) {
return fail(res, notFound("Job not found"));
@@ -1120,7 +1159,11 @@ jobsRouter.patch("/:id", async (req: Request, res: Response) => {
}
}
- const job = await jobsRepo.updateJob(req.params.id, input);
+ const job = await jobsRepo.updateJob(
+ req.params.id,
+ input,
+ currentJob.ownerProfileId,
+ );
if (!job) {
const err = new AppError({
@@ -1235,10 +1278,14 @@ jobsRouter.post("/:id/check-sponsor", async (req: Request, res: Response) => {
visaSponsors.calculateSponsorMatchSummary(sponsorResults);
// Update job with sponsor match info
- const updatedJob = await jobsRepo.updateJob(job.id, {
- sponsorMatchScore: sponsorMatchScore,
- sponsorMatchNames: sponsorMatchNames ?? undefined,
- });
+ const updatedJob = await jobsRepo.updateJob(
+ job.id,
+ {
+ sponsorMatchScore: sponsorMatchScore,
+ sponsorMatchNames: sponsorMatchNames ?? undefined,
+ },
+ job.ownerProfileId,
+ );
res.json({
success: true,
@@ -1317,10 +1364,14 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
null,
);
- const updatedJob = await jobsRepo.updateJob(job.id, {
- status: "applied",
- appliedAt,
- });
+ const updatedJob = await jobsRepo.updateJob(
+ job.id,
+ {
+ status: "applied",
+ appliedAt,
+ },
+ job.ownerProfileId,
+ );
if (updatedJob) {
notifyJobCompleteWebhook(updatedJob).catch((error) => {
diff --git a/orchestrator/src/server/api/routes/pipeline.test.ts b/orchestrator/src/server/api/routes/pipeline.test.ts
index 4b5efbb..41f1468 100644
--- a/orchestrator/src/server/api/routes/pipeline.test.ts
+++ b/orchestrator/src/server/api/routes/pipeline.test.ts
@@ -40,10 +40,13 @@ describe.sequential("Pipeline API routes", () => {
});
const runBody = await runRes.json();
expect(runBody.ok).toBe(true);
- expect(runPipeline).toHaveBeenCalledWith({
- topN: 5,
- sources: ["gradcracker"],
- });
+ expect(runPipeline).toHaveBeenCalledWith(
+ expect.objectContaining({
+ topN: 5,
+ sources: ["gradcracker"],
+ ownerProfileId: "__default__",
+ }),
+ );
const glassdoorRunRes = await fetch(`${baseUrl}/api/pipeline/run`, {
method: "POST",
@@ -52,9 +55,13 @@ describe.sequential("Pipeline API routes", () => {
});
const glassdoorRunBody = await glassdoorRunRes.json();
expect(glassdoorRunBody.ok).toBe(true);
- expect(runPipeline).toHaveBeenNthCalledWith(2, {
- sources: ["glassdoor"],
- });
+ expect(runPipeline).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ sources: ["glassdoor"],
+ ownerProfileId: "__default__",
+ }),
+ );
const adzunaRunRes = await fetch(`${baseUrl}/api/pipeline/run`, {
method: "POST",
@@ -63,9 +70,13 @@ describe.sequential("Pipeline API routes", () => {
});
const adzunaRunBody = await adzunaRunRes.json();
expect(adzunaRunBody.ok).toBe(true);
- expect(runPipeline).toHaveBeenNthCalledWith(3, {
- sources: ["adzuna"],
- });
+ expect(runPipeline).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ sources: ["adzuna"],
+ ownerProfileId: "__default__",
+ }),
+ );
});
it("returns conflict when cancelling with no active pipeline", async () => {
diff --git a/orchestrator/src/server/api/routes/pipeline.ts b/orchestrator/src/server/api/routes/pipeline.ts
index 21ab4cf..24a3b6e 100644
--- a/orchestrator/src/server/api/routes/pipeline.ts
+++ b/orchestrator/src/server/api/routes/pipeline.ts
@@ -7,13 +7,17 @@ import {
} from "@infra/errors";
import { fail, ok, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger";
-import { runWithRequestContext } from "@infra/request-context";
+import {
+ getJobOwnerProfileId,
+ runWithRequestContext,
+} from "@infra/request-context";
import { setupSse, startSseHeartbeat, writeSseData } from "@infra/sse";
import { isDemoMode } from "@server/config/demo";
import {
type ExtractorRegistry,
getExtractorRegistry,
} from "@server/extractors/registry";
+import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import {
getPipelineStatus,
requestPipelineCancel,
@@ -157,9 +161,11 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => {
return okWithMeta(res, simulated, { simulated: true });
}
- // Start pipeline in background
- runWithRequestContext({}, () => {
- runPipeline(config).catch((error) => {
+ // Start pipeline in background (preserve tenant for async pipeline work).
+ const ownerProfileId =
+ getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID;
+ runWithRequestContext({ ownerProfileId }, () => {
+ runPipeline({ ...config, ownerProfileId }).catch((error) => {
logger.error("Background pipeline run failed", error);
});
});
diff --git a/orchestrator/src/server/api/routes/profiles.ts b/orchestrator/src/server/api/routes/profiles.ts
index f421dc0..98d9586 100644
--- a/orchestrator/src/server/api/routes/profiles.ts
+++ b/orchestrator/src/server/api/routes/profiles.ts
@@ -1,19 +1,47 @@
-import { badRequest } from "@infra/errors";
+import { badRequest, forbidden } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http";
import { logger } from "@infra/logger";
+import {
+ isBasicAuthEnabled,
+ parseBasicAuthUsername,
+} from "@server/infra/basic-auth-credentials";
import * as profilesRepo from "@server/repositories/profiles";
import { setSetting } from "@server/repositories/settings";
-import { getProfile } from "@server/services/profile";
+import { clearProfileCache, getProfile } from "@server/services/profile";
import { generateProfileFromResume } from "@server/services/profile-generator";
import { jobSearchProfileSchema } from "@shared/settings-registry";
+import type { SearchProfile } from "@shared/types";
import { type Request, type Response, Router } from "express";
export const profilesRouter = Router();
+function profileMatchesBasicAuthUser(
+ profile: SearchProfile,
+ username: string | null,
+): boolean {
+ if (!username?.trim()) return false;
+ const u = username.trim().toLowerCase();
+ const bu = (profile.data.basicAuthUser ?? "").trim().toLowerCase();
+ return Boolean(bu) && bu === u;
+}
+
+function assertProfileVisibleToRequest(
+ req: Request,
+ profile: SearchProfile,
+): boolean {
+ if (!isBasicAuthEnabled()) return true;
+ const username = parseBasicAuthUsername(req.headers.authorization);
+ return profileMatchesBasicAuthUser(profile, username);
+}
+
profilesRouter.get(
"/",
- asyncRoute(async (_req: Request, res: Response) => {
- const profiles = await profilesRepo.listProfiles();
+ asyncRoute(async (req: Request, res: Response) => {
+ const username = parseBasicAuthUsername(req.headers.authorization);
+ const profiles =
+ isBasicAuthEnabled() && username?.trim()
+ ? await profilesRepo.listProfilesForBasicAuthUser(username)
+ : await profilesRepo.listProfiles();
return ok(res, profiles);
}),
);
@@ -25,6 +53,9 @@ profilesRouter.get(
if (!profile) {
return fail(res, badRequest("Profile not found"));
}
+ if (!assertProfileVisibleToRequest(req, profile)) {
+ return fail(res, forbidden("You cannot access this profile"));
+ }
return ok(res, profile);
}),
);
@@ -42,9 +73,14 @@ profilesRouter.post(
issues: parsed.error.issues,
});
}
+ const username = parseBasicAuthUsername(req.headers.authorization)?.trim();
+ const dataWithOwner =
+ isBasicAuthEnabled() && username
+ ? { ...parsed.data, basicAuthUser: username }
+ : parsed.data;
const profile = await profilesRepo.createProfile({
name: name.trim(),
- data: parsed.data,
+ data: dataWithOwner,
});
return ok(res, profile);
}),
@@ -53,6 +89,13 @@ profilesRouter.post(
profilesRouter.patch(
"/:id",
asyncRoute(async (req: Request, res: Response) => {
+ const existing = await profilesRepo.getProfileById(req.params.id);
+ if (!existing) {
+ return fail(res, badRequest("Profile not found"));
+ }
+ if (!assertProfileVisibleToRequest(req, existing)) {
+ return fail(res, forbidden("You cannot update this profile"));
+ }
const { name, data } = req.body;
const updates: { name?: string; data?: typeof data } = {};
if (name !== undefined) {
@@ -68,7 +111,18 @@ profilesRouter.patch(
issues: parsed.error.issues,
});
}
- updates.data = parsed.data;
+ const username = parseBasicAuthUsername(
+ req.headers.authorization,
+ )?.trim();
+ if (isBasicAuthEnabled() && username) {
+ const locked = (existing.data.basicAuthUser ?? "").trim();
+ if (locked && locked.toLowerCase() !== username.toLowerCase()) {
+ return fail(res, forbidden("Cannot reassign basicAuthUser"));
+ }
+ updates.data = { ...parsed.data, basicAuthUser: username };
+ } else {
+ updates.data = parsed.data;
+ }
}
const profile = await profilesRepo.updateProfile(req.params.id, updates);
if (!profile) {
@@ -81,6 +135,13 @@ profilesRouter.patch(
profilesRouter.delete(
"/:id",
asyncRoute(async (req: Request, res: Response) => {
+ const existing = await profilesRepo.getProfileById(req.params.id);
+ if (!existing) {
+ return fail(res, badRequest("Profile not found"));
+ }
+ if (!assertProfileVisibleToRequest(req, existing)) {
+ return fail(res, forbidden("You cannot delete this profile"));
+ }
const deleted = await profilesRepo.deleteProfile(req.params.id);
if (!deleted) {
return fail(res, badRequest("Profile not found"));
@@ -96,8 +157,23 @@ profilesRouter.post(
if (!profile) {
return fail(res, badRequest("Profile not found"));
}
+ if (!assertProfileVisibleToRequest(req, profile)) {
+ return fail(res, forbidden("You cannot activate this profile"));
+ }
+ const basicUser = parseBasicAuthUsername(req.headers.authorization)?.trim();
+
+ if (isBasicAuthEnabled() && basicUser) {
+ clearProfileCache();
+ return ok(res, { activated: true, profileId: profile.id });
+ }
+
+ const resumePath = profile.data.resumeLocalPath?.trim();
await setSetting("activeProfileId", profile.id);
await setSetting("jobSearchProfile", JSON.stringify(profile.data));
+ if (resumePath) {
+ await setSetting("localResumeProfilePath", resumePath);
+ clearProfileCache();
+ }
return ok(res, { activated: true, profileId: profile.id });
}),
);
diff --git a/orchestrator/src/server/api/routes/stats-proxy.test.ts b/orchestrator/src/server/api/routes/stats-proxy.test.ts
index b5bc9cf..75b5f20 100644
--- a/orchestrator/src/server/api/routes/stats-proxy.test.ts
+++ b/orchestrator/src/server/api/routes/stats-proxy.test.ts
@@ -175,7 +175,7 @@ describe.sequential("Stats proxy routes", () => {
await stopServer({ server, closeDb, tempDir });
({ server, baseUrl, closeDb, tempDir } = await startServer({
env: {
- BASIC_AUTH_USER: "admin",
+ BASIC_AUTH_USER: "ilia",
BASIC_AUTH_PASSWORD: "secret",
},
}));
diff --git a/orchestrator/src/server/api/routes/tracer-links.test.ts b/orchestrator/src/server/api/routes/tracer-links.test.ts
index 80dd164..62792a9 100644
--- a/orchestrator/src/server/api/routes/tracer-links.test.ts
+++ b/orchestrator/src/server/api/routes/tracer-links.test.ts
@@ -227,7 +227,8 @@ describe.sequential("Tracer links routes", () => {
await stopServer({ server, closeDb, tempDir });
({ server, baseUrl, closeDb, tempDir } = await startServer({
env: {
- BASIC_AUTH_USER: "admin",
+ // Must match a seeded search profile `basicAuthUser` (see db/migrate.ts).
+ BASIC_AUTH_USER: "ilia",
BASIC_AUTH_PASSWORD: "secret",
},
}));
@@ -235,7 +236,7 @@ describe.sequential("Tracer links routes", () => {
const unauthorized = await fetch(`${baseUrl}/api/tracer-links/analytics`);
expect(unauthorized.status).toBe(401);
- const credentials = Buffer.from("admin:secret").toString("base64");
+ const credentials = Buffer.from("ilia:secret").toString("base64");
const authorized = await fetch(`${baseUrl}/api/tracer-links/analytics`, {
headers: {
Authorization: `Basic ${credentials}`,
diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts
index 3b1b7f1..f457575 100644
--- a/orchestrator/src/server/app.ts
+++ b/orchestrator/src/server/app.ts
@@ -19,6 +19,12 @@ import {
} from "@infra/http";
import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
+import {
+ basicAuthMatchesDecodedUserPass,
+ isBasicAuthEnabled,
+ parseBasicAuthCredentials,
+} from "@server/infra/basic-auth-credentials";
+import { jobOwnerContextMiddleware } from "@server/infra/job-owner-context";
import cors from "cors";
import express from "express";
import { apiRouter } from "./api/index";
@@ -132,33 +138,21 @@ function buildUmamiProxyHeaders(req: express.Request): Headers {
}
export function createBasicAuthGuard() {
- function getAuthConfig() {
- const user = process.env.BASIC_AUTH_USER || "";
- const pass = process.env.BASIC_AUTH_PASSWORD || "";
- return {
- user,
- pass,
- enabled: user.length > 0 && pass.length > 0,
- };
+ function isAuthorized(req: express.Request): boolean {
+ if (!isBasicAuthEnabled()) return false;
+ const parsed = parseBasicAuthCredentials(req.headers.authorization);
+ if (!parsed) return false;
+ return basicAuthMatchesDecodedUserPass(parsed.user, parsed.pass);
}
- function isAuthorized(req: express.Request): boolean {
- const { user: authUser, pass: authPass, enabled } = getAuthConfig();
- if (!enabled) return false;
- const authHeader = req.headers.authorization || "";
- if (!authHeader.startsWith("Basic ")) return false;
- const encoded = authHeader.slice("Basic ".length).trim();
- let decoded = "";
- try {
- decoded = Buffer.from(encoded, "base64").toString("utf-8");
- } catch {
- return false;
- }
- const separatorIndex = decoded.indexOf(":");
- if (separatorIndex === -1) return false;
- const user = decoded.slice(0, separatorIndex);
- const pass = decoded.slice(separatorIndex + 1);
- return user === authUser && pass === authPass;
+ function isPublicApiGet(path: string): boolean {
+ const normalizedPath = path.split("?")[0] || path;
+ if (normalizedPath === "/api/auth/basic-status") return true;
+ if (normalizedPath === "/api/demo/info") return true;
+ if (normalizedPath === "/api/visa-sponsors/status") return true;
+ if (normalizedPath === "/api/profile/status") return true;
+ if (normalizedPath === "/api/pipeline/status") return true;
+ return false;
}
function isPublicReadOnlyRoute(method: string, path: string): boolean {
@@ -175,10 +169,26 @@ export function createBasicAuthGuard() {
function requiresAuth(method: string, path: string): boolean {
if (isPublicReadOnlyRoute(method, path)) return false;
if (isStatsRoute(path)) return false;
+ const normalizedPath = path.split("?")[0] || path;
+ if (
+ method.toUpperCase() === "GET" &&
+ normalizedPath === "/api/auth/basic-status"
+ ) {
+ return false;
+ }
if (path.startsWith("/api/tracer-links")) {
return method.toUpperCase() !== "OPTIONS";
}
- return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
+ const m = method.toUpperCase();
+ if (
+ isBasicAuthEnabled() &&
+ path.startsWith("/api/") &&
+ (m === "GET" || m === "HEAD")
+ ) {
+ if (isPublicApiGet(path)) return false;
+ return true;
+ }
+ return !["GET", "HEAD", "OPTIONS"].includes(m);
}
const middleware = (
@@ -186,8 +196,8 @@ export function createBasicAuthGuard() {
res: express.Response,
next: express.NextFunction,
) => {
- const { enabled } = getAuthConfig();
- if (!enabled || !requiresAuth(req.method, req.path)) return next();
+ if (!isBasicAuthEnabled() || !requiresAuth(req.method, req.path))
+ return next();
if (isAuthorized(req)) return next();
fail(res, unauthorized("Authentication required"));
};
@@ -195,7 +205,7 @@ export function createBasicAuthGuard() {
return {
middleware,
isAuthorized,
- basicAuthEnabled: getAuthConfig().enabled,
+ basicAuthEnabled: isBasicAuthEnabled(),
};
}
@@ -277,6 +287,7 @@ export function createApp() {
// Optional Basic Auth for write access (read-only by default)
app.use(authGuard.middleware);
+ app.use(jobOwnerContextMiddleware());
// API routes
app.use("/api", apiRouter);
diff --git a/orchestrator/src/server/basic-auth.test.ts b/orchestrator/src/server/basic-auth.test.ts
index 1d9740f..de01e75 100644
--- a/orchestrator/src/server/basic-auth.test.ts
+++ b/orchestrator/src/server/basic-auth.test.ts
@@ -114,9 +114,32 @@ describe.sequential("Basic Auth read-only enforcement", () => {
expect(res.status).not.toHaveBeenCalled();
});
+ it("allows writes when the second Basic Auth user is configured", () => {
+ process.env.BASIC_AUTH_USER = "user1";
+ process.env.BASIC_AUTH_PASSWORD = "pass1";
+ process.env.BASIC_AUTH_USER_2 = "user2";
+ process.env.BASIC_AUTH_PASSWORD_2 = "pass2";
+
+ const { middleware } = createBasicAuthGuard();
+ const req = createMockRequest({
+ method: "POST",
+ path: "/api/jobs/actions",
+ authorization: buildAuthHeader("user2", "pass2"),
+ });
+ const res = createMockResponse();
+ const next = vi.fn() as NextFunction;
+
+ middleware(req, res, next);
+
+ expect(next).toHaveBeenCalledOnce();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
it("does not require auth when Basic Auth is disabled", () => {
delete process.env.BASIC_AUTH_USER;
delete process.env.BASIC_AUTH_PASSWORD;
+ delete process.env.BASIC_AUTH_USER_2;
+ delete process.env.BASIC_AUTH_PASSWORD_2;
const { middleware } = createBasicAuthGuard();
const req = createMockRequest({
diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts
index 7ffe0e1..e0a8b73 100644
--- a/orchestrator/src/server/db/migrate.ts
+++ b/orchestrator/src/server/db/migrate.ts
@@ -18,6 +18,17 @@ if (!existsSync(dataDir)) {
const sqlite = new Database(DB_PATH);
+function sqlInsertSearchProfileSeed(input: {
+ id: string;
+ name: string;
+ profile: Record;
+}): string {
+ const data = JSON.stringify(input.profile).replace(/'/g, "''");
+ const id = input.id.replace(/'/g, "''");
+ const name = input.name.replace(/'/g, "''");
+ return `INSERT OR IGNORE INTO search_profiles (id, name, data, created_at, updated_at) VALUES ('${id}', '${name}', '${data}', datetime('now'), datetime('now'))`;
+}
+
const migrations = [
`CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
@@ -666,6 +677,183 @@ const migrations = [
ORDER BY se.occurred_at DESC, se.id DESC
LIMIT 1
), 'applied') = 'closed'`,
+
+ // Per-profile job ownership: composite uniqueness on (owner_profile_id, job_url).
+ `ALTER TABLE jobs ADD COLUMN owner_profile_id TEXT`,
+ `UPDATE jobs SET owner_profile_id = '__default__' WHERE owner_profile_id IS NULL OR trim(owner_profile_id) = ''`,
+ `PRAGMA foreign_keys = OFF`,
+ `DROP TABLE IF EXISTS jobs_reowner_v1`,
+ `CREATE TABLE jobs_reowner_v1 (
+ id TEXT PRIMARY KEY,
+ owner_profile_id TEXT NOT NULL DEFAULT '__default__',
+ source TEXT NOT NULL DEFAULT 'gradcracker',
+ source_job_id TEXT,
+ job_url_direct TEXT,
+ date_posted TEXT,
+ job_type TEXT,
+ salary_source TEXT,
+ salary_interval TEXT,
+ salary_min_amount REAL,
+ salary_max_amount REAL,
+ salary_currency TEXT,
+ is_remote INTEGER,
+ job_level TEXT,
+ job_function TEXT,
+ listing_type TEXT,
+ emails TEXT,
+ company_industry TEXT,
+ company_logo TEXT,
+ company_url_direct TEXT,
+ company_addresses TEXT,
+ company_num_employees TEXT,
+ company_revenue TEXT,
+ company_description TEXT,
+ skills TEXT,
+ experience_range TEXT,
+ company_rating REAL,
+ company_reviews_count INTEGER,
+ vacancy_count INTEGER,
+ work_from_home_type TEXT,
+ title TEXT NOT NULL,
+ employer TEXT NOT NULL,
+ employer_url TEXT,
+ job_url TEXT NOT NULL,
+ application_link TEXT,
+ disciplines TEXT,
+ deadline TEXT,
+ salary TEXT,
+ location TEXT,
+ degree_required TEXT,
+ starting TEXT,
+ job_description TEXT,
+ status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'in_progress', 'skipped', 'expired')),
+ outcome TEXT,
+ closed_at INTEGER,
+ suitability_score REAL,
+ suitability_reason TEXT,
+ suitability_analysis TEXT,
+ cover_letter TEXT,
+ tailored_summary TEXT,
+ tailored_headline TEXT,
+ tailored_skills TEXT,
+ selected_project_ids TEXT,
+ pdf_path TEXT,
+ tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
+ sponsor_match_score REAL,
+ sponsor_match_names TEXT,
+ notes TEXT,
+ discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
+ processed_at TEXT,
+ applied_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(owner_profile_id, job_url)
+ )`,
+ `INSERT OR REPLACE INTO jobs_reowner_v1 (
+ id, owner_profile_id, source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
+ salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
+ emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
+ company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
+ vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
+ deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
+ suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
+ selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
+ applied_at, created_at, updated_at
+ )
+ SELECT
+ id,
+ coalesce(nullif(trim(owner_profile_id), ''), '__default__'),
+ source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
+ salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
+ emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
+ company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
+ vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
+ deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
+ suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
+ selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
+ applied_at, created_at, updated_at
+ FROM jobs`,
+ `DROP TABLE jobs`,
+ `ALTER TABLE jobs_reowner_v1 RENAME TO jobs`,
+ `PRAGMA foreign_keys = ON`,
+ `CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
+ `CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_jobs_status_discovered_at ON jobs(status, discovered_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_jobs_owner_profile_id ON jobs(owner_profile_id)`,
+
+ // Seed default job-search personas (INSERT OR IGNORE — safe on existing DBs).
+ sqlInsertSearchProfileSeed({
+ id: "685b0000-0000-4000-8000-000000000001",
+ name: "Ilia Dobkin",
+ profile: {
+ targetRoles: [
+ "Software Development Engineer in Test",
+ "QA Automation Engineer",
+ "SDET",
+ ],
+ experienceLevel: "Senior",
+ mustHaveSkills: [
+ "Playwright",
+ "Cypress",
+ "Selenium",
+ "TypeScript",
+ "API testing",
+ ],
+ niceToHaveSkills: ["Python", ".NET", "CI/CD"],
+ dealBreakers: [],
+ preferredWorkArrangement: ["remote", "hybrid"],
+ preferredLocations: ["Toronto", "Ontario", "Canada"],
+ minimumSalary: "",
+ industriesToTarget: ["Software", "FinTech", "iGaming"],
+ industriesToAvoid: [],
+ aboutMe:
+ "Ilia Dobkin — SDET / test automation; paired login user `ilia` activates this profile and local resume ilia-dobkin.json (unset JOBOPS_LOCAL_RESUME_PATH so Settings path applies).",
+ basicAuthUser: "ilia",
+ resumeLocalPath: "../data/resumes/ilia-dobkin.json",
+ },
+ }),
+ sqlInsertSearchProfileSeed({
+ id: "685b0000-0000-4000-8000-000000000002",
+ name: "Iliya Cherepaha",
+ profile: {
+ targetRoles: [
+ "Senior QA Analyst",
+ "QA Analyst",
+ "Guidewire Tester",
+ "Test Analyst",
+ "Manual QA Tester",
+ "UAT Tester",
+ "Quality Assurance Specialist",
+ "Accessibility Tester",
+ "Integration Tester",
+ ],
+ experienceLevel: "Senior",
+ mustHaveSkills: [
+ "Guidewire",
+ "API testing",
+ "Selenium",
+ "SQL",
+ "Accessibility",
+ "UAT",
+ ],
+ niceToHaveSkills: ["JMeter", "Azure DevOps", "Postman"],
+ dealBreakers: [],
+ preferredWorkArrangement: ["remote", "hybrid", "onsite"],
+ preferredLocations: ["Toronto", "Ontario", "GTA", "Canada"],
+ minimumSalary: "",
+ industriesToTarget: [
+ "Insurance",
+ "Banking",
+ "Public sector",
+ "Enterprise software",
+ ],
+ industriesToAvoid: [],
+ aboutMe:
+ "Iliya Cherepaha — Senior QA (Guidewire, OPS, accessibility); paired login user `cherepaha` activates this profile and local resume cherepaha.json (unset JOBOPS_LOCAL_RESUME_PATH so Settings path applies).",
+ basicAuthUser: "cherepaha",
+ resumeLocalPath: "../data/resumes/cherepaha.json",
+ },
+ }),
];
console.log("🔧 Running database migrations...");
diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts
index 693944d..adbc35d 100644
--- a/orchestrator/src/server/db/schema.ts
+++ b/orchestrator/src/server/db/schema.ts
@@ -28,92 +28,106 @@ import {
uniqueIndex,
} from "drizzle-orm/sqlite-core";
-export const jobs = sqliteTable("jobs", {
- id: text("id").primaryKey(),
+export const jobs = sqliteTable(
+ "jobs",
+ {
+ id: text("id").primaryKey(),
- // From crawler
- source: text("source").notNull().default("gradcracker"),
- sourceJobId: text("source_job_id"),
- jobUrlDirect: text("job_url_direct"),
- datePosted: text("date_posted"),
- title: text("title").notNull(),
- employer: text("employer").notNull(),
- employerUrl: text("employer_url"),
- jobUrl: text("job_url").notNull().unique(),
- applicationLink: text("application_link"),
- disciplines: text("disciplines"),
- deadline: text("deadline"),
- salary: text("salary"),
- location: text("location"),
- degreeRequired: text("degree_required"),
- starting: text("starting"),
- jobDescription: text("job_description"),
+ /** Search profile that owns this row (multi-tenant). */
+ ownerProfileId: text("owner_profile_id").notNull().default("__default__"),
- // JobSpy fields (nullable for other sources)
- jobType: text("job_type"),
- salarySource: text("salary_source"),
- salaryInterval: text("salary_interval"),
- salaryMinAmount: real("salary_min_amount"),
- salaryMaxAmount: real("salary_max_amount"),
- salaryCurrency: text("salary_currency"),
- isRemote: integer("is_remote", { mode: "boolean" }),
- jobLevel: text("job_level"),
- jobFunction: text("job_function"),
- listingType: text("listing_type"),
- emails: text("emails"),
- companyIndustry: text("company_industry"),
- companyLogo: text("company_logo"),
- companyUrlDirect: text("company_url_direct"),
- companyAddresses: text("company_addresses"),
- companyNumEmployees: text("company_num_employees"),
- companyRevenue: text("company_revenue"),
- companyDescription: text("company_description"),
- skills: text("skills"),
- experienceRange: text("experience_range"),
- companyRating: real("company_rating"),
- companyReviewsCount: integer("company_reviews_count"),
- vacancyCount: integer("vacancy_count"),
- workFromHomeType: text("work_from_home_type"),
+ // From crawler
+ source: text("source").notNull().default("gradcracker"),
+ sourceJobId: text("source_job_id"),
+ jobUrlDirect: text("job_url_direct"),
+ datePosted: text("date_posted"),
+ title: text("title").notNull(),
+ employer: text("employer").notNull(),
+ employerUrl: text("employer_url"),
+ jobUrl: text("job_url").notNull(),
+ applicationLink: text("application_link"),
+ disciplines: text("disciplines"),
+ deadline: text("deadline"),
+ salary: text("salary"),
+ location: text("location"),
+ degreeRequired: text("degree_required"),
+ starting: text("starting"),
+ jobDescription: text("job_description"),
- // Orchestrator enrichments
- status: text("status", {
- enum: [
- "discovered",
- "processing",
- "ready",
- "applied",
- "in_progress",
- "skipped",
- "expired",
- ],
- })
- .notNull()
- .default("discovered"),
- outcome: text("outcome", { enum: APPLICATION_OUTCOMES }),
- closedAt: integer("closed_at", { mode: "number" }),
- suitabilityScore: real("suitability_score"),
- suitabilityReason: text("suitability_reason"),
- suitabilityAnalysis: text("suitability_analysis"),
- tailoredSummary: text("tailored_summary"),
- tailoredHeadline: text("tailored_headline"),
- tailoredSkills: text("tailored_skills"),
- selectedProjectIds: text("selected_project_ids"),
- pdfPath: text("pdf_path"),
- tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
- .notNull()
- .default(false),
- coverLetter: text("cover_letter"),
- sponsorMatchScore: real("sponsor_match_score"),
- sponsorMatchNames: text("sponsor_match_names"),
- notes: text("notes"),
+ // JobSpy fields (nullable for other sources)
+ jobType: text("job_type"),
+ salarySource: text("salary_source"),
+ salaryInterval: text("salary_interval"),
+ salaryMinAmount: real("salary_min_amount"),
+ salaryMaxAmount: real("salary_max_amount"),
+ salaryCurrency: text("salary_currency"),
+ isRemote: integer("is_remote", { mode: "boolean" }),
+ jobLevel: text("job_level"),
+ jobFunction: text("job_function"),
+ listingType: text("listing_type"),
+ emails: text("emails"),
+ companyIndustry: text("company_industry"),
+ companyLogo: text("company_logo"),
+ companyUrlDirect: text("company_url_direct"),
+ companyAddresses: text("company_addresses"),
+ companyNumEmployees: text("company_num_employees"),
+ companyRevenue: text("company_revenue"),
+ companyDescription: text("company_description"),
+ skills: text("skills"),
+ experienceRange: text("experience_range"),
+ companyRating: real("company_rating"),
+ companyReviewsCount: integer("company_reviews_count"),
+ vacancyCount: integer("vacancy_count"),
+ workFromHomeType: text("work_from_home_type"),
- // Timestamps
- discoveredAt: text("discovered_at").notNull().default(sql`(datetime('now'))`),
- processedAt: text("processed_at"),
- appliedAt: text("applied_at"),
- createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
- updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
-});
+ // Orchestrator enrichments
+ status: text("status", {
+ enum: [
+ "discovered",
+ "processing",
+ "ready",
+ "applied",
+ "in_progress",
+ "skipped",
+ "expired",
+ ],
+ })
+ .notNull()
+ .default("discovered"),
+ outcome: text("outcome", { enum: APPLICATION_OUTCOMES }),
+ closedAt: integer("closed_at", { mode: "number" }),
+ suitabilityScore: real("suitability_score"),
+ suitabilityReason: text("suitability_reason"),
+ suitabilityAnalysis: text("suitability_analysis"),
+ tailoredSummary: text("tailored_summary"),
+ tailoredHeadline: text("tailored_headline"),
+ tailoredSkills: text("tailored_skills"),
+ selectedProjectIds: text("selected_project_ids"),
+ pdfPath: text("pdf_path"),
+ tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
+ .notNull()
+ .default(false),
+ coverLetter: text("cover_letter"),
+ sponsorMatchScore: real("sponsor_match_score"),
+ sponsorMatchNames: text("sponsor_match_names"),
+ notes: text("notes"),
+
+ // Timestamps
+ discoveredAt: text("discovered_at")
+ .notNull()
+ .default(sql`(datetime('now'))`),
+ processedAt: text("processed_at"),
+ appliedAt: text("applied_at"),
+ createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
+ updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
+ },
+ (table) => ({
+ ownerJobUrlUnique: uniqueIndex("idx_jobs_owner_profile_job_url").on(
+ table.ownerProfileId,
+ table.jobUrl,
+ ),
+ }),
+);
export const stageEvents = sqliteTable("stage_events", {
id: text("id").primaryKey(),
diff --git a/orchestrator/src/server/infra/basic-auth-credentials.ts b/orchestrator/src/server/infra/basic-auth-credentials.ts
new file mode 100644
index 0000000..934631f
--- /dev/null
+++ b/orchestrator/src/server/infra/basic-auth-credentials.ts
@@ -0,0 +1,56 @@
+/**
+ * Basic Auth credential pairs from environment (primary + optional second user).
+ */
+
+export type BasicAuthCredentialPair = { user: string; pass: string };
+
+export function getBasicAuthCredentialPairs(): BasicAuthCredentialPair[] {
+ const pairs: BasicAuthCredentialPair[] = [];
+ const u1 = process.env.BASIC_AUTH_USER?.trim() ?? "";
+ const p1 = process.env.BASIC_AUTH_PASSWORD?.trim() ?? "";
+ if (u1 && p1) pairs.push({ user: u1, pass: p1 });
+ const u2 = process.env.BASIC_AUTH_USER_2?.trim() ?? "";
+ const p2 = process.env.BASIC_AUTH_PASSWORD_2?.trim() ?? "";
+ if (u2 && p2) pairs.push({ user: u2, pass: p2 });
+ return pairs;
+}
+
+export function isBasicAuthEnabled(): boolean {
+ return getBasicAuthCredentialPairs().length > 0;
+}
+
+export function basicAuthMatchesDecodedUserPass(
+ user: string,
+ pass: string,
+): boolean {
+ return getBasicAuthCredentialPairs().some(
+ (pair) => pair.user === user && pair.pass === pass,
+ );
+}
+
+/** Returns Basic `user:pass` when the header is valid Base64. */
+export function parseBasicAuthCredentials(
+ authorizationHeader: string | undefined,
+): { user: string; pass: string } | null {
+ const authHeader = authorizationHeader ?? "";
+ if (!authHeader.startsWith("Basic ")) return null;
+ const encoded = authHeader.slice("Basic ".length).trim();
+ try {
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
+ const separatorIndex = decoded.indexOf(":");
+ if (separatorIndex === -1) return null;
+ return {
+ user: decoded.slice(0, separatorIndex),
+ pass: decoded.slice(separatorIndex + 1),
+ };
+ } catch {
+ return null;
+ }
+}
+
+/** Returns the Basic username (without password) when the header is valid Base64 `user:pass`. */
+export function parseBasicAuthUsername(
+ authorizationHeader: string | undefined,
+): string | null {
+ return parseBasicAuthCredentials(authorizationHeader)?.user ?? null;
+}
diff --git a/orchestrator/src/server/infra/job-owner-context.ts b/orchestrator/src/server/infra/job-owner-context.ts
new file mode 100644
index 0000000..3a00069
--- /dev/null
+++ b/orchestrator/src/server/infra/job-owner-context.ts
@@ -0,0 +1,80 @@
+import { forbidden } from "@infra/errors";
+import { fail } from "@infra/http";
+import { logger } from "@infra/logger";
+import { getSearchProfileIdForBasicAuthUser } from "@server/repositories/profiles";
+import type { NextFunction, Request, RequestHandler, Response } from "express";
+import {
+ isBasicAuthEnabled,
+ parseBasicAuthUsername,
+} from "./basic-auth-credentials";
+import { runWithRequestContext } from "./request-context";
+
+export const DEFAULT_JOB_OWNER_PROFILE_ID = "__default__";
+
+/**
+ * Resolves the search profile id for the current request (Basic Auth → profile.basicAuthUser).
+ * When Basic Auth is off, returns {@link DEFAULT_JOB_OWNER_PROFILE_ID}.
+ */
+export async function resolveRequestJobOwnerProfileId(
+ req: Request,
+): Promise {
+ if (!isBasicAuthEnabled()) {
+ return DEFAULT_JOB_OWNER_PROFILE_ID;
+ }
+
+ const username = parseBasicAuthUsername(req.headers.authorization);
+ if (!username?.trim()) {
+ return DEFAULT_JOB_OWNER_PROFILE_ID;
+ }
+
+ const profileId = await getSearchProfileIdForBasicAuthUser(username);
+ if (!profileId) {
+ logger.warn("Basic Auth user has no matching search profile", {
+ username: username.trim(),
+ });
+ return "__unmapped__";
+ }
+ return profileId;
+}
+
+function isAuthBootstrapPath(path: string): boolean {
+ const p = path.split("?")[0] || path;
+ return p === "/api/auth/verify" || p === "/api/auth/basic-status";
+}
+
+/**
+ * When Basic Auth is enabled, require a matching search profile for mutating API calls
+ * (handled in route handlers). This middleware only sets {@link DEFAULT_JOB_OWNER_PROFILE_ID}
+ * or the resolved profile id on the request context for downstream filtering.
+ */
+export function jobOwnerContextMiddleware(): RequestHandler {
+ return (req: Request, res: Response, next: NextFunction) => {
+ if (!req.path.startsWith("/api")) {
+ next();
+ return;
+ }
+
+ void (async () => {
+ try {
+ let ownerProfileId = await resolveRequestJobOwnerProfileId(req);
+ if (
+ ownerProfileId === "__unmapped__" &&
+ isAuthBootstrapPath(req.path)
+ ) {
+ ownerProfileId = DEFAULT_JOB_OWNER_PROFILE_ID;
+ } else if (ownerProfileId === "__unmapped__") {
+ fail(
+ res,
+ forbidden(
+ "No job search profile is linked to this login. Set basicAuthUser on a profile in Settings.",
+ ),
+ );
+ return;
+ }
+ runWithRequestContext({ ownerProfileId }, () => next());
+ } catch (error) {
+ next(error);
+ }
+ })();
+ };
+}
diff --git a/orchestrator/src/server/infra/request-context.ts b/orchestrator/src/server/infra/request-context.ts
index afd71d1..e14b75f 100644
--- a/orchestrator/src/server/infra/request-context.ts
+++ b/orchestrator/src/server/infra/request-context.ts
@@ -4,6 +4,8 @@ export type RequestContext = {
requestId: string;
pipelineRunId?: string;
jobId?: string;
+ /** Search profile id for job list / pipeline isolation (Basic Auth). */
+ ownerProfileId?: string;
};
const storage = new AsyncLocalStorage();
@@ -28,3 +30,7 @@ export function runWithRequestContext(
export function getRequestId(): string | undefined {
return storage.getStore()?.requestId;
}
+
+export function getJobOwnerProfileId(): string | undefined {
+ return storage.getStore()?.ownerProfileId;
+}
diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts
index b1b111b..c95f11e 100644
--- a/orchestrator/src/server/pipeline/orchestrator.ts
+++ b/orchestrator/src/server/pipeline/orchestrator.ts
@@ -9,7 +9,11 @@
import { join } from "node:path";
import { logger } from "@infra/logger";
-import { runWithRequestContext } from "@infra/request-context";
+import {
+ getJobOwnerProfileId,
+ runWithRequestContext,
+} from "@infra/request-context";
+import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import type { PipelineConfig } from "@shared/types";
import { getDataDir } from "../config/dataDir";
import * as jobsRepo from "../repositories/jobs";
@@ -88,137 +92,154 @@ export async function runPipeline(
activePipelineRunId = "pending";
cancelRequestedAt = null;
resetProgress();
- const mergedConfig = { ...DEFAULT_CONFIG, ...config };
+ const mergedConfig: PipelineConfig = {
+ ...DEFAULT_CONFIG,
+ ...config,
+ ownerProfileId:
+ config.ownerProfileId ??
+ getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+ };
+ const ownerProfileId =
+ mergedConfig.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID;
const pipelineRun = await pipelineRepo.createPipelineRun();
activePipelineRunId = pipelineRun.id;
- return runWithRequestContext({ pipelineRunId: pipelineRun.id }, async () => {
- const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id });
- let jobsDiscovered = 0;
- let jobsProcessed = 0;
- pipelineLogger.info("Starting pipeline run", {
- topN: mergedConfig.topN,
- minSuitabilityScore: mergedConfig.minSuitabilityScore,
- sources: mergedConfig.sources,
- });
-
- try {
- ensureNotCancelled();
- const profile = await loadProfileStep();
-
- ensureNotCancelled();
- const { discoveredJobs } = await discoverJobsStep({
- mergedConfig,
- shouldCancel: () => cancelRequestedAt !== null,
+ return runWithRequestContext(
+ {
+ pipelineRunId: pipelineRun.id,
+ ownerProfileId,
+ },
+ async () => {
+ const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id });
+ let jobsDiscovered = 0;
+ let jobsProcessed = 0;
+ pipelineLogger.info("Starting pipeline run", {
+ topN: mergedConfig.topN,
+ minSuitabilityScore: mergedConfig.minSuitabilityScore,
+ sources: mergedConfig.sources,
});
- ensureNotCancelled();
- const { created } = await importJobsStep({ discoveredJobs });
- jobsDiscovered = created;
+ try {
+ ensureNotCancelled();
+ const profile = await loadProfileStep();
- await pipelineRepo.updatePipelineRun(pipelineRun.id, {
- jobsDiscovered: created,
- });
+ ensureNotCancelled();
+ const { discoveredJobs } = await discoverJobsStep({
+ mergedConfig,
+ shouldCancel: () => cancelRequestedAt !== null,
+ });
- ensureNotCancelled();
- const { unprocessedJobs, scoredJobs } = await scoreJobsStep({
- profile,
- shouldCancel: () => cancelRequestedAt !== null,
- });
+ ensureNotCancelled();
+ const { created } = await importJobsStep({ discoveredJobs });
+ jobsDiscovered = created;
- ensureNotCancelled();
- const jobsToProcess = selectJobsStep({
- scoredJobs,
- mergedConfig,
- });
-
- pipelineLogger.info("Selected jobs for processing", {
- candidates: jobsToProcess.length,
- });
-
- const { processedCount } = await processJobsStep({
- jobsToProcess,
- processJob,
- shouldCancel: () => cancelRequestedAt !== null,
- });
- jobsProcessed = processedCount;
-
- await pipelineRepo.updatePipelineRun(pipelineRun.id, {
- status: "completed",
- completedAt: new Date().toISOString(),
- jobsProcessed: processedCount,
- });
-
- progressHelpers.complete(created, processedCount);
- pipelineLogger.info("Pipeline run completed", {
- jobsDiscovered: created,
- jobsProcessed: processedCount,
- });
-
- await notifyPipelineWebhookStep("pipeline.completed", {
- pipelineRunId: pipelineRun.id,
- jobsDiscovered: created,
- jobsScored: unprocessedJobs.length,
- jobsProcessed: processedCount,
- });
-
- return {
- success: true,
- jobsDiscovered: created,
- jobsProcessed: processedCount,
- };
- } catch (error) {
- if (error instanceof PipelineCancelledError) {
- const message = "Cancelled by user request";
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
- status: "cancelled",
+ jobsDiscovered: created,
+ });
+
+ ensureNotCancelled();
+ const { unprocessedJobs, scoredJobs } = await scoreJobsStep({
+ profile,
+ ownerProfileId,
+ shouldCancel: () => cancelRequestedAt !== null,
+ });
+
+ ensureNotCancelled();
+ const jobsToProcess = selectJobsStep({
+ scoredJobs,
+ mergedConfig,
+ });
+
+ pipelineLogger.info("Selected jobs for processing", {
+ candidates: jobsToProcess.length,
+ });
+
+ const { processedCount } = await processJobsStep({
+ jobsToProcess,
+ processJob,
+ shouldCancel: () => cancelRequestedAt !== null,
+ });
+ jobsProcessed = processedCount;
+
+ await pipelineRepo.updatePipelineRun(pipelineRun.id, {
+ status: "completed",
+ completedAt: new Date().toISOString(),
+ jobsProcessed: processedCount,
+ });
+
+ progressHelpers.complete(created, processedCount);
+ pipelineLogger.info("Pipeline run completed", {
+ jobsDiscovered: created,
+ jobsProcessed: processedCount,
+ });
+
+ await notifyPipelineWebhookStep("pipeline.completed", {
+ pipelineRunId: pipelineRun.id,
+ jobsDiscovered: created,
+ jobsScored: unprocessedJobs.length,
+ jobsProcessed: processedCount,
+ });
+
+ return {
+ success: true,
+ jobsDiscovered: created,
+ jobsProcessed: processedCount,
+ };
+ } catch (error) {
+ if (error instanceof PipelineCancelledError) {
+ const message = "Cancelled by user request";
+ await pipelineRepo.updatePipelineRun(pipelineRun.id, {
+ status: "cancelled",
+ completedAt: new Date().toISOString(),
+ jobsDiscovered,
+ jobsProcessed,
+ errorMessage: message,
+ });
+ progressHelpers.cancelled(message);
+ pipelineLogger.info("Pipeline run cancelled", {
+ jobsDiscovered,
+ jobsProcessed,
+ });
+ return {
+ success: false,
+ jobsDiscovered,
+ jobsProcessed,
+ error: message,
+ };
+ }
+
+ const message =
+ error instanceof Error ? error.message : "Unknown error";
+
+ await pipelineRepo.updatePipelineRun(pipelineRun.id, {
+ status: "failed",
completedAt: new Date().toISOString(),
- jobsDiscovered,
- jobsProcessed,
errorMessage: message,
});
- progressHelpers.cancelled(message);
- pipelineLogger.info("Pipeline run cancelled", {
- jobsDiscovered,
- jobsProcessed,
+
+ progressHelpers.failed(message);
+ pipelineLogger.error("Pipeline run failed", error);
+
+ await notifyPipelineWebhookStep("pipeline.failed", {
+ pipelineRunId: pipelineRun.id,
+ error: message,
});
+
return {
success: false,
jobsDiscovered,
jobsProcessed,
error: message,
};
+ } finally {
+ isPipelineRunning = false;
+ activePipelineRunId = null;
+ cancelRequestedAt = null;
}
-
- const message = error instanceof Error ? error.message : "Unknown error";
-
- await pipelineRepo.updatePipelineRun(pipelineRun.id, {
- status: "failed",
- completedAt: new Date().toISOString(),
- errorMessage: message,
- });
-
- progressHelpers.failed(message);
- pipelineLogger.error("Pipeline run failed", error);
-
- await notifyPipelineWebhookStep("pipeline.failed", {
- pipelineRunId: pipelineRun.id,
- error: message,
- });
-
- return {
- success: false,
- jobsDiscovered,
- jobsProcessed,
- error: message,
- };
- } finally {
- isPipelineRunning = false;
- activePipelineRunId = null;
- cancelRequestedAt = null;
- }
- });
+ },
+ );
}
export type ProcessJobOptions = {
@@ -240,7 +261,10 @@ export async function summarizeJob(
const jobLogger = logger.child({ jobId });
jobLogger.info("Summarizing job");
try {
- const job = await jobsRepo.getJobById(jobId);
+ const job = await jobsRepo.getJobById(
+ jobId,
+ getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID,
+ );
if (!job) return { success: false, error: "Job not found" };
const profile = await getProfile();
@@ -303,12 +327,16 @@ export async function summarizeJob(
}
}
- await jobsRepo.updateJob(job.id, {
- tailoredSummary: tailoredSummary ?? undefined,
- tailoredHeadline: tailoredHeadline ?? undefined,
- tailoredSkills: tailoredSkills ?? undefined,
- selectedProjectIds: selectedProjectIds ?? undefined,
- });
+ await jobsRepo.updateJob(
+ job.id,
+ {
+ tailoredSummary: tailoredSummary ?? undefined,
+ tailoredHeadline: tailoredHeadline ?? undefined,
+ tailoredSkills: tailoredSkills ?? undefined,
+ selectedProjectIds: selectedProjectIds ?? undefined,
+ },
+ job.ownerProfileId,
+ );
return { success: true };
} catch (error) {
@@ -333,11 +361,18 @@ export async function generateFinalPdf(
const jobLogger = logger.child({ jobId });
jobLogger.info("Generating final PDF");
try {
- const job = await jobsRepo.getJobById(jobId);
+ const job = await jobsRepo.getJobById(
+ jobId,
+ getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID,
+ );
if (!job) return { success: false, error: "Job not found" };
// Mark as processing
- await jobsRepo.updateJob(job.id, { status: "processing" });
+ await jobsRepo.updateJob(
+ job.id,
+ { status: "processing" },
+ job.ownerProfileId,
+ );
const pdfResult = await generatePdf(
job.id,
@@ -358,14 +393,22 @@ export async function generateFinalPdf(
if (!pdfResult.success) {
// Revert status if failed
- await jobsRepo.updateJob(job.id, { status: "discovered" });
+ await jobsRepo.updateJob(
+ job.id,
+ { status: "discovered" },
+ job.ownerProfileId,
+ );
return { success: false, error: pdfResult.error };
}
- await jobsRepo.updateJob(job.id, {
- status: "ready",
- pdfPath: pdfResult.pdfPath,
- });
+ await jobsRepo.updateJob(
+ job.id,
+ {
+ status: "ready",
+ pdfPath: pdfResult.pdfPath,
+ },
+ job.ownerProfileId,
+ );
return { success: true };
} catch (error) {
diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts
index 9eb90e3..26d7a7c 100644
--- a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts
+++ b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts
@@ -11,6 +11,10 @@ vi.mock("@server/repositories/jobs", () => ({
getAllJobUrls: vi.fn().mockResolvedValue([]),
}));
+vi.mock("@server/repositories/profiles", () => ({
+ getProfileById: vi.fn().mockResolvedValue(null),
+}));
+
vi.mock("@server/extractors/registry", () => ({
getExtractorRegistry: vi.fn(),
}));
@@ -20,6 +24,7 @@ const baseConfig: PipelineConfig = {
minSuitabilityScore: 50,
sources: ["indeed", "linkedin", "ukvisajobs"],
outputDir: "./tmp",
+ ownerProfileId: "__default__",
enableCrawling: true,
enableScoring: true,
enableImporting: true,
@@ -84,6 +89,7 @@ describe("discoverJobsStep", () => {
const result = await discoverJobsStep({ mergedConfig: baseConfig });
expect(result.discoveredJobs).toHaveLength(1);
+ expect(result.discoveredJobs[0]?.ownerProfileId).toBe("__default__");
expect(result.sourceErrors).toEqual([
"UK Visa Jobs: login failed (sources: ukvisajobs)",
]);
diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.ts
index 11658d2..1df99c3 100644
--- a/orchestrator/src/server/pipeline/steps/discover-jobs.ts
+++ b/orchestrator/src/server/pipeline/steps/discover-jobs.ts
@@ -1,7 +1,9 @@
import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
import { getExtractorRegistry } from "@server/extractors/registry";
+import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { getAllJobUrls } from "@server/repositories/jobs";
+import { getProfileById } from "@server/repositories/profiles";
import * as settingsRepo from "@server/repositories/settings";
import { asyncPool } from "@server/utils/async-pool";
import {
@@ -94,32 +96,47 @@ export async function discoverJobsStep(args: {
.filter(Boolean);
}
- const profileSetting = settings.jobSearchProfile;
- if (profileSetting) {
- try {
- const profile = JSON.parse(profileSetting);
+ const ownerProfileId =
+ args.mergedConfig.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID;
+
+ const mergeTargetRoles = (targetRoles: unknown) => {
+ if (!Array.isArray(targetRoles) || targetRoles.length === 0) return;
+ const existingLower = new Set(searchTerms.map((t) => t.toLowerCase()));
+ for (const role of targetRoles) {
if (
- Array.isArray(profile.targetRoles) &&
- profile.targetRoles.length > 0
+ typeof role === "string" &&
+ role.trim() &&
+ !existingLower.has(role.trim().toLowerCase())
) {
- const existingLower = new Set(searchTerms.map((t) => t.toLowerCase()));
- for (const role of profile.targetRoles) {
- if (
- typeof role === "string" &&
- role.trim() &&
- !existingLower.has(role.trim().toLowerCase())
- ) {
- searchTerms.push(role.trim());
- existingLower.add(role.trim().toLowerCase());
- }
- }
- logger.info("Augmented search terms with profile target roles", {
- addedRoles: profile.targetRoles.length,
- totalTerms: searchTerms.length,
- });
+ searchTerms.push(role.trim());
+ existingLower.add(role.trim().toLowerCase());
+ }
+ }
+ logger.info("Augmented search terms with profile target roles", {
+ addedRoles: targetRoles.length,
+ totalTerms: searchTerms.length,
+ });
+ };
+
+ if (ownerProfileId && ownerProfileId !== DEFAULT_JOB_OWNER_PROFILE_ID) {
+ const row = await getProfileById(ownerProfileId);
+ if (row?.data?.targetRoles?.length) {
+ mergeTargetRoles(row.data.targetRoles);
+ }
+ } else {
+ const profileSetting = settings.jobSearchProfile;
+ if (profileSetting) {
+ try {
+ const profile = JSON.parse(profileSetting);
+ if (
+ Array.isArray(profile.targetRoles) &&
+ profile.targetRoles.length > 0
+ ) {
+ mergeTargetRoles(profile.targetRoles);
+ }
+ } catch {
+ // malformed profile JSON, continue with existing terms
}
- } catch {
- // malformed profile JSON, continue with existing terms
}
}
@@ -161,7 +178,7 @@ export async function discoverJobsStep(args: {
let existingJobUrlsPromise: Promise | null = null;
const getExistingJobUrls = (): Promise => {
if (!existingJobUrlsPromise) {
- existingJobUrlsPromise = getAllJobUrls();
+ existingJobUrlsPromise = getAllJobUrls(ownerProfileId);
}
return existingJobUrlsPromise;
};
@@ -399,5 +416,10 @@ export async function discoverJobsStep(args: {
progressHelpers.crawlingComplete(filteredDiscoveredJobs.length);
- return { discoveredJobs: filteredDiscoveredJobs, sourceErrors };
+ const stamped = filteredDiscoveredJobs.map((job) => ({
+ ...job,
+ ownerProfileId,
+ }));
+
+ return { discoveredJobs: stamped, sourceErrors };
}
diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.test.ts b/orchestrator/src/server/pipeline/steps/score-jobs.test.ts
index dea6d36..e65c50e 100644
--- a/orchestrator/src/server/pipeline/steps/score-jobs.test.ts
+++ b/orchestrator/src/server/pipeline/steps/score-jobs.test.ts
@@ -75,7 +75,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
- await scoreJobsStep({ profile: {} });
+ await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
expect(jobsRepo.updateJob).toHaveBeenCalledWith(
"job-1",
@@ -107,7 +107,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
analysis: null,
});
- await scoreJobsStep({ profile: {} });
+ await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
expect(jobsRepo.updateJob).toHaveBeenCalledWith(
"job-1",
@@ -131,7 +131,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
- await scoreJobsStep({ profile: {} });
+ await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
status?: string;
@@ -145,7 +145,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(settingsRepo.getSetting).mockResolvedValue("not-a-number");
- await scoreJobsStep({ profile: {} });
+ await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
const updatePayload = vi.mocked(jobsRepo.updateJob).mock.calls[0][1] as {
status?: string;
@@ -170,7 +170,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
}),
]);
- await scoreJobsStep({ profile: {} });
+ await scoreJobsStep({ profile: {}, ownerProfileId: "__default__" });
expect(jobsRepo.updateJob).toHaveBeenCalledWith(
"job-applied",
@@ -218,7 +218,10 @@ describe("scoreJobsStep auto-skip behavior", () => {
analysis: null,
});
- const result = await scoreJobsStep({ profile: {} });
+ const result = await scoreJobsStep({
+ profile: {},
+ ownerProfileId: "__default__",
+ });
expect(result.scoredJobs).toHaveLength(2);
expect(vi.mocked(jobsRepo.updateJob)).toHaveBeenCalledTimes(2);
@@ -241,6 +244,7 @@ describe("scoreJobsStep auto-skip behavior", () => {
const result = await scoreJobsStep({
profile: {},
+ ownerProfileId: "__default__",
shouldCancel: () => true,
});
diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.ts b/orchestrator/src/server/pipeline/steps/score-jobs.ts
index 25b4829..ed81be0 100644
--- a/orchestrator/src/server/pipeline/steps/score-jobs.ts
+++ b/orchestrator/src/server/pipeline/steps/score-jobs.ts
@@ -12,10 +12,14 @@ const SCORING_CONCURRENCY = 4;
export async function scoreJobsStep(args: {
profile: Record;
+ ownerProfileId: string;
shouldCancel?: () => boolean;
}): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> {
logger.info("Running scoring step");
- const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
+ const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs(
+ undefined,
+ args.ownerProfileId,
+ );
// Check if auto-skip threshold is configured
const autoSkipThresholdRaw = await settingsRepo.getSetting(
diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts
index 62bc9c0..bbcc402 100644
--- a/orchestrator/src/server/repositories/jobs.ts
+++ b/orchestrator/src/server/repositories/jobs.ts
@@ -3,6 +3,8 @@
*/
import { randomUUID } from "node:crypto";
+import { getJobOwnerProfileId } from "@infra/request-context";
+import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import { canonicalizeJobUrl } from "@shared/job-url-canonical";
import type {
CreateJobInput,
@@ -29,7 +31,13 @@ function sourceJobKey(source: string, sourceJobId: string): string {
return `${source}\0${sourceJobId}`;
}
-async function loadJobDedupIndexes(): Promise<{
+function resolveOwnerForCreate(input: CreateJobInput): string {
+ const fromInput = input.ownerProfileId?.trim();
+ if (fromInput) return fromInput;
+ return getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID;
+}
+
+async function loadJobDedupIndexes(ownerProfileId: string): Promise<{
existingCanonicalSet: Set;
existingSourceJobKeySet: Set;
}> {
@@ -39,7 +47,8 @@ async function loadJobDedupIndexes(): Promise<{
source: jobs.source,
sourceJobId: jobs.sourceJobId,
})
- .from(jobs);
+ .from(jobs)
+ .where(eq(jobs.ownerProfileId, ownerProfileId));
const existingCanonicalSet = new Set(
rows.map((r) => canonicalizeJobUrl(r.jobUrl)),
@@ -54,14 +63,22 @@ async function loadJobDedupIndexes(): Promise<{
return { existingCanonicalSet, existingSourceJobKeySet };
}
-async function findJobByCanonicalUrl(canonical: string): Promise {
+async function findJobByCanonicalUrl(
+ canonical: string,
+ ownerProfileId: string,
+): Promise {
const [exact] = await db
.select()
.from(jobs)
- .where(eq(jobs.jobUrl, canonical));
+ .where(
+ and(eq(jobs.ownerProfileId, ownerProfileId), eq(jobs.jobUrl, canonical)),
+ );
if (exact) return mapRowToJob(exact);
- const allRows = await db.select().from(jobs);
+ const allRows = await db
+ .select()
+ .from(jobs)
+ .where(eq(jobs.ownerProfileId, ownerProfileId));
for (const row of allRows) {
if (canonicalizeJobUrl(row.jobUrl) === canonical) {
return mapRowToJob(row);
@@ -73,11 +90,18 @@ async function findJobByCanonicalUrl(canonical: string): Promise {
async function getJobBySourceAndExternalId(
source: string,
sourceJobId: string,
+ ownerProfileId: string,
): Promise {
const [row] = await db
.select()
.from(jobs)
- .where(and(eq(jobs.source, source), eq(jobs.sourceJobId, sourceJobId)));
+ .where(
+ and(
+ eq(jobs.ownerProfileId, ownerProfileId),
+ eq(jobs.source, source),
+ eq(jobs.sourceJobId, sourceJobId),
+ ),
+ );
return row ? mapRowToJob(row) : null;
}
@@ -89,15 +113,24 @@ function normalizeStatusFilter(statuses?: JobStatus[]): string | null {
/**
* Get all jobs, optionally filtered by status.
*/
-export async function getAllJobs(statuses?: JobStatus[]): Promise {
+export async function getAllJobs(
+ statuses?: JobStatus[],
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise {
+ const ownerClause = eq(jobs.ownerProfileId, ownerProfileId);
const query =
statuses && statuses.length > 0
? db
.select()
.from(jobs)
- .where(inArray(jobs.status, statuses))
+ .where(and(ownerClause, inArray(jobs.status, statuses)))
.orderBy(desc(jobs.discoveredAt))
- : db.select().from(jobs).orderBy(desc(jobs.discoveredAt));
+ : db
+ .select()
+ .from(jobs)
+ .where(ownerClause)
+ .orderBy(desc(jobs.discoveredAt));
const rows = await query;
return rows.map(mapRowToJob);
@@ -108,7 +141,10 @@ export async function getAllJobs(statuses?: JobStatus[]): Promise {
*/
export async function getJobListItems(
statuses?: JobStatus[],
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise {
+ const ownerClause = eq(jobs.ownerProfileId, ownerProfileId);
const selection = {
id: jobs.id,
source: jobs.source,
@@ -141,9 +177,13 @@ export async function getJobListItems(
? db
.select(selection)
.from(jobs)
- .where(inArray(jobs.status, statuses))
+ .where(and(ownerClause, inArray(jobs.status, statuses)))
.orderBy(desc(jobs.discoveredAt))
- : db.select(selection).from(jobs).orderBy(desc(jobs.discoveredAt));
+ : db
+ .select(selection)
+ .from(jobs)
+ .where(ownerClause)
+ .orderBy(desc(jobs.discoveredAt));
const rows = await query;
return rows.map((row) => ({
@@ -158,9 +198,12 @@ export async function getJobListItems(
*/
export async function getJobsRevision(
statuses?: JobStatus[],
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise {
const statusFilter = normalizeStatusFilter(statuses);
- const whereClause =
+ const ownerClause = eq(jobs.ownerProfileId, ownerProfileId);
+ const statusClause =
statuses && statuses.length > 0
? inArray(jobs.status, statuses)
: undefined;
@@ -171,9 +214,9 @@ export async function getJobsRevision(
total: sql`count(*)`,
})
.from(jobs);
- const [row] = whereClause
- ? await baseQuery.where(whereClause)
- : await baseQuery;
+ const [row] = statusClause
+ ? await baseQuery.where(and(ownerClause, statusClause))
+ : await baseQuery.where(ownerClause);
const latestUpdatedAt = row?.latestUpdatedAt ?? null;
const total = row?.total ?? 0;
@@ -188,14 +231,25 @@ export async function getJobsRevision(
}
/**
- * Get a single job by ID.
+ * Get a single job by ID (scoped to the owning search profile).
*/
-export async function getJobById(id: string): Promise {
- const [row] = await db.select().from(jobs).where(eq(jobs.id, id));
+export async function getJobById(
+ id: string,
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise {
+ const [row] = await db
+ .select()
+ .from(jobs)
+ .where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));
return row ? mapRowToJob(row) : null;
}
-export async function listJobSummariesByIds(jobIds: string[]): Promise<
+export async function listJobSummariesByIds(
+ jobIds: string[],
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise<
Array<{
id: string;
title: string;
@@ -211,22 +265,34 @@ export async function listJobSummariesByIds(jobIds: string[]): Promise<
employer: jobs.employer,
})
.from(jobs)
- .where(inArray(jobs.id, jobIds));
+ .where(
+ and(eq(jobs.ownerProfileId, ownerProfileId), inArray(jobs.id, jobIds)),
+ );
}
/**
* Get a job by its URL (for deduplication).
* Matches canonical URL equivalence, including legacy rows stored with non-canonical URLs.
*/
-export async function getJobByUrl(jobUrl: string): Promise {
- return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl));
+export async function getJobByUrl(
+ jobUrl: string,
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise {
+ return findJobByCanonicalUrl(canonicalizeJobUrl(jobUrl), ownerProfileId);
}
/**
* Get all known canonical job URLs (for deduplication / crawler skip lists).
*/
-export async function getAllJobUrls(): Promise {
- const rows = await db.select({ jobUrl: jobs.jobUrl }).from(jobs);
+export async function getAllJobUrls(
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise {
+ const rows = await db
+ .select({ jobUrl: jobs.jobUrl })
+ .from(jobs)
+ .where(eq(jobs.ownerProfileId, ownerProfileId));
const canonicals = rows.map((r) => canonicalizeJobUrl(r.jobUrl));
return Array.from(new Set(canonicals));
}
@@ -235,8 +301,11 @@ async function insertJob(input: CreateJobInput): Promise {
const id = randomUUID();
const now = new Date().toISOString();
+ const ownerProfileId = resolveOwnerForCreate(input);
+
await db.insert(jobs).values({
id,
+ ownerProfileId,
source: input.source,
sourceJobId: input.sourceJobId ?? null,
jobUrlDirect: input.jobUrlDirect ?? null,
@@ -292,7 +361,12 @@ async function insertJob(input: CreateJobInput): Promise {
function isJobUrlUniqueViolation(error: unknown): boolean {
if (!(error instanceof Error)) return false;
- return /UNIQUE constraint failed: jobs\.job_url/i.test(error.message);
+ return (
+ /UNIQUE constraint failed: jobs\.job_url/i.test(error.message) ||
+ /UNIQUE constraint failed.*idx_jobs_owner_profile_job_url/i.test(
+ error.message,
+ )
+ );
}
async function tryInsertJob(input: CreateJobInput): Promise {
@@ -316,8 +390,13 @@ export async function createJobs(
): Promise {
if (!Array.isArray(inputOrInputs)) {
const normalized = normalizeCreateJobInputForDedup(inputOrInputs);
+ const ownerProfileId = resolveOwnerForCreate(normalized);
+ const normalizedWithOwner: CreateJobInput = {
+ ...normalized,
+ ownerProfileId,
+ };
const { existingCanonicalSet, existingSourceJobKeySet } =
- await loadJobDedupIndexes();
+ await loadJobDedupIndexes(ownerProfileId);
const sid = normalized.sourceJobId?.trim();
if (sid) {
@@ -326,29 +405,40 @@ export async function createJobs(
const existing = await getJobBySourceAndExternalId(
normalized.source,
sid,
+ ownerProfileId,
);
if (existing) return existing;
}
}
if (existingCanonicalSet.has(normalized.jobUrl)) {
- const existing = await findJobByCanonicalUrl(normalized.jobUrl);
+ const existing = await findJobByCanonicalUrl(
+ normalized.jobUrl,
+ ownerProfileId,
+ );
if (existing) return existing;
}
- const inserted = await tryInsertJob(normalized);
+ const inserted = await tryInsertJob(normalizedWithOwner);
if (inserted) return inserted;
const existingAfterConflict =
- (await findJobByCanonicalUrl(normalized.jobUrl)) ??
- (sid ? await getJobBySourceAndExternalId(normalized.source, sid) : null);
+ (await findJobByCanonicalUrl(normalized.jobUrl, ownerProfileId)) ??
+ (sid
+ ? await getJobBySourceAndExternalId(
+ normalized.source,
+ sid,
+ ownerProfileId,
+ )
+ : null);
if (existingAfterConflict) return existingAfterConflict;
throw new Error("Failed to create or resolve existing job by URL");
}
+ const ownerProfileId = resolveOwnerForCreate(inputOrInputs[0] ?? {});
const { existingCanonicalSet, existingSourceJobKeySet } =
- await loadJobDedupIndexes();
+ await loadJobDedupIndexes(ownerProfileId);
const batchBuckets = new Map<
string,
@@ -359,9 +449,13 @@ export async function createJobs(
>();
for (const raw of inputOrInputs) {
- const normalized = normalizeCreateJobInputForDedup(raw);
- const batchKey = normalized.sourceJobId?.trim()
- ? `sid:${sourceJobKey(normalized.source, normalized.sourceJobId!)}`
+ const normalized = normalizeCreateJobInputForDedup({
+ ...raw,
+ ownerProfileId,
+ });
+ const sidForKey = normalized.sourceJobId?.trim();
+ const batchKey = sidForKey
+ ? `sid:${sourceJobKey(normalized.source, sidForKey)}`
: `url:${normalized.jobUrl}`;
const prev = batchBuckets.get(batchKey);
if (prev) {
@@ -418,6 +512,8 @@ export async function createJob(input: CreateJobInput): Promise {
export async function updateJob(
id: string,
input: UpdateJobInput,
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise {
const now = new Date().toISOString();
@@ -431,21 +527,25 @@ export async function updateJob(
? { appliedAt: now }
: {}),
})
- .where(eq(jobs.id, id));
+ .where(and(eq(jobs.id, id), eq(jobs.ownerProfileId, ownerProfileId)));
- return getJobById(id);
+ return getJobById(id, ownerProfileId);
}
/**
* Get job statistics by status.
*/
-export async function getJobStats(): Promise> {
+export async function getJobStats(
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise> {
const result = await db
.select({
status: jobs.status,
count: sql`count(*)`,
})
.from(jobs)
+ .where(eq(jobs.ownerProfileId, ownerProfileId))
.groupBy(jobs.status);
const stats: Record = {
@@ -468,12 +568,17 @@ export async function getJobStats(): Promise> {
/**
* Get jobs ready for processing (discovered with description).
*/
-export async function getJobsForProcessing(limit: number = 10): Promise {
+export async function getJobsForProcessing(
+ limit: number = 10,
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise {
const rows = await db
.select()
.from(jobs)
.where(
and(
+ eq(jobs.ownerProfileId, ownerProfileId),
eq(jobs.status, "discovered"),
sql`${jobs.jobDescription} IS NOT NULL`,
),
@@ -489,11 +594,19 @@ export async function getJobsForProcessing(limit: number = 10): Promise {
*/
export async function getUnscoredDiscoveredJobs(
limit?: number,
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
): Promise {
const query = db
.select()
.from(jobs)
- .where(and(eq(jobs.status, "discovered"), isNull(jobs.suitabilityScore)))
+ .where(
+ and(
+ eq(jobs.ownerProfileId, ownerProfileId),
+ eq(jobs.status, "discovered"),
+ isNull(jobs.suitabilityScore),
+ ),
+ )
.orderBy(desc(jobs.discoveredAt));
const rows =
@@ -504,19 +617,33 @@ export async function getUnscoredDiscoveredJobs(
/**
* Delete jobs by status.
*/
-export async function deleteJobsByStatus(status: JobStatus): Promise {
- const result = await db.delete(jobs).where(eq(jobs.status, status)).run();
+export async function deleteJobsByStatus(
+ status: JobStatus,
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise {
+ const result = await db
+ .delete(jobs)
+ .where(
+ and(eq(jobs.ownerProfileId, ownerProfileId), eq(jobs.status, status)),
+ )
+ .run();
return result.changes;
}
/**
* Delete jobs with suitability score below threshold (excluding applied and in_progress jobs).
*/
-export async function deleteJobsBelowScore(threshold: number): Promise {
+export async function deleteJobsBelowScore(
+ threshold: number,
+ ownerProfileId: string = getJobOwnerProfileId() ??
+ DEFAULT_JOB_OWNER_PROFILE_ID,
+): Promise {
const result = await db
.delete(jobs)
.where(
and(
+ eq(jobs.ownerProfileId, ownerProfileId),
lt(jobs.suitabilityScore, threshold),
ne(jobs.status, "applied"),
ne(jobs.status, "in_progress"),
@@ -530,6 +657,7 @@ export async function deleteJobsBelowScore(threshold: number): Promise {
function mapRowToJob(row: typeof jobs.$inferSelect): Job {
return {
id: row.id,
+ ownerProfileId: row.ownerProfileId ?? DEFAULT_JOB_OWNER_PROFILE_ID,
source: row.source as Job["source"],
sourceJobId: row.sourceJobId ?? null,
jobUrlDirect: row.jobUrlDirect ?? null,
diff --git a/orchestrator/src/server/repositories/profiles.ts b/orchestrator/src/server/repositories/profiles.ts
index 256ca58..f36c345 100644
--- a/orchestrator/src/server/repositories/profiles.ts
+++ b/orchestrator/src/server/repositories/profiles.ts
@@ -42,6 +42,18 @@ export async function listProfiles(): Promise {
return rows.map(mapRow);
}
+/** When Basic Auth maps users to profiles, each user only sees rows with matching `basicAuthUser`. */
+export async function listProfilesForBasicAuthUser(
+ username: string,
+): Promise {
+ const u = username.trim().toLowerCase();
+ if (!u) return [];
+ const all = await listProfiles();
+ return all.filter(
+ (p) => (p.data.basicAuthUser ?? "").trim().toLowerCase() === u,
+ );
+}
+
export async function getProfileById(
id: string,
): Promise {
@@ -87,6 +99,18 @@ export async function updateProfile(
return getProfileById(id);
}
+export async function getSearchProfileIdForBasicAuthUser(
+ username: string,
+): Promise {
+ const profiles = await listProfiles();
+ const u = username.trim().toLowerCase();
+ for (const p of profiles) {
+ const bu = (p.data.basicAuthUser ?? "").trim().toLowerCase();
+ if (bu && bu === u) return p.id;
+ }
+ return null;
+}
+
export async function deleteProfile(id: string): Promise {
const result = await db
.delete(searchProfiles)
diff --git a/orchestrator/src/server/services/demo-simulator.ts b/orchestrator/src/server/services/demo-simulator.ts
index 1783a54..dd43211 100644
--- a/orchestrator/src/server/services/demo-simulator.ts
+++ b/orchestrator/src/server/services/demo-simulator.ts
@@ -1,4 +1,6 @@
import { logger } from "@infra/logger";
+import { getJobOwnerProfileId } from "@infra/request-context";
+import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
import * as pipeline from "@server/pipeline/index";
import * as jobsRepo from "@server/repositories/jobs";
import * as pipelineRepo from "@server/repositories/pipeline";
@@ -42,7 +44,10 @@ function samplePdfPath(job: Job): string {
}
async function ensureJob(jobId: string): Promise {
- const job = await jobsRepo.getJobById(jobId);
+ const job = await jobsRepo.getJobById(
+ jobId,
+ getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID,
+ );
if (!job) throw new Error("Job not found");
return job;
}
@@ -56,6 +61,7 @@ export async function simulatePipelineRun(
const isoNow = now.toISOString();
const jobUrl = `https://demo.job-ops.local/jobs/${run.id}`;
await jobsRepo.createJob({
+ ownerProfileId: getJobOwnerProfileId() ?? DEFAULT_JOB_OWNER_PROFILE_ID,
source: source as JobSource,
title: "Demo Software Engineer",
employer: "Demo Systems Ltd",
@@ -89,16 +95,20 @@ export async function simulateSummarizeJob(
_options?: ProcessOptions,
): Promise<{ success: boolean; error?: string }> {
const job = await ensureJob(jobId);
- await jobsRepo.updateJob(job.id, {
- tailoredSummary: makeDemoSummary(job),
- tailoredHeadline: `Demo Tailored Resume - ${job.title}`,
- tailoredSkills: JSON.stringify([
- "TypeScript",
- "System Design",
- "Communication",
- ]),
- selectedProjectIds: ensureProjectIds(job),
- });
+ await jobsRepo.updateJob(
+ job.id,
+ {
+ tailoredSummary: makeDemoSummary(job),
+ tailoredHeadline: `Demo Tailored Resume - ${job.title}`,
+ tailoredSkills: JSON.stringify([
+ "TypeScript",
+ "System Design",
+ "Communication",
+ ]),
+ selectedProjectIds: ensureProjectIds(job),
+ },
+ job.ownerProfileId,
+ );
return { success: true };
}
@@ -106,10 +116,14 @@ export async function simulateGeneratePdf(
jobId: string,
): Promise<{ success: boolean; error?: string }> {
const job = await ensureJob(jobId);
- await jobsRepo.updateJob(job.id, {
- status: "ready",
- pdfPath: samplePdfPath(job),
- });
+ await jobsRepo.updateJob(
+ job.id,
+ {
+ status: "ready",
+ pdfPath: samplePdfPath(job),
+ },
+ job.ownerProfileId,
+ );
return { success: true };
}
@@ -125,10 +139,14 @@ export async function simulateProcessJob(
export async function simulateRescoreJob(jobId: string): Promise {
const job = await ensureJob(jobId);
const score = scoreFromJob(job);
- const updated = await jobsRepo.updateJob(job.id, {
- suitabilityScore: score,
- suitabilityReason: makeDemoReason(job, score),
- });
+ const updated = await jobsRepo.updateJob(
+ job.id,
+ {
+ suitabilityScore: score,
+ suitabilityReason: makeDemoReason(job, score),
+ },
+ job.ownerProfileId,
+ );
if (!updated) throw new Error("Job not found");
return updated;
}
@@ -148,10 +166,14 @@ export async function simulateApplyJob(jobId: string): Promise {
null,
);
- const updated = await jobsRepo.updateJob(job.id, {
- status: "applied",
- appliedAt: appliedAtDate.toISOString(),
- });
+ const updated = await jobsRepo.updateJob(
+ job.id,
+ {
+ status: "applied",
+ appliedAt: appliedAtDate.toISOString(),
+ },
+ job.ownerProfileId,
+ );
if (!updated) throw new Error("Job not found");
return updated;
}
diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts
index 53a1756..a003157 100644
--- a/orchestrator/src/server/services/envSettings.ts
+++ b/orchestrator/src/server/services/envSettings.ts
@@ -1,3 +1,4 @@
+import { isBasicAuthEnabled } from "@server/infra/basic-auth-credentials";
import type { SettingKey } from "@server/repositories/settings";
import * as settingsRepo from "@server/repositories/settings";
import { settingsRegistry } from "@shared/settings-registry";
@@ -79,12 +80,7 @@ export async function getEnvSettingsData(
}
}
- const basicAuthUser =
- activeOverrides.basicAuthUser ?? process.env.BASIC_AUTH_USER;
- const basicAuthPassword =
- activeOverrides.basicAuthPassword ?? process.env.BASIC_AUTH_PASSWORD;
-
- values.basicAuthActive = Boolean(basicAuthUser && basicAuthPassword);
+ values.basicAuthActive = isBasicAuthEnabled();
return values;
}
diff --git a/orchestrator/src/server/services/ghostwriter.test.ts b/orchestrator/src/server/services/ghostwriter.test.ts
index dc7a71e..005a0fd 100644
--- a/orchestrator/src/server/services/ghostwriter.test.ts
+++ b/orchestrator/src/server/services/ghostwriter.test.ts
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getRequestId: vi.fn(),
+ getJobOwnerProfileId: vi.fn(),
buildJobChatPromptContext: vi.fn(),
llmCallJson: vi.fn(),
repo: {
@@ -40,6 +41,7 @@ vi.mock("@infra/logger", () => ({
vi.mock("@infra/request-context", () => ({
getRequestId: mocks.getRequestId,
+ getJobOwnerProfileId: mocks.getJobOwnerProfileId,
}));
vi.mock("./ghostwriter-context", () => ({
@@ -134,6 +136,7 @@ describe("ghostwriter service", () => {
vi.clearAllMocks();
mocks.getRequestId.mockReturnValue("req-123");
+ mocks.getJobOwnerProfileId.mockReturnValue(undefined);
mocks.settings.getAllSettings.mockResolvedValue({});
mocks.buildJobChatPromptContext.mockResolvedValue({
job: { id: "job-1" },
diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts
index f6c637b..c760727 100644
--- a/orchestrator/src/server/services/profile.ts
+++ b/orchestrator/src/server/services/profile.ts
@@ -1,6 +1,9 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { logger } from "@infra/logger";
+import { getJobOwnerProfileId } from "@infra/request-context";
+import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
+import { getProfileById } from "@server/repositories/profiles";
import { getSetting } from "@server/repositories/settings";
import type { ResumeProfile } from "@shared/types";
import { getResume, RxResumeAuthConfigError } from "./rxresume";
@@ -19,7 +22,28 @@ let cachedLocalProfile: ResumeProfile | null = null;
*/
export async function resolveLocalResumeFilePath(): Promise {
const envPath = process.env.JOBOPS_LOCAL_RESUME_PATH?.trim();
- const raw = envPath ?? (await getSetting("localResumeProfilePath"))?.trim();
+ if (envPath) {
+ return path.isAbsolute(envPath)
+ ? envPath
+ : path.resolve(process.cwd(), envPath);
+ }
+
+ const ownerId = getJobOwnerProfileId();
+ if (
+ ownerId &&
+ ownerId !== DEFAULT_JOB_OWNER_PROFILE_ID &&
+ ownerId !== "__unmapped__"
+ ) {
+ const row = await getProfileById(ownerId);
+ const tenantPath = row?.data?.resumeLocalPath?.trim();
+ if (tenantPath) {
+ return path.isAbsolute(tenantPath)
+ ? tenantPath
+ : path.resolve(process.cwd(), tenantPath);
+ }
+ }
+
+ const raw = (await getSetting("localResumeProfilePath"))?.trim();
if (!raw) return null;
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
}
diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts
index a3125ad..cae4672 100644
--- a/orchestrator/src/server/services/settings.ts
+++ b/orchestrator/src/server/services/settings.ts
@@ -1,7 +1,11 @@
import { logger } from "@infra/logger";
+import { getJobOwnerProfileId } from "@infra/request-context";
+import { DEFAULT_JOB_OWNER_PROFILE_ID } from "@server/infra/job-owner-context";
+import * as profilesRepo from "@server/repositories/profiles";
import * as settingsRepo from "@server/repositories/settings";
import {
getDefaultModelForProvider,
+ jobSearchProfileSchema,
settingsRegistry,
} from "@shared/settings-registry";
import type { AppSettings } from "@shared/types";
@@ -128,6 +132,21 @@ export async function getEffectiveSettings(): Promise {
const envSettings = await getEnvSettingsData(overrides);
+ const tenantJobSearchProfile = await (async () => {
+ const ownerId = getJobOwnerProfileId();
+ if (
+ !ownerId ||
+ ownerId === DEFAULT_JOB_OWNER_PROFILE_ID ||
+ ownerId === "__unmapped__"
+ ) {
+ return null;
+ }
+ const row = await profilesRepo.getProfileById(ownerId);
+ if (!row?.data) return null;
+ const parsed = jobSearchProfileSchema.safeParse(row.data);
+ return parsed.success ? parsed.data : null;
+ })();
+
const result: Partial = {
...envSettings,
};
@@ -150,6 +169,10 @@ export async function getEffectiveSettings(): Promise {
let override = def.parse(rawOverride);
let defaultValue = def.default();
+ if (key === "jobSearchProfile" && tenantJobSearchProfile) {
+ override = tenantJobSearchProfile;
+ }
+
if (key === "model") {
defaultValue = resolvedModelDefault;
override = overrideModel;
diff --git a/scripts/jobber-cron.env.example b/scripts/jobber-cron.env.example
index 2e59ed2..0edb074 100644
--- a/scripts/jobber-cron.env.example
+++ b/scripts/jobber-cron.env.example
@@ -13,6 +13,8 @@ JOBOPS_URL="http://127.0.0.1:3005"
# Example (matches typical JobSpy bundle + UK sources):
# JOBBER_PIPELINE_SOURCES=gradcracker,indeed,linkedin,glassdoor,ukvisajobs
-# Optional — only if BASIC_AUTH_USER / BASIC_AUTH_PASSWORD are set in Jobber .env
+# Optional — only if BASIC_AUTH_USER / BASIC_AUTH_PASSWORD are set in Jobber .env (use one pair; cron runs as a single identity)
# BASIC_AUTH_USER=""
# BASIC_AUTH_PASSWORD=""
+# BASIC_AUTH_USER_2=""
+# BASIC_AUTH_PASSWORD_2=""
diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts
index 3b9808c..05db688 100644
--- a/shared/src/settings-registry.ts
+++ b/shared/src/settings-registry.ts
@@ -143,6 +143,8 @@ export const jobSearchProfileSchema = z.object({
industriesToTarget: z.array(z.string().trim().min(1).max(200)).max(20),
industriesToAvoid: z.array(z.string().trim().min(1).max(200)).max(20),
aboutMe: z.string().trim().max(4000),
+ basicAuthUser: z.string().trim().max(100).nullable().optional(),
+ resumeLocalPath: z.string().trim().max(500).nullable().optional(),
});
export const resumeProjectsSchema = z.object({
diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts
index 40855cd..fa85100 100644
--- a/shared/src/testing/factories.ts
+++ b/shared/src/testing/factories.ts
@@ -70,6 +70,7 @@ export const createJob = (overrides: Partial = {}): Job => ({
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
+ ownerProfileId: "__default__",
...overrides,
});
diff --git a/shared/src/types/jobs.ts b/shared/src/types/jobs.ts
index 1ac3b97..bf146e3 100644
--- a/shared/src/types/jobs.ts
+++ b/shared/src/types/jobs.ts
@@ -192,6 +192,9 @@ export interface Job {
appliedAt: string | null;
createdAt: string;
updatedAt: string;
+
+ /** Search profile id that owns this job (multi-tenant / Basic Auth). */
+ ownerProfileId: string;
}
export type JobListItem = Pick<
@@ -223,6 +226,8 @@ export type JobListItem = Pick<
>;
export interface CreateJobInput {
+ /** When omitted, server uses the current request / pipeline owner. */
+ ownerProfileId?: string;
source: JobSource;
title: string;
employer: string;
diff --git a/shared/src/types/pipeline.ts b/shared/src/types/pipeline.ts
index ed2ea61..490647e 100644
--- a/shared/src/types/pipeline.ts
+++ b/shared/src/types/pipeline.ts
@@ -6,6 +6,8 @@ export interface PipelineConfig {
minSuitabilityScore: number; // Minimum score to auto-process
sources: ExtractorSourceId[]; // Job sources to crawl
outputDir: string; // Directory for generated PDFs
+ /** Search profile that owns discovered/processed jobs (Basic Auth multi-tenant). */
+ ownerProfileId?: string;
enableCrawling?: boolean;
enableScoring?: boolean;
enableImporting?: boolean;
diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts
index 0eaed4c..c1b00f3 100644
--- a/shared/src/types/settings.ts
+++ b/shared/src/types/settings.ts
@@ -10,6 +10,10 @@ export interface JobSearchProfile {
industriesToTarget: string[];
industriesToAvoid: string[];
aboutMe: string;
+ /** When set, logging in with this Basic Auth username activates this profile and its resume path. */
+ basicAuthUser?: string | null;
+ /** Relative (to orchestrator cwd) or absolute path to local Reactive Resume JSON; applied on profile activate. */
+ resumeLocalPath?: string | null;
}
export interface SearchProfile {