From 9dd8e1432b08c277b2909875c266d70204df91b5 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 16 Jan 2026 01:33:43 +0000 Subject: [PATCH] readonly mode --- .env.example | 28 +----- README.md | 5 + documentation/self-hosting.md | 1 + orchestrator/src/server/app.ts | 93 ++++++++++++++++++ orchestrator/src/server/basic-auth.test.ts | 107 +++++++++++++++++++++ orchestrator/src/server/index.ts | 45 +-------- 6 files changed, 213 insertions(+), 66 deletions(-) create mode 100644 orchestrator/src/server/app.ts create mode 100644 orchestrator/src/server/basic-auth.test.ts diff --git a/.env.example b/.env.example index 1a10850..9c5d7b0 100644 --- a/.env.example +++ b/.env.example @@ -13,29 +13,11 @@ MODEL=openai/gpt-4o-mini RXRESUME_EMAIL=your_email@example.com RXRESUME_PASSWORD=your_password_here -# Pipeline configuration -PIPELINE_TOP_N=10 -PIPELINE_MIN_SCORE=50 - -# Optional: Notion integration for job tracking -NOTION_API_KEY= -NOTION_DATABASE_ID= - -# Optional: Webhook secret for n8n automation -WEBHOOK_SECRET= -PIPELINE_WEBHOOK_URL= -JOB_COMPLETE_WEBHOOK_URL= - -# ============================================================================= -# JobSpy (Indeed/LinkedIn scraping) - optional -# ============================================================================= -# These control the Python JobSpy scraper used by the pipeline. - -JOBSPY_LOCATION=UK -JOBSPY_RESULTS_WANTED=200 -JOBSPY_HOURS_OLD=72 -JOBSPY_COUNTRY_INDEED=UK -JOBSPY_LINKEDIN_FETCH_DESCRIPTION=1 +# Optional: Basic Auth for write access (read-only without auth) +# When set, all write actions (POST/PATCH/DELETE) require Basic Auth. +# Browsing remains public and read-only. +BASIC_AUTH_USER= +BASIC_AUTH_PASSWORD= # ============================================================================= # UKVisaJobs (UK visa sponsorship jobs) - optional diff --git a/README.md b/README.md index 686fdce..767f2eb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ Essential variables in `.env`: Technical breakdowns here: `documentation/extractors/README.md` Orchestrator docs here: `documentation/orchestrator.md` + +## Read-only mode (Basic Auth) + +Set `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD` in `.env` to make the app read-only for the public. +All write actions (POST/PATCH/DELETE) require Basic Auth; browsing and viewing remain public. 2. Put your exported RXResume JSON at `resume-generator/base.json`. 3. Start: `docker compose up -d --build` 4. Open: diff --git a/documentation/self-hosting.md b/documentation/self-hosting.md index 52e442c..778b074 100644 --- a/documentation/self-hosting.md +++ b/documentation/self-hosting.md @@ -20,6 +20,7 @@ Open `.env` and set at least: Optional but commonly used: - `RXRESUME_EMAIL`, `RXRESUME_PASSWORD` (for CV PDF generation) - `UKVISAJOBS_EMAIL`, `UKVISAJOBS_PASSWORD` (if you want to scrape UKVisaJobs) +- `BASIC_AUTH_USER`, `BASIC_AUTH_PASSWORD` (read-only public, auth required for writes) ## 2) Provide a base resume JSON diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts new file mode 100644 index 0000000..2184f84 --- /dev/null +++ b/orchestrator/src/server/app.ts @@ -0,0 +1,93 @@ +/** + * Express app factory (useful for tests). + */ + +import express from 'express'; +import cors from 'cors'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { apiRouter } from './api/index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function buildBasicAuthMiddleware() { + 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 isAuthorized(req: express.Request): boolean { + if (!basicAuthEnabled) return true; + 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 === BASIC_AUTH_USER && pass === BASIC_AUTH_PASSWORD; + } + + function requiresAuth(method: string): boolean { + return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()); + } + + return (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (!basicAuthEnabled || !requiresAuth(req.method)) return next(); + if (isAuthorized(req)) return next(); + res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"'); + res.status(401).send('Authentication required'); + }; +} + +export function createApp() { + const app = express(); + + app.use(cors()); + app.use(express.json()); + + // Logging middleware + app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + console.log(`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`); + }); + next(); + }); + + // Optional Basic Auth for write access (read-only by default) + app.use(buildBasicAuthMiddleware()); + + // API routes + app.use('/api', apiRouter); + + // Serve static files for generated PDFs + const pdfDir = process.env.DATA_DIR + ? join(process.env.DATA_DIR, 'pdfs') + : join(__dirname, '../../data/pdfs'); + app.use('/pdfs', express.static(pdfDir)); + + // Health check + app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); + }); + + // Serve client app in production + if (process.env.NODE_ENV === 'production') { + const clientDir = join(__dirname, '../../dist/client'); + app.use(express.static(clientDir)); + + // SPA fallback + app.get('*', (_req, res) => { + res.sendFile(join(clientDir, 'index.html')); + }); + } + + return app; +} diff --git a/orchestrator/src/server/basic-auth.test.ts b/orchestrator/src/server/basic-auth.test.ts new file mode 100644 index 0000000..91ad379 --- /dev/null +++ b/orchestrator/src/server/basic-auth.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import type { Server } from 'http'; +import { createApp } from './app.js'; + +const originalEnv = { ...process.env }; + +function buildAuthHeader(user: string, pass: string): string { + const token = Buffer.from(`${user}:${pass}`).toString('base64'); + return `Basic ${token}`; +} + +async function startServer(): Promise<{ server: Server; baseUrl: string }> { + const app = createApp(); + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server address'); + } + return { server, baseUrl: `http://127.0.0.1:${address.port}` }; +} + +describe.sequential('Basic Auth read-only enforcement', () => { + let server: Server | null = null; + let baseUrl = ''; + let tempDir = ''; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'job-ops-auth-test-')); + process.env.DATA_DIR = tempDir; + process.env.NODE_ENV = 'test'; + }); + + afterEach(async () => { + if (server) { + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + process.env = { ...originalEnv }; + }); + + it('allows read-only GETs without auth when Basic Auth is enabled', async () => { + process.env.BASIC_AUTH_USER = 'user'; + process.env.BASIC_AUTH_PASSWORD = 'pass'; + + ({ server, baseUrl } = await startServer()); + + const healthRes = await fetch(`${baseUrl}/health`); + expect(healthRes.status).toBe(200); + + const pdfRes = await fetch(`${baseUrl}/pdfs/does-not-exist.pdf`); + expect(pdfRes.status).toBe(404); + }); + + it('blocks POST/PATCH/DELETE without auth when Basic Auth is enabled', async () => { + process.env.BASIC_AUTH_USER = 'user'; + process.env.BASIC_AUTH_PASSWORD = 'pass'; + + ({ server, baseUrl } = await startServer()); + + const postRes = await fetch(`${baseUrl}/api/jobs/123/skip`, { method: 'POST' }); + expect(postRes.status).toBe(401); + expect(postRes.headers.get('www-authenticate')).toMatch(/Basic/); + + const patchRes = await fetch(`${baseUrl}/api/jobs/123`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'ready' }), + }); + expect(patchRes.status).toBe(401); + + const deleteRes = await fetch(`${baseUrl}/api/jobs/status/skipped`, { method: 'DELETE' }); + expect(deleteRes.status).toBe(401); + }); + + it('allows writes with valid Basic Auth when enabled', async () => { + process.env.BASIC_AUTH_USER = 'user'; + process.env.BASIC_AUTH_PASSWORD = 'pass'; + + ({ server, baseUrl } = await startServer()); + + const authHeader = buildAuthHeader('user', 'pass'); + const res = await fetch(`${baseUrl}/api/jobs/123/skip`, { + method: 'POST', + headers: { Authorization: authHeader }, + }); + + expect(res.status).not.toBe(401); + }); + + it('does not require auth when Basic Auth is disabled', async () => { + delete process.env.BASIC_AUTH_USER; + delete process.env.BASIC_AUTH_PASSWORD; + + ({ server, baseUrl } = await startServer()); + + const res = await fetch(`${baseUrl}/api/jobs/123/skip`, { method: 'POST' }); + expect(res.status).not.toBe(401); + }); +}); diff --git a/orchestrator/src/server/index.ts b/orchestrator/src/server/index.ts index 5911f70..7b1c54f 100644 --- a/orchestrator/src/server/index.ts +++ b/orchestrator/src/server/index.ts @@ -2,60 +2,19 @@ * Express server entry point. */ -import express from 'express'; -import cors from 'cors'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { config } from 'dotenv'; -import { apiRouter } from './api/index.js'; +import { createApp } from './app.js'; import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js'; // Load environment variables from orchestrator root const __dirname = dirname(fileURLToPath(import.meta.url)); config({ path: join(__dirname, '../../.env') }); -const app = express(); +const app = createApp(); const PORT = process.env.PORT || 3001; -// Middleware -app.use(cors()); -app.use(express.json()); - -// Logging middleware -app.use((req, res, next) => { - const start = Date.now(); - res.on('finish', () => { - const duration = Date.now() - start; - console.log(`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`); - }); - next(); -}); - -// API routes -app.use('/api', apiRouter); - -// Serve static files for generated PDFs -const pdfDir = process.env.DATA_DIR - ? join(process.env.DATA_DIR, 'pdfs') - : join(__dirname, '../../data/pdfs'); -app.use('/pdfs', express.static(pdfDir)); - -// Health check -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// Serve client app in production -if (process.env.NODE_ENV === 'production') { - const clientDir = join(__dirname, '../../dist/client'); - app.use(express.static(clientDir)); - - // SPA fallback - app.get('*', (req, res) => { - res.sendFile(join(clientDir, 'index.html')); - }); -} - // Start server app.listen(PORT, async () => { console.log(`