Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 149 additions & 50 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
/**
* apps/api — ApplyPilot Gateway (Engineering Marble).
*
* Composition:
* request-id → mono-logger → security → cors → limiter
* → routes (/v1/*, /api/* alias)
* → error envelope
*
* Design notes:
* • Every response is wrapped in the canonical Envelope (lib/envelope.ts).
* • `/healthz`, `/readyz`, `/v1/meta` form the operational triad.
* • `/` is a plain-text curl-friendly spec sheet of the surface.
*/

import compression from 'compression';
import cors from 'cors';
import dotenv from 'dotenv';
import express, { type Request, type Response } from 'express';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import rateLimit from 'express-rate-limit';

import { ok } from './lib/envelope.js';
import { banner, endpointLine, m, rule } from './lib/marble.js';
import { monoLogger } from './lib/mono-logger.js';
import { requestId } from './lib/request-id.js';
import { errorHandler } from './middleware/errorHandler.js';
import { analyticsRouter } from './routes/analytics.js';
import { authRouter } from './routes/auth.js';
import { jobsRouter } from './routes/jobs.js';
import { analyticsRouter } from './routes/analytics.js';
import { metaRouter } from './routes/meta.js';
import { settingsRouter } from './routes/settings.js';
import { tailorRouter } from './routes/tailor.js';
import { websocketHandler } from './websocket/handler.js';
Expand All @@ -22,67 +40,148 @@ const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });

const PORT = process.env.PORT || 8080;
const PORT = Number(process.env.PORT) || 8080;
const VERSION = process.env.API_VERSION || '0.1.0';
const STARTED_AT = new Date();

app.disable('x-powered-by');

// ── Request pipeline ───────────────────────────────────────────────────
app.use(requestId);
app.use(monoLogger);

// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", "ws:", "wss:"],
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", 'ws:', 'wss:'],
},
},
},
}));

app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
}));

// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later.',
});
app.use(limiter);
})
);

app.use(
cors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
})
);

app.use(
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
})
);

// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(compression());

// Logging
app.use(morgan('combined'));
// ── Triad: healthz · readyz · meta ─────────────────────────────────────
app.get('/healthz', (req: Request, res: Response) => {
ok(req, res, { status: 'live', uptime: Math.floor(process.uptime()) });
});

// Compression
app.use(compression());
app.get('/readyz', (req: Request, res: Response) => {
// Deliberately cheap: no DB ping here. Dependency-aware readiness
// lives at /v1/meta/ready so consumers can opt-in explicitly.
ok(req, res, { status: 'ready' });
});

// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
// Back-compat aliases (pre-v1)
app.get('/health', (req, res) => ok(req, res, { status: 'ok' }));

// ── Curl-friendly index ────────────────────────────────────────────────
app.get('/', (_req: Request, res: Response) => {
res.type('text/plain; charset=utf-8').send(
[
banner('APPLYPILOT / GATEWAY', [
{ key: 'version', value: VERSION },
{ key: 'node', value: process.version },
{ key: 'mode', value: process.env.NODE_ENV || 'development' },
{ key: 'started', value: STARTED_AT.toISOString() },
]),
'',
rule('// ENDPOINTS'),
endpointLine('GET', '/healthz', 'liveness probe'),
endpointLine('GET', '/readyz', 'readiness probe'),
endpointLine('GET', '/v1/meta', 'build + runtime metadata'),
endpointLine('GET', '/v1/jobs', 'list job applications'),
endpointLine('POST', '/v1/jobs', 'create job from JD text'),
endpointLine('GET', '/v1/jobs/:id', 'fetch a single job'),
endpointLine('POST', '/v1/jobs/:id/analyze', 'run JD analysis'),
endpointLine('GET', '/v1/analytics/dashboard', 'pipeline metrics'),
endpointLine('GET', '/v1/analytics/funnel', 'stage funnel'),
endpointLine('GET', '/v1/analytics/costs', 'LLM cost summary'),
endpointLine('GET', '/v1/settings', 'user / llm config'),
endpointLine('POST', '/v1/tailor/resume', 'tailor resume for a job'),
endpointLine('WS ', '/ws', 'progress + notifications'),
'',
rule('// RESPONSE ENVELOPE'),
m.graphite(' ok ') +
m.muted('boolean — true on success, false on failure'),
m.graphite(' data ') + m.muted('<T> — present when ok: true'),
m.graphite(' error ') +
m.muted('{code,message,details?} — present when ok: false'),
m.graphite(' meta ') +
m.muted('{ requestId, timestamp } — always present'),
'',
m.vein('── ') + m.muted('docs: README.md · source: https://github.com/lavkushry/ApplyBot'),
'',
].join('\n')
);
});

