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_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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
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(`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user