readonly mode

This commit is contained in:
DaKheera47 2026-01-16 01:33:43 +00:00
parent 8aca53a57d
commit 9dd8e1432b
6 changed files with 213 additions and 66 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View 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;
}

View 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);
});
});

View File

@ -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(`