// API routes
app.use('/api/auth', authRouter);
app.use('/api/jobs', jobsRouter);
app.use('/api/analytics', analyticsRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/tailor', tailorRouter);
// ── Routes ─────────────────────────────────────────────────────────────
const mount = (prefix: string): void => {
app.use(`${prefix}/meta`, metaRouter);
app.use(`${prefix}/auth`, authRouter);
app.use(`${prefix}/jobs`, jobsRouter);
app.use(`${prefix}/analytics`, analyticsRouter);
app.use(`${prefix}/settings`, settingsRouter);
app.use(`${prefix}/tailor`, tailorRouter);
};

// WebSocket handling
websocketHandler(wss);
mount('/v1');
mount('/api'); // legacy alias for the current web client

// Error handling
app.use(errorHandler);
// ── WebSocket ──────────────────────────────────────────────────────────
websocketHandler(wss);

// 404 handler
// ── 404 + error envelope ───────────────────────────────────────────────
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
res.status(404).json({
ok: false,
error: { code: 'NOT_FOUND', message: `No route for ${req.method} ${req.path}` },
meta: {
requestId: (req as Request & { id?: string }).id ?? 'unknown',
timestamp: new Date().toISOString(),
},
});
});

app.use(errorHandler);

// ── Listen + marble boot banner ────────────────────────────────────────
server.listen(PORT, () => {
console.log(`🚀 API server running on port ${PORT}`);
console.log(`📡 WebSocket server ready on ws://localhost:${PORT}/ws`);
process.stdout.write(
'\n' +
banner('APPLYPILOT / GATEWAY', [
{ key: 'version', value: VERSION, tone: 'signal' },
{ key: 'port', value: String(PORT) },
{ key: 'ws', value: `ws://0.0.0.0:${PORT}/ws` },
{ key: 'node', value: process.version },
{ key: 'mode', value: process.env.NODE_ENV || 'development' },
]) +
'\n' +
rule('// LIVE') +
'\n'
);
});

export { app, server, wss };
66 changes: 66 additions & 0 deletions apps/api/src/lib/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* envelope.ts — every response passes through here.
*
* Canonical shape, borrowed from engineered RPC conventions:
*
* success → { ok: true, data: <T>, meta?: { requestId, timestamp } }
* failure → { ok: false, error: { code, message, details? }, meta: { requestId, timestamp } }
*
* Helpers below also set the correct status code so routes read as
* single-line intents: `return ok(res, { job })` / `return fail(res, 404, 'NOT_FOUND', 'Job not found')`.
*/

import type { Request, Response } from 'express';

export interface EnvelopeMeta {
requestId: string;
timestamp: string;
}

export interface SuccessEnvelope<T> {
ok: true;
data: T;
meta: EnvelopeMeta;
}

export interface FailureEnvelope {
ok: false;
error: {
code: string;
message: string;
details?: unknown;
};
meta: EnvelopeMeta;
}

export type Envelope<T> = SuccessEnvelope<T> | FailureEnvelope;

const meta = (req: Request): EnvelopeMeta => ({
requestId: (req as Request & { id?: string }).id ?? 'unknown',
timestamp: new Date().toISOString(),
});

export function ok<T>(
req: Request,
res: Response,
data: T,
status = 200
): Response<SuccessEnvelope<T>> {
return res.status(status).json({ ok: true, data, meta: meta(req) });
}

export function fail(
req: Request,
res: Response,
status: number,
code: string,
message: string,
details?: unknown
): Response<FailureEnvelope> {
const payload: FailureEnvelope = {
ok: false,
error: { code, message, ...(details !== undefined ? { details } : {}) },
meta: meta(req),
};
return res.status(status).json(payload);
}
Loading