diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index f60d8c6..f3f1af3 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -198,6 +198,17 @@ export async function updateSettings(update: { jobspySites?: string[] | null jobspyLinkedinFetchDescription?: boolean | null showSponsorInfo?: boolean | null + openrouterApiKey?: string | null + rxresumeEmail?: string | null + rxresumePassword?: string | null + basicAuthUser?: string | null + basicAuthPassword?: string | null + ukvisajobsEmail?: string | null + ukvisajobsPassword?: string | null + ukvisajobsHeadless?: boolean | null + webhookSecret?: string | null + notionApiKey?: string | null + notionDatabaseId?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index d488b22..6b5de51 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -95,6 +95,17 @@ const baseSettings: AppSettings = { showSponsorInfo: true, defaultShowSponsorInfo: true, overrideShowSponsorInfo: null, + openrouterApiKeyHint: null, + rxresumeEmail: "", + rxresumePasswordHint: null, + basicAuthUser: "", + basicAuthPasswordHint: null, + ukvisajobsEmail: "", + ukvisajobsPasswordHint: null, + ukvisajobsHeadless: true, + webhookSecretHint: null, + notionApiKeyHint: null, + notionDatabaseId: "", } const renderPage = () => { diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 37b4c90..5cee80d 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -14,6 +14,7 @@ import { arraysEqual } from "@/lib/utils" import { resumeProjectsEqual } from "@client/pages/settings/utils" import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection" import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection" +import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection" import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection" import { JobCompleteWebhookSection } from "@client/pages/settings/components/JobCompleteWebhookSection" import { JobspySection } from "@client/pages/settings/components/JobspySection" @@ -41,6 +42,17 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { jobspySites: null, jobspyLinkedinFetchDescription: null, showSponsorInfo: null, + openrouterApiKey: "", + rxresumeEmail: "", + rxresumePassword: "", + basicAuthUser: "", + basicAuthPassword: "", + ukvisajobsEmail: "", + ukvisajobsPassword: "", + ukvisajobsHeadless: null, + webhookSecret: "", + notionApiKey: "", + notionDatabaseId: "", } const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { @@ -61,6 +73,17 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { jobspySites: null, jobspyLinkedinFetchDescription: null, showSponsorInfo: null, + openrouterApiKey: null, + rxresumeEmail: null, + rxresumePassword: null, + basicAuthUser: null, + basicAuthPassword: null, + ukvisajobsEmail: null, + ukvisajobsPassword: null, + ukvisajobsHeadless: null, + webhookSecret: null, + notionApiKey: null, + notionDatabaseId: null, } const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ @@ -81,6 +104,17 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ jobspySites: data.overrideJobspySites, jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription, showSponsorInfo: data.overrideShowSponsorInfo, + openrouterApiKey: "", + rxresumeEmail: data.rxresumeEmail ?? "", + rxresumePassword: "", + basicAuthUser: data.basicAuthUser ?? "", + basicAuthPassword: "", + ukvisajobsEmail: data.ukvisajobsEmail ?? "", + ukvisajobsPassword: "", + ukvisajobsHeadless: data.ukvisajobsHeadless, + webhookSecret: "", + notionApiKey: "", + notionDatabaseId: data.notionDatabaseId ?? "", }) const normalizeString = (value: string | null | undefined) => { @@ -88,6 +122,11 @@ const normalizeString = (value: string | null | undefined) => { return trimmed ? trimmed : null } +const normalizePrivateInput = (value: string | null | undefined) => { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { if (!left && !right) return true if (!left || !right) return false @@ -170,6 +209,23 @@ const getDerivedSettings = (settings: AppSettings | null) => { effective: settings?.showSponsorInfo ?? true, default: settings?.defaultShowSponsorInfo ?? true, }, + envSettings: { + readable: { + rxresumeEmail: settings?.rxresumeEmail ?? "", + ukvisajobsEmail: settings?.ukvisajobsEmail ?? "", + basicAuthUser: settings?.basicAuthUser ?? "", + notionDatabaseId: settings?.notionDatabaseId ?? "", + ukvisajobsHeadless: settings?.ukvisajobsHeadless ?? true, + }, + private: { + openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null, + rxresumePasswordHint: settings?.rxresumePasswordHint ?? null, + ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null, + basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null, + webhookSecretHint: settings?.webhookSecretHint ?? null, + notionApiKeyHint: settings?.notionApiKeyHint ?? null, + }, + }, defaultResumeProjects: settings?.defaultResumeProjects ?? null, profileProjects, maxProjectsTotal: profileProjects.length, @@ -188,7 +244,7 @@ export const SettingsPage: React.FC = () => { defaultValues: DEFAULT_FORM_VALUES, }) - const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid } } = methods + const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods useEffect(() => { let isMounted = true @@ -224,6 +280,7 @@ export const SettingsPage: React.FC = () => { searchTerms, jobspy, display, + envSettings, defaultResumeProjects, profileProjects, maxProjectsTotal, @@ -245,6 +302,58 @@ export const SettingsPage: React.FC = () => { ? null : resumeProjectsData + const envPayload: Partial = {} + + if (dirtyFields.rxresumeEmail) { + envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail) + } + + if (dirtyFields.ukvisajobsEmail) { + envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail) + } + + if (dirtyFields.basicAuthUser) { + envPayload.basicAuthUser = normalizeString(data.basicAuthUser) + } + + if (dirtyFields.notionDatabaseId) { + envPayload.notionDatabaseId = normalizeString(data.notionDatabaseId) + } + + if (dirtyFields.ukvisajobsHeadless) { + envPayload.ukvisajobsHeadless = data.ukvisajobsHeadless ?? null + } + + if (dirtyFields.openrouterApiKey) { + const value = normalizePrivateInput(data.openrouterApiKey) + if (value !== undefined) envPayload.openrouterApiKey = value + } + + if (dirtyFields.rxresumePassword) { + const value = normalizePrivateInput(data.rxresumePassword) + if (value !== undefined) envPayload.rxresumePassword = value + } + + if (dirtyFields.ukvisajobsPassword) { + const value = normalizePrivateInput(data.ukvisajobsPassword) + if (value !== undefined) envPayload.ukvisajobsPassword = value + } + + if (dirtyFields.basicAuthPassword) { + const value = normalizePrivateInput(data.basicAuthPassword) + if (value !== undefined) envPayload.basicAuthPassword = value + } + + if (dirtyFields.webhookSecret) { + const value = normalizePrivateInput(data.webhookSecret) + if (value !== undefined) envPayload.webhookSecret = value + } + + if (dirtyFields.notionApiKey) { + const value = normalizePrivateInput(data.notionApiKey) + if (value !== undefined) envPayload.notionApiKey = value + } + const payload: UpdateSettingsInput = { model: normalizeString(data.model), modelScorer: normalizeString(data.modelScorer), @@ -266,6 +375,7 @@ export const SettingsPage: React.FC = () => { jobspy.linkedinFetchDescription.default ), showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), + ...envPayload, } const updated = await api.updateSettings(payload) @@ -407,6 +517,11 @@ export const SettingsPage: React.FC = () => { isLoading={isLoading} isSaving={isSaving} /> + { + const methods = useForm({ + defaultValues: { + rxresumeEmail: "resume@example.com", + ukvisajobsEmail: "visa@example.com", + basicAuthUser: "admin", + notionDatabaseId: "db-123", + ukvisajobsHeadless: false, + openrouterApiKey: "", + rxresumePassword: "", + ukvisajobsPassword: "", + basicAuthPassword: "", + webhookSecret: "", + notionApiKey: "", + } + }) + + return ( + + + + + + ) +} + +describe("EnvironmentSettingsSection", () => { + it("renders readable values and masks private secrets with hints", () => { + render() + + expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument() + expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument() + expect(screen.getByDisplayValue("admin")).toBeInTheDocument() + expect(screen.getByDisplayValue("db-123")).toBeInTheDocument() + + expect(screen.getByText("sk-1********")).toBeInTheDocument() + expect(screen.getByText("pass********")).toBeInTheDocument() + expect(screen.getByText("abcd********")).toBeInTheDocument() + expect(screen.getByText("sec-********")).toBeInTheDocument() + expect(screen.getByText("not-********")).toBeInTheDocument() + expect(screen.getByText("Not set")).toBeInTheDocument() + + const headlessToggle = screen.getByLabelText("UKVisaJobs headless mode") + expect(headlessToggle).not.toBeChecked() + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx new file mode 100644 index 0000000..fefdd72 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -0,0 +1,218 @@ +import React from "react" +import { useFormContext, Controller } from "react-hook-form" + +import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" +import type { EnvSettingsValues } from "@client/pages/settings/types" + +type EnvironmentSettingsSectionProps = { + values: EnvSettingsValues + isLoading: boolean + isSaving: boolean +} + +const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set") + +export const EnvironmentSettingsSection: React.FC = ({ + values, + isLoading, + isSaving, +}) => { + const { register, control, formState: { errors } } = useFormContext() + const { readable, private: privateValues } = values + + return ( + + + Environment + + +
+
+
Readable values
+
+
+
RxResume email
+ + {errors.rxresumeEmail &&

{errors.rxresumeEmail.message}

} +
+ Used for RxResume PDF automation. +
+
+ +
+
UKVisaJobs email
+ + {errors.ukvisajobsEmail &&

{errors.ukvisajobsEmail.message}

} +
+ Used for refreshing UKVisaJobs sessions. +
+
+ +
+
Basic auth user
+ + {errors.basicAuthUser &&

{errors.basicAuthUser.message}

} +
+ Pair with a password to require auth on writes. +
+
+ +
+
Notion database ID
+ + {errors.notionDatabaseId &&

{errors.notionDatabaseId.message}

} +
+ Destination database for applied job entries. +
+
+ +
+
+ ( + { + field.onChange(checked === "indeterminate" ? null : checked === true) + }} + disabled={isLoading || isSaving} + /> + )} + /> +
+ +

+ Disable to show the browser while authenticating. +

+
+
+
+
+
+ + + +
+
Private values
+
+
+
OpenRouter API key
+ + {errors.openrouterApiKey &&

{errors.openrouterApiKey.message}

} +
+ Current: {formatSecretHint(privateValues.openrouterApiKeyHint)} +
+
+ +
+
RxResume password
+ + {errors.rxresumePassword &&

{errors.rxresumePassword.message}

} +
+ Current: {formatSecretHint(privateValues.rxresumePasswordHint)} +
+
+ +
+
UKVisaJobs password
+ + {errors.ukvisajobsPassword &&

{errors.ukvisajobsPassword.message}

} +
+ Current: {formatSecretHint(privateValues.ukvisajobsPasswordHint)} +
+
+ +
+
Basic auth password
+ + {errors.basicAuthPassword &&

{errors.basicAuthPassword.message}

} +
+ Current: {formatSecretHint(privateValues.basicAuthPasswordHint)} +
+
+ +
+
Webhook secret
+ + {errors.webhookSecret &&

{errors.webhookSecret.message}

} +
+ Current: {formatSecretHint(privateValues.webhookSecretHint)} +
+
+ +
+
Notion API key
+ + {errors.notionApiKey &&

{errors.notionApiKey.message}

} +
+ Current: {formatSecretHint(privateValues.notionApiKeyHint)} +
+
+
+
+ Private values are write-only. Enter a new value to replace the stored secret. +
+
+
+
+
+ ) +} diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index 227be46..e424a7d 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -22,3 +22,21 @@ export type JobspyValues = { countryIndeed: EffectiveDefault linkedinFetchDescription: EffectiveDefault } + +export type EnvSettingsValues = { + readable: { + rxresumeEmail: string + ukvisajobsEmail: string + basicAuthUser: string + notionDatabaseId: string + ukvisajobsHeadless: boolean + } + private: { + openrouterApiKeyHint: string | null + rxresumePasswordHint: string | null + ukvisajobsPasswordHint: string | null + basicAuthPasswordHint: string | null + webhookSecretHint: string | null + notionApiKeyHint: string | null + } +} diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 1e13474..6d2006b 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -9,7 +9,13 @@ describe.sequential('Settings API routes', () => { let tempDir: string; beforeEach(async () => { - ({ server, baseUrl, closeDb, tempDir } = await startServer()); + ({ server, baseUrl, closeDb, tempDir } = await startServer({ + env: { + OPENROUTER_API_KEY: 'secret-key', + RXRESUME_EMAIL: 'resume@example.com', + UKVISAJOBS_HEADLESS: 'false', + }, + })); }); afterEach(async () => { @@ -22,6 +28,9 @@ describe.sequential('Settings API routes', () => { expect(body.success).toBe(true); expect(body.data.defaultModel).toBe('test-model'); expect(Array.isArray(body.data.searchTerms)).toBe(true); + expect(body.data.rxresumeEmail).toBe('resume@example.com'); + expect(body.data.openrouterApiKeyHint).toBe('secr'); + expect(body.data.ukvisajobsHeadless).toBe(false); }); it('rejects invalid settings updates and persists overrides', async () => { @@ -35,11 +44,19 @@ describe.sequential('Settings API routes', () => { const patchRes = await fetch(`${baseUrl}/api/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ searchTerms: ['engineer'] }), + body: JSON.stringify({ + searchTerms: ['engineer'], + rxresumeEmail: 'updated@example.com', + openrouterApiKey: 'updated-secret', + ukvisajobsHeadless: true, + }), }); const patchBody = await patchRes.json(); expect(patchBody.success).toBe(true); expect(patchBody.data.searchTerms).toEqual(['engineer']); expect(patchBody.data.overrideSearchTerms).toEqual(['engineer']); + expect(patchBody.data.rxresumeEmail).toBe('updated@example.com'); + expect(patchBody.data.openrouterApiKeyHint).toBe('upda'); + expect(patchBody.data.ukvisajobsHeadless).toBe(true); }); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 1813fca..082c6dd 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -1,6 +1,12 @@ import { Router, Request, Response } from 'express'; import { updateSettingsSchema } from '@shared/settings-schema.js'; import * as settingsRepo from '@server/repositories/settings.js'; +import { + applyEnvValue, + getEnvSettingsData, + normalizeEnvInput, + serializeEnvBoolean, +} from '@server/services/envSettings.js'; import { extractProjectsFromProfile, normalizeResumeProjectsSettings, @@ -97,6 +103,8 @@ settingsRouter.get('/', async (_req: Request, res: Response) => { : null; const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo; + const envSettings = await getEnvSettingsData(); + res.json({ success: true, data: { @@ -146,6 +154,7 @@ settingsRouter.get('/', async (_req: Request, res: Response) => { showSponsorInfo, defaultShowSponsorInfo, overrideShowSponsorInfo, + ...envSettings, }, }); } catch (error) { @@ -256,6 +265,73 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { await settingsRepo.setSetting('showSponsorInfo', value !== null ? (value ? '1' : '0') : null); } + if ('openrouterApiKey' in input) { + const value = normalizeEnvInput(input.openrouterApiKey); + await settingsRepo.setSetting('openrouterApiKey', value); + applyEnvValue('OPENROUTER_API_KEY', value); + } + + if ('rxresumeEmail' in input) { + const value = normalizeEnvInput(input.rxresumeEmail); + await settingsRepo.setSetting('rxresumeEmail', value); + applyEnvValue('RXRESUME_EMAIL', value); + } + + if ('rxresumePassword' in input) { + const value = normalizeEnvInput(input.rxresumePassword); + await settingsRepo.setSetting('rxresumePassword', value); + applyEnvValue('RXRESUME_PASSWORD', value); + } + + if ('basicAuthUser' in input) { + const value = normalizeEnvInput(input.basicAuthUser); + await settingsRepo.setSetting('basicAuthUser', value); + applyEnvValue('BASIC_AUTH_USER', value); + } + + if ('basicAuthPassword' in input) { + const value = normalizeEnvInput(input.basicAuthPassword); + await settingsRepo.setSetting('basicAuthPassword', value); + applyEnvValue('BASIC_AUTH_PASSWORD', value); + } + + if ('ukvisajobsEmail' in input) { + const value = normalizeEnvInput(input.ukvisajobsEmail); + await settingsRepo.setSetting('ukvisajobsEmail', value); + applyEnvValue('UKVISAJOBS_EMAIL', value); + } + + if ('ukvisajobsPassword' in input) { + const value = normalizeEnvInput(input.ukvisajobsPassword); + await settingsRepo.setSetting('ukvisajobsPassword', value); + applyEnvValue('UKVISAJOBS_PASSWORD', value); + } + + if ('ukvisajobsHeadless' in input) { + const value = input.ukvisajobsHeadless ?? null; + const serialized = serializeEnvBoolean(value); + await settingsRepo.setSetting('ukvisajobsHeadless', serialized); + applyEnvValue('UKVISAJOBS_HEADLESS', serialized); + } + + if ('webhookSecret' in input) { + const value = normalizeEnvInput(input.webhookSecret); + await settingsRepo.setSetting('webhookSecret', value); + applyEnvValue('WEBHOOK_SECRET', value); + } + + if ('notionApiKey' in input) { + const value = normalizeEnvInput(input.notionApiKey); + await settingsRepo.setSetting('notionApiKey', value); + applyEnvValue('NOTION_API_KEY', value); + } + + if ('notionDatabaseId' in input) { + const value = normalizeEnvInput(input.notionDatabaseId); + await settingsRepo.setSetting('notionDatabaseId', value); + applyEnvValue('NOTION_DATABASE_ID', value); + } + const overrideModel = await settingsRepo.getSetting('model'); const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; const model = overrideModel || defaultModel; @@ -338,6 +414,8 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { : null; const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo; + const envSettings = await getEnvSettingsData(); + res.json({ success: true, data: { @@ -387,6 +465,7 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { showSponsorInfo, defaultShowSponsorInfo, overrideShowSponsorInfo, + ...envSettings, }, }); } catch (error) { diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 246f0aa..8787a9d 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -86,11 +86,14 @@ export async function startServer(options?: { }; await import('../../db/migrate.js'); + const { applyStoredEnvOverrides } = await import('../../services/envSettings.js'); const { createApp } = await import('../../app.js'); const { closeDb } = await import('../../db/index.js'); const { getPipelineStatus } = await import('../../pipeline/index.js'); vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false }); + await applyStoredEnvOverrides(); + const app = createApp(); const server = app.listen(0); await new Promise((resolve) => server.once('listening', () => resolve())); diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts index b592371..4f39077 100644 --- a/orchestrator/src/server/app.ts +++ b/orchestrator/src/server/app.ts @@ -13,12 +13,19 @@ import { getDataDir } from './config/dataDir.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); function createBasicAuthGuard() { - const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER || ''; - const BASIC_AUTH_PASSWORD = process.env.BASIC_AUTH_PASSWORD || ''; - const basicAuthEnabled = BASIC_AUTH_USER.length > 0 && BASIC_AUTH_PASSWORD.length > 0; + 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 (!basicAuthEnabled) return false; + 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(); @@ -32,7 +39,7 @@ function createBasicAuthGuard() { if (separatorIndex === -1) return false; const user = decoded.slice(0, separatorIndex); const pass = decoded.slice(separatorIndex + 1); - return user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASSWORD; + return user === authUser && pass === authPass; } function isPublicReadOnlyRoute(method: string, path: string): boolean { @@ -48,7 +55,8 @@ function createBasicAuthGuard() { } const middleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (!basicAuthEnabled || !requiresAuth(req.method, req.path)) return next(); + const { enabled } = getAuthConfig(); + if (!enabled || !requiresAuth(req.method, req.path)) return next(); if (isAuthorized(req)) return next(); res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"'); res.status(401).send('Authentication required'); @@ -57,7 +65,7 @@ function createBasicAuthGuard() { return { middleware, isAuthorized, - basicAuthEnabled, + basicAuthEnabled: getAuthConfig().enabled, }; } diff --git a/orchestrator/src/server/index.ts b/orchestrator/src/server/index.ts index df29e1b..fc7e212 100644 --- a/orchestrator/src/server/index.ts +++ b/orchestrator/src/server/index.ts @@ -4,14 +4,18 @@ import './config/env.js'; import { createApp } from './app.js'; +import { applyStoredEnvOverrides } from './services/envSettings.js'; import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js'; -const app = createApp(); -const PORT = process.env.PORT || 3001; +async function startServer() { + await applyStoredEnvOverrides(); -// Start server -app.listen(PORT, async () => { - console.log(` + const app = createApp(); + const PORT = process.env.PORT || 3001; + + // Start server + app.listen(PORT, async () => { + console.log(` ╔═══════════════════════════════════════════════════════════╗ ║ ║ ║ 🚀 Job Ops Orchestrator ║ @@ -25,10 +29,13 @@ app.listen(PORT, async () => { ╚═══════════════════════════════════════════════════════════╝ `); - // Initialize visa sponsors service (downloads data if needed, starts scheduler) - try { - await initializeVisaSponsors(); - } catch (error) { - console.warn('⚠️ Failed to initialize visa sponsors service:', error); - } -}); + // Initialize visa sponsors service (downloads data if needed, starts scheduler) + try { + await initializeVisaSponsors(); + } catch (error) { + console.warn('⚠️ Failed to initialize visa sponsors service:', error); + } + }); +} + +void startServer(); diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 36b6f19..399581b 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -24,6 +24,17 @@ export type SettingKey = 'model' | 'jobspySites' | 'jobspyLinkedinFetchDescription' | 'showSponsorInfo' + | 'openrouterApiKey' + | 'rxresumeEmail' + | 'rxresumePassword' + | 'basicAuthUser' + | 'basicAuthPassword' + | 'ukvisajobsEmail' + | 'ukvisajobsPassword' + | 'ukvisajobsHeadless' + | 'webhookSecret' + | 'notionApiKey' + | 'notionDatabaseId' export async function getSetting(key: SettingKey): Promise { const [row] = await db.select().from(settings).where(eq(settings.key, key)) diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts new file mode 100644 index 0000000..201f209 --- /dev/null +++ b/orchestrator/src/server/services/envSettings.ts @@ -0,0 +1,109 @@ +import * as settingsRepo from '@server/repositories/settings.js'; +import { SettingKey } from '@server/repositories/settings.js'; + +const envDefaults: Record = { ...process.env }; + +const readableStringConfig: { settingKey: SettingKey, envKey: string }[] = [ + { settingKey: 'rxresumeEmail', envKey: 'RXRESUME_EMAIL' }, + { settingKey: 'ukvisajobsEmail', envKey: 'UKVISAJOBS_EMAIL' }, + { settingKey: 'basicAuthUser', envKey: 'BASIC_AUTH_USER' }, + { settingKey: 'notionDatabaseId', envKey: 'NOTION_DATABASE_ID' }, +]; + +const readableBooleanConfig: { settingKey: SettingKey, envKey: string, defaultValue: boolean }[] = [ + { settingKey: 'ukvisajobsHeadless', envKey: 'UKVISAJOBS_HEADLESS', defaultValue: true }, +]; + +const privateStringConfig: { settingKey: SettingKey, envKey: string, hintKey: string }[] = [ + { settingKey: 'openrouterApiKey', envKey: 'OPENROUTER_API_KEY', hintKey: 'openrouterApiKeyHint' }, + { settingKey: 'rxresumePassword', envKey: 'RXRESUME_PASSWORD', hintKey: 'rxresumePasswordHint' }, + { settingKey: 'ukvisajobsPassword', envKey: 'UKVISAJOBS_PASSWORD', hintKey: 'ukvisajobsPasswordHint' }, + { settingKey: 'basicAuthPassword', envKey: 'BASIC_AUTH_PASSWORD', hintKey: 'basicAuthPasswordHint' }, + { settingKey: 'webhookSecret', envKey: 'WEBHOOK_SECRET', hintKey: 'webhookSecretHint' }, + { settingKey: 'notionApiKey', envKey: 'NOTION_API_KEY', hintKey: 'notionApiKeyHint' }, +]; + +export function normalizeEnvInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function parseEnvBoolean(raw: string | null | undefined, defaultValue: boolean): boolean { + if (raw === undefined || raw === null || raw === '') return defaultValue; + if (raw === 'false' || raw === '0') return false; + return true; +} + +export function applyEnvValue(envKey: string, value: string | null): void { + if (value === null) { + const fallback = envDefaults[envKey]; + if (fallback === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = fallback; + } + return; + } + + process.env[envKey] = value; +} + +export function serializeEnvBoolean(value: boolean | null): string | null { + if (value === null) return null; + return value ? 'true' : 'false'; +} + +export async function applyStoredEnvOverrides(): Promise { + await Promise.all([ + ...readableStringConfig.map(async ({ settingKey, envKey }) => { + const override = await settingsRepo.getSetting(settingKey); + if (override === null) return; + applyEnvValue(envKey, normalizeEnvInput(override)); + }), + ...readableBooleanConfig.map(async ({ settingKey, envKey, defaultValue }) => { + const override = await settingsRepo.getSetting(settingKey); + if (override === null) return; + const parsed = parseEnvBoolean(override, defaultValue); + applyEnvValue(envKey, serializeEnvBoolean(parsed)); + }), + ...privateStringConfig.map(async ({ settingKey, envKey }) => { + const override = await settingsRepo.getSetting(settingKey); + if (override === null) return; + applyEnvValue(envKey, normalizeEnvInput(override)); + }), + ]); +} + +export async function getEnvSettingsData(): Promise> { + const readableValues: Record = {}; + const privateValues: Record = {}; + + for (const { settingKey, envKey } of readableStringConfig) { + const override = await settingsRepo.getSetting(settingKey); + const rawValue = override ?? process.env[envKey]; + readableValues[settingKey] = normalizeEnvInput(rawValue); + } + + for (const { settingKey, envKey, defaultValue } of readableBooleanConfig) { + const override = await settingsRepo.getSetting(settingKey); + const rawValue = override ?? process.env[envKey]; + readableValues[settingKey] = parseEnvBoolean(rawValue, defaultValue); + } + + for (const { settingKey, envKey, hintKey } of privateStringConfig) { + const override = await settingsRepo.getSetting(settingKey); + const rawValue = override ?? process.env[envKey]; + privateValues[hintKey] = rawValue ? rawValue.slice(0, 4) : null; + } + + return { + ...readableValues, + ...privateValues, + }; +} + +export const envSettingConfig = { + readableStringConfig, + readableBooleanConfig, + privateStringConfig, +}; diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index a4db7f2..237df50 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -24,6 +24,17 @@ export const updateSettingsSchema = z.object({ jobspySites: z.array(z.string().trim().min(1).max(50)).max(20).nullable().optional(), jobspyLinkedinFetchDescription: z.boolean().nullable().optional(), showSponsorInfo: z.boolean().nullable().optional(), + openrouterApiKey: z.string().trim().max(2000).nullable().optional(), + rxresumeEmail: z.string().trim().max(200).nullable().optional(), + rxresumePassword: z.string().trim().max(2000).nullable().optional(), + basicAuthUser: z.string().trim().max(200).nullable().optional(), + basicAuthPassword: z.string().trim().max(2000).nullable().optional(), + ukvisajobsEmail: z.string().trim().max(200).nullable().optional(), + ukvisajobsPassword: z.string().trim().max(2000).nullable().optional(), + ukvisajobsHeadless: z.boolean().nullable().optional(), + webhookSecret: z.string().trim().max(2000).nullable().optional(), + notionApiKey: z.string().trim().max(2000).nullable().optional(), + notionDatabaseId: z.string().trim().max(200).nullable().optional(), }); export type UpdateSettingsInput = z.infer; diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index d59c27f..16d7bb0 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -383,4 +383,15 @@ export interface AppSettings { showSponsorInfo: boolean; defaultShowSponsorInfo: boolean; overrideShowSponsorInfo: boolean | null; + openrouterApiKeyHint: string | null; + rxresumeEmail: string | null; + rxresumePasswordHint: string | null; + basicAuthUser: string | null; + basicAuthPasswordHint: string | null; + ukvisajobsEmail: string | null; + ukvisajobsPasswordHint: string | null; + ukvisajobsHeadless: boolean; + webhookSecretHint: string | null; + notionApiKeyHint: string | null; + notionDatabaseId: string | null; }