readonly mode
This commit is contained in:
parent
8aca53a57d
commit
9dd8e1432b
28
.env.example
28
.env.example
@ -13,29 +13,11 @@ MODEL=openai/gpt-4o-mini
|
|||||||
RXRESUME_EMAIL=your_email@example.com
|
RXRESUME_EMAIL=your_email@example.com
|
||||||
RXRESUME_PASSWORD=your_password_here
|
RXRESUME_PASSWORD=your_password_here
|
||||||
|
|
||||||
# Pipeline configuration
|
# Optional: Basic Auth for write access (read-only without auth)
|
||||||
PIPELINE_TOP_N=10
|
# When set, all write actions (POST/PATCH/DELETE) require Basic Auth.
|
||||||
PIPELINE_MIN_SCORE=50
|
# Browsing remains public and read-only.
|
||||||
|
BASIC_AUTH_USER=
|
||||||
# Optional: Notion integration for job tracking
|
BASIC_AUTH_PASSWORD=
|
||||||
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
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# UKVisaJobs (UK visa sponsorship jobs) - optional
|
# UKVisaJobs (UK visa sponsorship jobs) - optional
|
||||||
|
|||||||
@ -35,6 +35,11 @@ Essential variables in `.env`:
|
|||||||
|
|
||||||
Technical breakdowns here: `documentation/extractors/README.md`
|
Technical breakdowns here: `documentation/extractors/README.md`
|
||||||
Orchestrator docs here: `documentation/orchestrator.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`.
|
2. Put your exported RXResume JSON at `resume-generator/base.json`.
|
||||||
3. Start: `docker compose up -d --build`
|
3. Start: `docker compose up -d --build`
|
||||||
4. Open:
|
4. Open:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ Open `.env` and set at least:
|
|||||||
Optional but commonly used:
|
Optional but commonly used:
|
||||||
- `RXRESUME_EMAIL`, `RXRESUME_PASSWORD` (for CV PDF generation)
|
- `RXRESUME_EMAIL`, `RXRESUME_PASSWORD` (for CV PDF generation)
|
||||||
- `UKVISAJOBS_EMAIL`, `UKVISAJOBS_PASSWORD` (if you want to scrape UKVisaJobs)
|
- `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
|
## 2) Provide a base resume JSON
|
||||||
|
|
||||||
|
|||||||
93
orchestrator/src/server/app.ts
Normal file
93
orchestrator/src/server/app.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
107
orchestrator/src/server/basic-auth.test.ts
Normal file
107
orchestrator/src/server/basic-auth.test.ts
Normal file
@ -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<void>((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<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,60 +2,19 @@
|
|||||||
* Express server entry point.
|
* Express server entry point.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { config } from 'dotenv';
|
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';
|
import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js';
|
||||||
|
|
||||||
// Load environment variables from orchestrator root
|
// Load environment variables from orchestrator root
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
config({ path: join(__dirname, '../../.env') });
|
config({ path: join(__dirname, '../../.env') });
|
||||||
|
|
||||||
const app = express();
|
const app = createApp();
|
||||||
const PORT = process.env.PORT || 3001;
|
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
|
// Start server
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`
|
console.log(`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user