Runtime hardening, logs, rate limits
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-08-28 18:03:45 +00:00
parent 893757646b
commit 888bdd2939
17 changed files with 1508 additions and 180 deletions

View File

@ -1 +1 @@
afd62e0deab27814cfa0067f1fae1dc4ad79e7dd-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
fb83dd6424562765662889aea6436fdb4b1b975f-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -19,6 +19,8 @@ COPY --chown=app:app src/assets/ ./src/assets/
COPY --chown=app:app backend/data/ ./backend/data/
RUN mkdir -p /run/secrets && chown -R app:app /run/secrets
RUN mkdir -p /data/uploads && chown -R app:app /data
USER app
CMD ["node", "backend/server3.js"]

View File

@ -50,6 +50,7 @@ try {
await initEncryption(); // <-- wrap in try/catch
const db = pool.raw || pool; // <-- bypass DAO wrapper for canary ops
const DB_POOL_SIZE = 12;
// quick connectivity check
await db.query('SELECT 1');
@ -86,16 +87,197 @@ try {
const app = express();
const PORT = process.env.SERVER1_PORT || 5000;
app.disable('x-powered-by');
app.use(express.json());
app.set('trust proxy', 1); // important if you're behind a proxy/HTTPS terminator
app.use(express.json({ limit: '1mb' }));
if (process.env.NODE_ENV === 'prod') app.set('trust proxy', 1); // important if you're behind a proxy/HTTPS terminator
app.use(cookieParser());
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
})
);
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
app.use((req, res, next) => {
if (req.path.startsWith('/api/')) res.type('application/json');
next();
});
// --- Request ID + minimal audit log for /api/* ---
function getRequestId(req, res) {
const hdr = req.headers['x-request-id'];
if (typeof hdr === 'string' && hdr) return hdr; // from Nginx
const rid = crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`;
res.setHeader('X-Request-ID', rid);
return rid;
}
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
const rid = getRequestId(req, res);
const t0 = process.hrtime.bigint();
res.on('finish', () => {
const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
const out = {
ts: new Date().toISOString(),
rid,
ip: req.ip || req.headers['x-forwarded-for'] || '',
method: req.method,
path: req.path,
status: res.statusCode,
dur_ms: durMs,
bytes_sent: Number(res.getHeader('Content-Length') || 0),
userId: req.userId || req.id || null
};
try { console.log(JSON.stringify(out)); } catch {}
});
next();
});
// ---- RUNTIME: minimal audit logging (API only, redacted) ----
function pickIp(req) {
// trust proxy already set in your apps
return req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '';
}
function redactHeaders(h) {
const out = { ...h };
delete out.authorization;
delete out.cookie;
delete out['x-forwarded-for'];
return out;
}
function sampleBody(b) {
if (!b || typeof b !== 'object') return undefined;
// avoid logging PII: show keys + small snippet
const keys = Object.keys(b);
const preview = {};
for (const k of keys.slice(0, 12)) {
const v = b[k];
preview[k] = typeof v === 'string' ? (v.length > 80 ? v.slice(0, 80) + '…' : v) : (Array.isArray(v) ? `[array:${v.length}]` : typeof v);
}
return preview;
}
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
// correlation id
const rid = req.headers['x-request-id'] || crypto.randomUUID?.() || String(Date.now());
res.setHeader('X-Request-ID', rid);
const t0 = process.hrtime.bigint();
// capture minimal request data
const reqLog = {
ts: new Date().toISOString(),
rid,
ip: pickIp(req),
method: req.method,
path: req.path,
userId: req.userId || req.id || null, // populated by your auth middleware on many routes
ua: req.headers['user-agent'] || '',
hdr: redactHeaders(req.headers),
body: sampleBody(req.body)
};
res.on('finish', () => {
const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
const out = {
...reqLog,
status: res.statusCode,
dur_ms: durMs,
bytes_sent: Number(res.getHeader('Content-Length') || 0)
};
// one line JSON per request
try { console.log(JSON.stringify(out)); } catch {}
});
next();
});
// ---- RUNTIME: never cache API responses ----
app.use((req, res, next) => {
if (req.path.startsWith('/api/')) {
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
next();
});
process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
// ---- RUNTIME: enforce JSON on API writes (with narrow exceptions) ----
const MUST_JSON = new Set(['POST','PUT','PATCH']);
const EXEMPT_PATHS = [
// server3
/^\/api\/premium\/resume\/optimize$/, // multer (multipart/form-data)
/^\/api\/premium\/stripe\/webhook$/, // Stripe (express.raw)
// add others if truly needed
];
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
if (!MUST_JSON.has(req.method)) return next();
if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next();
const ct = req.headers['content-type'] || '';
if (!ct.toLowerCase().includes('application/json')) {
return res.status(415).json({ error: 'unsupported_media_type' });
}
next();
});
// ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ----
app.use((req, _res, next) => {
const MAX_ARRAY = 20; // sane cap; adjust if you truly need more
const sanitize = (obj) => {
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) {
const v = obj[k];
if (Array.isArray(v)) {
// keep first value semantics + bound array size
obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null);
if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons
}
}
};
sanitize(req.query);
sanitize(req.body);
next();
});
// ---- RUNTIME: reject request bodies on GET/HEAD ----
app.use((req, res, next) => {
if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) {
return res.status(400).json({ error: 'no_body_allowed' });
}
next();
});
// ---- RUNTIME: last-resort error sanitizer ----
app.use((err, req, res, _next) => {
// dont double-send
if (res.headersSent) return;
// map a few known errors cleanly
if (err?.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'file_too_large', limit_mb: 10 });
}
if (err?.message && String(err.message).startsWith('blocked_outbound_host:')) {
return res.status(400).json({ error: 'blocked_outbound_host' });
}
if (err?.message === 'unsupported_type') {
return res.status(415).json({ error: 'unsupported_type' });
}
// default: generic 500 without internals
console.error('[unhandled]', err?.message || err); // logs to stderr only
return res.status(500).json({ error: 'Server error' });
});
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
@ -104,7 +286,7 @@ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.filter(Boolean);
function sessionCookieOptions() {
const IS_PROD = process.env.NODE_ENV === 'production';
const IS_PROD = process.env.NODE_ENV === 'prod';
const CROSS_SITE = process.env.CROSS_SITE_COOKIES === '1'; // set to "1" if FE and API are different sites
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
@ -190,7 +372,6 @@ app.get('/healthz', async (_req, res) => {
return res.status(ready ? 200 : 503).json(out);
});
// Password reset token table (MySQL)
try {
const db = pool.raw || pool;
@ -237,35 +418,6 @@ app.use(
})
);
// Handle preflight requests explicitly
app.options('*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
res.setHeader('Access-Control-Allow-Credentials', 'true'); // <-- add this
res.status(200).end();
});
// Add HTTP headers for security
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
res.setHeader('Content-Security-Policy', "default-src 'self';");
res.removeHeader('X-Powered-By');
next();
});
// Force Content-Type to application/json on all responses
app.use((req, res, next) => {
res.setHeader('Content-Type', 'application/json');
next();
});
// keep tight on request
const pwRequestLimiter = rateLimit({
windowMs: 30 * 1000,
@ -627,7 +779,11 @@ app.post('/api/register', async (req, res) => {
* Body: { username, password }
* Returns JWT signed with user_profile.id
*/
app.post('/api/signin', async (req, res) => {
const signinLimiter = rateLimit({ windowMs: 15*60*1000, max: 50, standardHeaders: true, legacyHeaders: false });
app.post('/api/signin', signinLimiter, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res
@ -963,6 +1119,13 @@ app.post('/api/activate-premium', requireAuth, async (req, res) => {
}
});
app.use((err, req, res, _next) => {
if (res.headersSent) return;
const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res);
console.error(`[ref ${rid}]`, err?.message || err);
// map known cases if you have them; otherwise generic:
return res.status(500).json({ error: 'Server error', ref: rid });
});
/* ------------------------------------------------------------------
START SERVER

View File

@ -42,6 +42,7 @@ const CIP_TO_SOC_PATH = path.join(DATA_DIR, 'CIP_to_ONET_SOC.xlsx');
const INSTITUTION_DATA_PATH = path.join(DATA_DIR, 'Institution_data.json');
const SALARY_DB_PATH = path.join(ROOT_DIR, 'salary_info.db');
const USER_PROFILE_DB_PATH = path.join(ROOT_DIR, 'user_profile.db');
const DB_POOL_SIZE = 6;
for (const p of [CIP_TO_SOC_PATH, INSTITUTION_DATA_PATH, SALARY_DB_PATH, USER_PROFILE_DB_PATH]) {
if (!fs.existsSync(p)) {
@ -59,6 +60,36 @@ const chatLimiter = rateLimit({
});
// ── RUNTIME PROTECTION: outbound host allowlist (server2) ──
const OUTBOUND_ALLOW = new Set([
'services.onetcenter.org', // O*NET
'maps.googleapis.com', // Google Distance
'api.openai.com' // Free chat (chatFreeEndpoint)
]);
// Guard global fetch (Node 20+)
const _fetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const u = new URL(typeof input === 'string' ? input : input.url, 'http://local/');
// allow relative/internal URLs (no hostname)
if (!u.hostname || u.hostname === 'local') return _fetch(input, init);
if (!OUTBOUND_ALLOW.has(u.hostname)) throw new Error(`blocked_outbound_host:${u.hostname}`);
return _fetch(input, init);
};
// Guard axios
axios.interceptors.request.use((cfg) => {
try {
const u = cfg.baseURL ? new URL(cfg.url, cfg.baseURL) : new URL(cfg.url, 'http://local/');
if (!u.hostname || u.hostname === 'local') return cfg; // internal/relative
if (!OUTBOUND_ALLOW.has(u.hostname)) return Promise.reject(new Error(`blocked_outbound_host:${u.hostname}`));
} catch { /* leave internal relatives alone */ }
return cfg;
});
// ── helpers ─────────────────────────────────────────────────────────
const normTitle = (s='') =>
String(s)
@ -141,6 +172,189 @@ try {
const app = express();
const PORT = process.env.SERVER2_PORT || 5001;
app.use(cookieParser());
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '1mb' }));
// --- Request ID + minimal audit log for /api/* ---
function getRequestId(req, res) {
const hdr = req.headers['x-request-id'];
if (typeof hdr === 'string' && hdr) return hdr; // from Nginx
const rid = crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`;
res.setHeader('X-Request-ID', rid);
return rid;
}
// ---- RUNTIME: enforce JSON on API writes (with narrow exceptions) ----
const MUST_JSON = new Set(['POST','PUT','PATCH']);
const EXEMPT_PATHS = [
// server3
/^\/api\/premium\/resume\/optimize$/, // multer (multipart/form-data)
/^\/api\/premium\/stripe\/webhook$/, // Stripe (express.raw)
// add others if truly needed
];
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
if (!MUST_JSON.has(req.method)) return next();
if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next();
const ct = req.headers['content-type'] || '';
if (!ct.toLowerCase().includes('application/json')) {
return res.status(415).json({ error: 'unsupported_media_type' });
}
next();
});
// ---- RUNTIME: last-resort error sanitizer ----
app.use((err, req, res, _next) => {
// dont double-send
if (res.headersSent) return;
// map a few known errors cleanly
if (err?.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'file_too_large', limit_mb: 10 });
}
if (err?.message && String(err.message).startsWith('blocked_outbound_host:')) {
return res.status(400).json({ error: 'blocked_outbound_host' });
}
if (err?.message === 'unsupported_type') {
return res.status(415).json({ error: 'unsupported_type' });
}
// default: generic 500 without internals
console.error('[unhandled]', err?.message || err); // logs to stderr only
return res.status(500).json({ error: 'Server error' });
});
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
const rid = getRequestId(req, res);
const t0 = process.hrtime.bigint();
res.on('finish', () => {
const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
const out = {
ts: new Date().toISOString(),
rid,
ip: req.ip || req.headers['x-forwarded-for'] || '',
method: req.method,
path: req.path,
status: res.statusCode,
dur_ms: durMs,
bytes_sent: Number(res.getHeader('Content-Length') || 0),
userId: req.userId || req.id || null
};
try { console.log(JSON.stringify(out)); } catch {}
});
next();
});
// ---- RUNTIME: minimal audit logging (API only, redacted) ----
function pickIp(req) {
// trust proxy already set in your apps
return req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '';
}
function redactHeaders(h) {
const out = { ...h };
delete out.authorization;
delete out.cookie;
delete out['x-forwarded-for'];
return out;
}
function sampleBody(b) {
if (!b || typeof b !== 'object') return undefined;
// avoid logging PII: show keys + small snippet
const keys = Object.keys(b);
const preview = {};
for (const k of keys.slice(0, 12)) {
const v = b[k];
preview[k] = typeof v === 'string' ? (v.length > 80 ? v.slice(0, 80) + '…' : v) : (Array.isArray(v) ? `[array:${v.length}]` : typeof v);
}
return preview;
}
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
// correlation id
const rid = req.headers['x-request-id'] || crypto.randomUUID?.() || String(Date.now());
res.setHeader('X-Request-ID', rid);
const t0 = process.hrtime.bigint();
// capture minimal request data
const reqLog = {
ts: new Date().toISOString(),
rid,
ip: pickIp(req),
method: req.method,
path: req.path,
userId: req.userId || req.id || null, // populated by your auth middleware on many routes
ua: req.headers['user-agent'] || '',
hdr: redactHeaders(req.headers),
body: sampleBody(req.body)
};
res.on('finish', () => {
const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
const out = {
...reqLog,
status: res.statusCode,
dur_ms: durMs,
bytes_sent: Number(res.getHeader('Content-Length') || 0)
};
// one line JSON per request
try { console.log(JSON.stringify(out)); } catch {}
});
next();
});
// ---- RUNTIME: never cache API responses ----
app.use((req, res, next) => {
if (req.path.startsWith('/api/')) {
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
next();
});
process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
// ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ----
app.use((req, _res, next) => {
const MAX_ARRAY = 20; // sane cap; adjust if you truly need more
const sanitize = (obj) => {
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) {
const v = obj[k];
if (Array.isArray(v)) {
// keep first value semantics + bound array size
obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null);
if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons
}
}
};
sanitize(req.query);
sanitize(req.body);
next();
});
// ---- RUNTIME: reject request bodies on GET/HEAD ----
app.use((req, res, next) => {
if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) {
return res.status(400).json({ error: 'no_body_allowed' });
}
next();
});
function fprPathFromEnv() {
const p = (process.env.DEK_PATH || '').trim();
@ -316,29 +530,22 @@ app.use(
/* 4 — Dynamic CORS / pre-flight handling */
app.use((req, res, next) => {
const origin = req.headers.origin;
const origin = req.headers.origin || '';
/* 4a — Whitelisted origins (credentials allowed) */
if (origin && allowedOrigins.includes(origin)) {
// A) No Origin header (e.g. same-origin, curl, server->server) → allow
if (!origin) return next();
// B) Whitelisted browser origins (credentials allowed)
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods'
);
res.setHeader(
'Access-Control-Allow-Methods',
'GET, POST, OPTIONS'
);
/* 4c — Default permissive fallback (same as your original) */
} else {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
} else {
return res.status(403).end();
}
/* 4d — Short-circuit pre-flight requests */
@ -1666,7 +1873,13 @@ app.get('/api/tuition/estimate', (req, res) => {
});
app.use((err, req, res, _next) => {
if (res.headersSent) return;
const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res);
console.error(`[ref ${rid}]`, err?.message || err);
// map known cases if you have them; otherwise generic:
return res.status(500).json({ error: 'Server error', ref: rid });
});
/**************************************************
* Start the Express server

View File

@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename);
import express from 'express';
import helmet from 'helmet';
import { readFile } from 'fs/promises'; // <-- add this
import { readFile, unlink } from 'fs/promises'; // <-- add this
import fs from 'fs';
import multer from 'multer';
import fetch from 'node-fetch';
@ -24,6 +24,7 @@ import OpenAI from 'openai';
import Fuse from 'fuse.js';
import Stripe from 'stripe';
import { createReminder } from './utils/smsService.js';
import rateLimit from 'express-rate-limit'; // already used elsewhere; ok to keep
import { initEncryption, verifyCanary } from './shared/crypto/encryption.js';
import { hashForLookup } from './shared/crypto/encryption.js';
@ -33,7 +34,7 @@ import './jobs/reminderCron.js';
import { cacheSummary } from "./utils/ctxCache.js";
const rootPath = path.resolve(__dirname, '..');
const env = (process.env.NODE_ENV || 'production');
const env = (process.env.NODE_ENV || 'prod');
const envPath = path.resolve(rootPath, `.env.${env}`);
if (!process.env.FROM_SECRETS_MANAGER) {
dotenv.config({ path: envPath, override: false });
@ -54,6 +55,31 @@ const ALLOWED_REDIRECT_HOSTS = new Set([
new URL(PUBLIC_BASE || 'http://localhost').host
]);
// ── RUNTIME PROTECTION: outbound host allowlist (server3) ──
const OUTBOUND_ALLOW = new Set([
'server2', // compose DNS (server2:5001)
'localhost', // self-calls (localhost:5002)
'api.openai.com', // OpenAI SDK traffic
'api.stripe.com', // Stripe SDK traffic
'api.twilio.com' // smsService may hit Twilio from this proc
]);
function assertAllowed(url) {
const u = new URL(url, 'http://localhost');
const host = u.hostname;
if (!OUTBOUND_ALLOW.has(host)) {
throw new Error(`blocked_outbound_host:${host}`);
}
}
// Wrap fetch for this file (dont reassign the imported binding)
const rawFetch = fetch;
async function guardedFetch(input, init) {
const url = typeof input === 'string' ? input : input?.url;
assertAllowed(url);
return rawFetch(input, init);
}
function isSafeRedirect(url) {
try {
const u = new URL(url);
@ -63,11 +89,198 @@ function isSafeRedirect(url) {
const app = express();
app.use(cookieParser());
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '1mb' }));
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
// --- Request ID + minimal audit log for /api/* ---
function getRequestId(req, res) {
const hdr = req.headers['x-request-id'];
if (typeof hdr === 'string' && hdr) return hdr; // from Nginx
const rid = crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`;
res.setHeader('X-Request-ID', rid);
return rid;
}
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
const rid = getRequestId(req, res);
const t0 = process.hrtime.bigint();
res.on('finish', () => {
const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
const out = {
ts: new Date().toISOString(),
rid,
ip: req.ip || req.headers['x-forwarded-for'] || '',
method: req.method,
path: req.path,
status: res.statusCode,
dur_ms: durMs,
bytes_sent: Number(res.getHeader('Content-Length') || 0),
userId: req.userId || req.id || null
};
try { console.log(JSON.stringify(out)); } catch {}
});
next();
});
// ---- RUNTIME: minimal audit logging (API only, redacted) ----
function pickIp(req) {
// trust proxy already set in your apps
return req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '';
}
function redactHeaders(h) {
const out = { ...h };
delete out.authorization;
delete out.cookie;
delete out['x-forwarded-for'];
return out;
}
function sampleBody(b) {
if (!b || typeof b !== 'object') return undefined;
// avoid logging PII: show keys + small snippet
const keys = Object.keys(b);
const preview = {};
for (const k of keys.slice(0, 12)) {
const v = b[k];
preview[k] = typeof v === 'string' ? (v.length > 80 ? v.slice(0, 80) + '…' : v) : (Array.isArray(v) ? `[array:${v.length}]` : typeof v);
}
return preview;
}
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
// correlation id
const rid = req.headers['x-request-id'] || crypto.randomUUID?.() || String(Date.now());
res.setHeader('X-Request-ID', rid);
const t0 = process.hrtime.bigint();
// capture minimal request data
const reqLog = {
ts: new Date().toISOString(),
rid,
ip: pickIp(req),
method: req.method,
path: req.path,
userId: req.userId || req.id || null, // populated by your auth middleware on many routes
ua: req.headers['user-agent'] || '',
hdr: redactHeaders(req.headers),
body: sampleBody(req.body)
};
res.on('finish', () => {
const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
const out = {
...reqLog,
status: res.statusCode,
dur_ms: durMs,
bytes_sent: Number(res.getHeader('Content-Length') || 0)
};
// one line JSON per request
try { console.log(JSON.stringify(out)); } catch {}
});
next();
});
// ---- RUNTIME: never cache API responses ----
app.use((req, res, next) => {
if (req.path.startsWith('/api/')) {
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
next();
});
process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
// ---- RUNTIME: enforce JSON on API writes (with narrow exceptions) ----
const MUST_JSON = new Set(['POST','PUT','PATCH']);
const EXEMPT_PATHS = [
// server3
/^\/api\/premium\/resume\/optimize$/, // multer (multipart/form-data)
/^\/api\/premium\/stripe\/webhook$/, // Stripe (express.raw)
// add others if truly needed
];
// ---- RUNTIME: last-resort error sanitizer ----
app.use((err, req, res, _next) => {
// dont double-send
if (res.headersSent) return;
// map a few known errors cleanly
if (err?.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'file_too_large', limit_mb: 10 });
}
if (err?.message && String(err.message).startsWith('blocked_outbound_host:')) {
return res.status(400).json({ error: 'blocked_outbound_host' });
}
if (err?.message === 'unsupported_type') {
return res.status(415).json({ error: 'unsupported_type' });
}
// default: generic 500 without internals
console.error('[unhandled]', err?.message || err); // logs to stderr only
return res.status(500).json({ error: 'Server error' });
});
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
if (!MUST_JSON.has(req.method)) return next();
if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next();
const ct = req.headers['content-type'] || '';
if (!ct.toLowerCase().includes('application/json')) {
return res.status(415).json({ error: 'unsupported_media_type' });
}
next();
});
// ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ----
app.use((req, _res, next) => {
const MAX_ARRAY = 20; // sane cap; adjust if you truly need more
const sanitize = (obj) => {
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) {
const v = obj[k];
if (Array.isArray(v)) {
// keep first value semantics + bound array size
obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null);
if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons
}
}
};
sanitize(req.query);
sanitize(req.body);
next();
});
// ---- RUNTIME: reject request bodies on GET/HEAD ----
app.use((req, res, next) => {
if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) {
return res.status(400).json({ error: 'no_body_allowed' });
}
next();
});
const { getDocument } = pkg;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' });
// ── Use raw pool for canary/db checks (avoid DAO wrapper noise) ──
const db = pool.raw || pool;
const DB_POOL_SIZE = 12;
// Bootstrap: unwrap DEK, check DB, verify canary
try {
@ -148,11 +361,110 @@ app.get('/healthz', async (_req, res) => {
return res.status(ready ? 200 : 503).json(out);
});
// …rest of your routes and app.listen(PORT)
// ── Tier config (env-overridable) ─────────────────────────────
const CHAT_BURST_WINDOW_SEC = Number(process.env.CHAT_BURST_WINDOW_SEC || 300); // 5 min
const CHAT_CONCURRENCY_PER_USER = Number(process.env.CHAT_CONCURRENCY_PER_USER || 1);
// “coach” chat (general)
const CHAT_BURST = {
basic: Number(process.env.CHAT_BURST_BASIC || 3), // per 5 min
premium: Number(process.env.CHAT_BURST_PREMIUM || 6),
pro: Number(process.env.CHAT_BURST_PRO || 12),
};
const CHAT_DAILY = {
basic: Number(process.env.CHAT_DAILY_BASIC || 20), // per 24h
premium: Number(process.env.CHAT_DAILY_PREMIUM || 60),
pro: Number(process.env.CHAT_DAILY_PRO || 120),
};
// “retire” beta (stricter)
const RET_BURST = {
premium: Number(process.env.RET_BURST_PREMIUM || 2), // per 5 min
pro: Number(process.env.RET_BURST_PRO || 4),
};
const RET_DAILY = {
premium: Number(process.env.RET_DAILY_PREMIUM || 5), // per 24h
pro: Number(process.env.RET_DAILY_PRO || 10),
};
const tierCache = new Map(); // userId -> { tier, exp }
async function resolveTier(userId) {
const c = tierCache.get(userId);
if (c && c.exp > Date.now()) return c.tier;
const [[row]] = await pool.query('SELECT is_premium, is_pro_premium FROM user_profile WHERE id=? LIMIT 1', [userId]);
const t = row?.is_pro_premium ? 'pro' : row?.is_premium ? 'premium' : 'basic';
tierCache.set(userId, { tier: t, exp: Date.now() + 60_000 }); // cache 60s
return t;
}
// in-memory sliding window + daily + concurrency
const usage = new Map(); // userId -> { win: number[], dayStart: number, dayCount: number, inflight: number }
function getU(id) {
let u = usage.get(id);
if (!u) { u = { win: [], dayStart: Date.now(), dayCount: 0, inflight: 0 }; usage.set(id, u); }
return u;
}
function resetDayIfNeeded(u) {
const DAY = 24*60*60*1000;
if (Date.now() - u.dayStart >= DAY) { u.dayStart = Date.now(); u.dayCount = 0; u.win.length = 0; }
}
function cleanWindow(u, windowMs) {
const cutoff = Date.now() - windowMs;
while (u.win.length && u.win[0] < cutoff) u.win.shift();
}
function chatGate(kind /* 'coach' | 'retire' */) {
return async (req, res, next) => {
const userId = req.id;
if (!userId) return res.status(401).json({ error: 'auth_required' });
const tier = await resolveTier(userId);
const u = getU(userId);
resetDayIfNeeded(u);
cleanWindow(u, CHAT_BURST_WINDOW_SEC*1000);
// choose caps
const burstCap = (kind === 'retire')
? (tier === 'pro' ? RET_BURST.pro : RET_BURST.premium)
: (tier === 'pro' ? CHAT_BURST.pro : tier === 'premium' ? CHAT_BURST.premium : CHAT_BURST.basic);
const dayCap = (kind === 'retire')
? (tier === 'pro' ? RET_DAILY.pro : RET_DAILY.premium) // no 'basic' for retire
: (tier === 'pro' ? CHAT_DAILY.pro : tier === 'premium' ? CHAT_DAILY.premium : CHAT_DAILY.basic);
// concurrency guard
if (u.inflight >= CHAT_CONCURRENCY_PER_USER) {
res.set('Retry-After', '3');
return res.status(429).json({ error: 'chat_in_progress' });
}
// daily cap
if (u.dayCount >= dayCap) {
return res.status(429).json({ error: 'daily_limit_reached' });
}
// burst cap (sliding window)
if (u.win.length >= burstCap) {
const retryMs = Math.max(0, (u.win[0] + CHAT_BURST_WINDOW_SEC*1000) - Date.now());
res.set('Retry-After', String(Math.ceil(retryMs/1000)));
return res.status(429).json({ error: 'slow_down' });
}
// admit; book slots
u.inflight += 1;
u.dayCount += 1;
u.win.push(Date.now());
res.on('finish', () => { u.inflight = Math.max(0, u.inflight - 1); });
next();
};
}
function internalFetch(req, urlPath, opts = {}) {
return fetch(`${API_BASE}${urlPath}`, {
return guardedFetch(`${API_BASE}${urlPath}`, {
...opts,
headers: {
"Content-Type": "application/json",
@ -339,7 +651,7 @@ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
/* ─── Dynamic CORS middleware (matches server1 / server2) ────────────── */
app.use((req, res, next) => {
const origin = req.headers.origin;
res.setHeader('Vary', 'Origin');
// A) whitelisted origins (credentials allowed)
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
@ -350,14 +662,8 @@ app.use((req, res, next) => {
);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
// B) default permissive fallback (same as server2s behaviour)
} else {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
return res.status(403).end();
}
if (req.method === 'OPTIONS') {
@ -530,7 +836,7 @@ async function ensureDescriptionAndTasks({ socCode, jobDescription, tasks }) {
try {
// hit server2 directly on the compose network
const r = await fetch(`http://server2:5001/api/onet/career-description/${encodeURIComponent(socCode)}`, {
const r = await guardedfetch(`http://server2:5001/api/onet/career-description/${encodeURIComponent(socCode)}`, {
headers: { Accept: 'application/json' }
});
if (r.ok) {
@ -819,7 +1125,7 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs
}
});
app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
app.post('/api/premium/ai/chat', authenticatePremiumUser, chatGate('coach'), async (req, res) => {
try {
const {
userProfile = {},
@ -1595,10 +1901,7 @@ Check your Milestones tab. Let me know if you want any changes!
/*
RETIREMENT AI-CHAT ENDPOINT (clone + patch)
*/
app.post(
'/api/premium/retirement/aichat',
authenticatePremiumUser,
async (req, res) => {
app.post('/api/premium/retirement/aichat', authenticatePremiumUser, chatGate('retire'), async (req, res) => {
try {
/* 0⃣ pull + sanity-check inputs */
const {
@ -3959,7 +4262,15 @@ return res.json({
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB (tune as needed)
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ok = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword'
].includes(file.mimetype);
cb(ok ? null : new Error('unsupported_type'), ok);
}
});
function buildResumePrompt(resumeText, jobTitle, jobDescription) {
@ -3990,7 +4301,7 @@ Precisely Tailored, ATS-Optimized Resume:
}
async function extractTextFromPDF(filePath) {
const fileBuffer = await fs.readFile(filePath);
const fileBuffer = await readFile(filePath);
const uint8Array = new Uint8Array(fileBuffer);
const pdfDoc = await getDocument({ data: uint8Array }).promise;
@ -4003,8 +4314,16 @@ async function extractTextFromPDF(filePath) {
return text;
}
const resumeLimiter = rateLimit({
windowMs: 5 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
app.post(
'/api/premium/resume/optimize',
resumeLimiter,
upload.single('resumeFile'),
authenticatePremiumUser,
async (req, res) => {
@ -4029,14 +4348,14 @@ app.post(
}
// figure out usage limit
let userPlan = 'basic';
let userPlan = 'premium';
if (userProfile.is_pro_premium) {
userPlan = 'pro';
} else if (userProfile.is_premium) {
userPlan = 'premium';
}
const weeklyLimits = { basic: 1, premium: 2, pro: 5 };
const weeklyLimits = { premium: 3, pro: 5 };
const userWeeklyLimit = weeklyLimits[userPlan] || 0;
let resetDate = new Date(userProfile.resume_limit_reset);
@ -4073,7 +4392,7 @@ app.post(
const result = await mammoth.extractRawText({ path: filePath });
resumeText = result.value;
} else {
await fs.unlink(filePath);
await unlink(filePath);
return res.status(400).json({ error: 'Unsupported or corrupted file upload.' });
}
@ -4100,7 +4419,7 @@ app.post(
const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1);
// remove uploaded file
await fs.unlink(filePath);
await unlink(filePath);
res.json({
optimizedResume,
@ -4309,14 +4628,25 @@ app.post('/api/ai-risk', async (req, res) => {
}
});
// ---- upload error mapper (multer) ----
app.use((err, _req, res, next) => {
if (!err) return next();
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'file_too_large', limit_mb: 10 });
}
if (err.code === 'LIMIT_UNEXPECTED_FILE' || err.code === 'LIMIT_PART_COUNT') {
return res.status(400).json({ error: 'bad_upload' });
}
return next(err);
});
/* ------------------------------------------------------------------
FALLBACK 404
------------------------------------------------------------------ */
app.use((req, res) => {
console.warn(`No route matched for ${req.method} ${req.originalUrl}`);
res.status(404).json({ error: 'Not found' });
app.use((err, req, res, _next) => {
if (res.headersSent) return;
const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res);
console.error(`[ref ${rid}]`, err?.message || err);
// map known cases if you have them; otherwise generic:
return res.status(500).json({ error: 'Server error', ref: rid });
});
// Start server

View File

@ -51,6 +51,10 @@ const TABLE_MAP = {
/* ── initialise KMS unwrap once ─────────────────────────────── */
async function ensureCryptoReady () { await initEncryption(); }
// choose cap per-process from env (server1/2/3 each have their own DB_POOL_SIZE)
const cap = Number(process.env.DB_POOL_SIZE || 5);
console.log(`[db] mysql pool connectionLimit=${cap}`);
/* ── mysql connection pool (uses env injected by docker) ────── */
export const pool = mysql.createPool({
host : process.env.DB_HOST,
@ -59,11 +63,12 @@ export const pool = mysql.createPool({
password : process.env.DB_PASSWORD,
database : process.env.DB_NAME,
waitForConnections : true,
connectionLimit : 5,
connectionLimit : cap,
ssl : {
ca : process.env.DB_SSL_CA,
key : process.env.DB_SSL_KEY,
cert : process.env.DB_SSL_CERT
cert : process.env.DB_SSL_CERT,
servername: ''
}
});

View File

@ -33,6 +33,22 @@ const PAGE_TOOLMAP = JSON.parse(
await fs.readFile(path.join(assetsDir, "pageToolMap.json"), "utf8")
);
const FREE_PROMPT_MAX_CHARS = Number(process.env.FREE_PROMPT_MAX_CHARS || 1500);
const FREE_BURST_WINDOW_MS = Number(process.env.FREE_BURST_WINDOW_MS || 5 * 60 * 1000); // 5 min
const FREE_BURST_LIMIT = Number(process.env.FREE_BURST_LIMIT || 6); // 6 chats / 5 min
const FREE_DAILY_LIMIT = Number(process.env.FREE_DAILY_LIMIT || 60); // 60 chats / day
const FREE_CONCURRENCY_PER_U = Number(process.env.FREE_CONCURRENCY_PER_U || 1);
const FREE_ALLOWED_ORIGINS = new Set(
(process.env.CORS_ALLOWED_ORIGINS || '').split(',').map(s=>s.trim()).filter(Boolean)
);
// in-mem counters (per user if authed, else per IP)
const usage = new Map(); // key -> { win: number[], dayStart: number, dayCount: number, inflight: number }
const DAY = 24*60*60*1000;
function getU(key){ let u=usage.get(key); if(!u){u={win:[],dayStart:Date.now(),dayCount:0,inflight:0}; usage.set(key,u);} return u; }
function clean(u){ if(Date.now()-u.dayStart>=DAY){u.dayStart=Date.now();u.dayCount=0;u.win.length=0;} const cut=Date.now()-FREE_BURST_WINDOW_MS; while(u.win.length&&u.win[0]<cut) u.win.shift(); }
/* ---------- helpers ---------- */
const classifyIntent = txt =>
HELP_TRIGGERS.some(k => txt.toLowerCase().includes(k)) ? "support" : "guide";
@ -178,39 +194,45 @@ export default function chatFreeEndpoint(
];
/* ----------------------------- ROUTE ----------------------------- */
app.post(
"/api/chat/free",
chatLimiter,
authenticateUser,
async (req, res) => {
app.post("/api/chat/free", chatLimiter, authenticateUser, async (req, res) => {
try {
const headers = {
// streaming MIME type browsers still treat it as text, but
// it signals “keep pushing” semantics more clearly
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no" // disables Nginx/ALB buffering
};
// “Connection” is allowed **only** on HTTP/1.x
if (req.httpVersionMajor < 2) {
headers.Connection = "keep-alive";
}
// ---- Guards (before writing any headers) ----
const origin = req.headers.origin || '';
if (origin && !FREE_ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'origin_not_allowed' });
}
res.writeHead(200, headers);
res.flushHeaders?.();
const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = req.body || {};
const p = String(prompt || '').trim();
if (!p) return res.status(400).json({ error: "Empty prompt" });
if (p.length > FREE_PROMPT_MAX_CHARS) {
return res.status(413).json({ error: "prompt_too_long" });
}
const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); };
const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = req.body || {};
if (!prompt.trim()) return res.status(400).json({ error: "Empty prompt" });
// per-user/IP light limits: 5-min burst, daily, 1 concurrent
const key = String((req.user && (req.user.id || req.user.sub)) || req.ip);
const u = getU(key);
clean(u);
if (u.inflight >= FREE_CONCURRENCY_PER_U) {
res.set('Retry-After','3'); return res.status(429).json({ error:'chat_in_progress' });
}
if (u.dayCount >= FREE_DAILY_LIMIT) return res.status(429).json({ error:'daily_limit_reached' });
if (u.win.length >= FREE_BURST_LIMIT) {
const retryMs = Math.max(0, (u.win[0] + FREE_BURST_WINDOW_MS) - Date.now());
res.set('Retry-After', String(Math.ceil(retryMs/1000)));
return res.status(429).json({ error:'slow_down' });
}
u.inflight++; u.dayCount++; u.win.push(Date.now());
res.on('finish', ()=>{ u.inflight = Math.max(0, u.inflight-1); });
res.on('close', ()=>{ u.inflight = Math.max(0, u.inflight-1); });
/* ---------- 0⃣ FAQ fast-path ---------- */
let faqHit = null;
try {
const { data } = await openai.embeddings.create({
model: "text-embedding-3-small",
input: prompt
input: p
});
const hits = await vectorSearch(FAQ_PATH, data[0].embedding, 1);
if (hits.length && hits[0].score >= FAQ_THRESHOLD) faqHit = hits[0];
@ -379,17 +401,25 @@ res.writeHead(200, headers);
let messages = [
{ role: "system", content: system },
...chatHistory,
{ role: "user", content: prompt }
{ role: "user", content: p }
];
const chatStream = await openai.chat.completions.create({
model : "gpt-4o-mini",
stream : true,
messages,
tools,
});
for await (const part of chatStream) {
const headers = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no" // disables Nginx/ALB buffering
};
if (req.httpVersionMajor < 2) headers.Connection = "keep-alive";
res.writeHead(200, headers);
res.flushHeaders?.();
const sendChunk = (txt="") => { res.write(txt); res.flush?.(); };
for await (const part of chatStream) {
const txt = part.choices?.[0]?.delta?.content;
if (txt) sendChunk(txt);
}

View File

@ -1,7 +1,3 @@
# ---------------------------------------------------------------------------
# A single envfile (.env) contains ONLY nonsecret constants.
# Every secret is exported from fetchsecrets.sh and injected at deploy time.
# ---------------------------------------------------------------------------
x-env: &with-env
restart: unless-stopped
@ -13,6 +9,14 @@ services:
volumes:
- dek-vol:/run/secrets/dev
restart: "no"
uploads-init:
image: busybox:1.36
user: "0:0"
command: sh -lc 'mkdir -p /data/uploads && chown -R 1000:1000 /data/uploads && chmod 770 /data/uploads'
volumes:
- aptiva_uploads:/data/uploads
restart: "no"
# ───────────────────────────── server1 ─────────────────────────────
server1:
<<: *with-env
@ -42,6 +46,7 @@ services:
DB_SSL_CERT: ${DB_SSL_CERT}
DB_SSL_KEY: ${DB_SSL_KEY}
DB_SSL_CA: ${DB_SSL_CA}
DB_POOL_SIZE: "12"
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
@ -90,6 +95,7 @@ services:
DB_SSL_CERT: ${DB_SSL_CERT}
DB_SSL_KEY: ${DB_SSL_KEY}
DB_SSL_CA: ${DB_SSL_CA}
DB_POOL_SIZE: "6"
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
@ -109,7 +115,7 @@ services:
# ───────────────────────────── server3 ─────────────────────────────
server3:
depends_on: [dek-init]
depends_on: [dek-init, uploads-init]
<<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG}
user: "1000:1000"
@ -146,6 +152,7 @@ services:
DB_SSL_CERT: ${DB_SSL_CERT}
DB_SSL_KEY: ${DB_SSL_KEY}
DB_SSL_CA: ${DB_SSL_CA}
DB_POOL_SIZE: "12"
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
SALARY_DB_PATH: /app/salary_info.db

View File

@ -1,57 +0,0 @@
module.exports = {
apps: [
/* ─────────────── SERVER-2 ─────────────── */
{
name: 'server2',
script: './backend/server2.js',
watch: false, // or true in dev
env_development: {
NODE_ENV: 'development',
ONET_USERNAME: 'aptivaai',
ONET_PASSWORD: '2296ahq'
},
env_production: {
NODE_ENV: 'production',
ONET_USERNAME: 'aptivaai',
ONET_PASSWORD: '2296ahq'
}
},
{
name : 'server3',
script : './backend/server3.js',
watch : false,
/* 👇 everything lives here, nothing else to pass at start-time */
env: {
NODE_ENV : 'production',
PREMIUM_PORT : 5002,
DB_HOST : '34.67.180.54',
DB_PORT : 3306,
DB_USER : 'sqluser',
DB_PASSWORD : 'ps<g+2DO-eTb2mb5',
DB_NAME : 'user_profile_db',
TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d',
TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80',
TWILIO_MESSAGING_SERVICE_SID : 'MGMGaa07992a9231c841b1bfb879649026d6'
}
}
],
deploy : {
production : {
user : 'SSH_USERNAME',
host : 'SSH_HOSTMACHINE',
ref : 'origin/master',
repo : 'GIT_REPOSITORY',
path : 'DESTINATION_PATH',
'pre-deploy-local': '',
'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production',
'pre-setup': ''
}
}
};

545
full_schema.sql Normal file
View File

@ -0,0 +1,545 @@
-- MySQL dump 10.13 Distrib 8.0.42, for Linux (x86_64)
--
-- Host: 34.67.180.54 Database: user_profile_db
-- ------------------------------------------------------
-- Server version 8.0.40-google
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN;
SET @@SESSION.SQL_LOG_BIN= 0;
--
-- GTID state at the beginning of the backup
--
SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ '';
--
-- Table structure for table `ai_chat_messages`
--
DROP TABLE IF EXISTS `ai_chat_messages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ai_chat_messages` (
`id` bigint NOT NULL AUTO_INCREMENT,
`thread_id` char(36) NOT NULL,
`user_id` bigint NOT NULL,
`role` enum('user','assistant','system') NOT NULL,
`content` mediumtext NOT NULL,
`meta_json` json DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `thread_id` (`thread_id`,`created_at`),
CONSTRAINT `fk_chat_thread` FOREIGN KEY (`thread_id`) REFERENCES `ai_chat_threads` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_messages_thread` FOREIGN KEY (`thread_id`) REFERENCES `ai_chat_threads` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=53 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `ai_chat_threads`
--
DROP TABLE IF EXISTS `ai_chat_threads`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ai_chat_threads` (
`id` char(36) NOT NULL,
`user_id` bigint NOT NULL,
`bot_type` enum('support','retire','coach') NOT NULL,
`title` varchar(200) DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`,`bot_type`,`updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `ai_generated_ksa`
--
DROP TABLE IF EXISTS `ai_generated_ksa`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ai_generated_ksa` (
`id` int NOT NULL AUTO_INCREMENT,
`soc_code` varchar(50) NOT NULL,
`career_title` varchar(255) NOT NULL,
`knowledge_json` mediumtext,
`skills_json` mediumtext,
`abilities_json` mediumtext,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `soc_code` (`soc_code`)
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `ai_risk_analysis`
--
DROP TABLE IF EXISTS `ai_risk_analysis`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ai_risk_analysis` (
`soc_code` varchar(10) NOT NULL,
`career_name` text,
`job_description` text,
`tasks` text,
`risk_level` varchar(50) DEFAULT NULL,
`reasoning` text,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`soc_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `career_profiles`
--
DROP TABLE IF EXISTS `career_profiles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `career_profiles` (
`id` varchar(36) NOT NULL,
`user_id` int NOT NULL,
`career_name` varchar(255) DEFAULT NULL,
`status` varchar(100) DEFAULT NULL,
`start_date` varchar(128) DEFAULT NULL,
`retirement_start_date` varchar(128) DEFAULT NULL,
`college_enrollment_status` varchar(100) DEFAULT NULL,
`currently_working` varchar(10) DEFAULT NULL,
`planned_monthly_expenses` varchar(128) DEFAULT NULL,
`planned_monthly_debt_payments` varchar(128) DEFAULT NULL,
`planned_monthly_retirement_contribution` varchar(128) DEFAULT NULL,
`planned_monthly_emergency_contribution` varchar(128) DEFAULT NULL,
`planned_surplus_emergency_pct` varchar(128) DEFAULT NULL,
`planned_surplus_retirement_pct` varchar(128) DEFAULT NULL,
`planned_additional_income` varchar(128) DEFAULT NULL,
`scenario_title` varchar(255) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
`career_goals` mediumtext,
`desired_retirement_income_monthly` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `user_id` (`user_id`,`career_name`),
CONSTRAINT `fk_career_profiles_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `college_profiles`
--
DROP TABLE IF EXISTS `college_profiles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `college_profiles` (
`id` varchar(36) NOT NULL,
`user_id` int NOT NULL,
`career_profile_id` varchar(36) DEFAULT NULL,
`selected_school` varchar(512) DEFAULT NULL,
`selected_program` varchar(512) DEFAULT NULL,
`program_type` varchar(50) DEFAULT NULL,
`academic_calendar` varchar(50) DEFAULT NULL,
`is_in_state` tinyint DEFAULT NULL,
`is_in_district` tinyint DEFAULT NULL,
`is_online` tinyint DEFAULT NULL,
`college_enrollment_status` varchar(50) DEFAULT NULL,
`annual_financial_aid` varchar(128) DEFAULT NULL,
`existing_college_debt` varchar(128) DEFAULT NULL,
`tuition` varchar(128) DEFAULT NULL,
`tuition_paid` varchar(128) DEFAULT NULL,
`loan_deferral_until_graduation` varchar(128) DEFAULT NULL,
`loan_term` varchar(128) DEFAULT NULL,
`interest_rate` varchar(128) DEFAULT NULL,
`extra_payment` varchar(128) DEFAULT NULL,
`credit_hours_per_year` decimal(5,2) DEFAULT NULL,
`hours_completed` decimal(5,1) DEFAULT NULL,
`program_length` decimal(5,2) DEFAULT NULL,
`credit_hours_required` decimal(5,2) DEFAULT NULL,
`expected_graduation` date DEFAULT NULL,
`expected_salary` varchar(128) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
`enrollment_date` date DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_user_school_prog` (`user_id`,`career_profile_id`,`selected_school`(192),`selected_program`(192),`program_type`),
KEY `idx_school` (`selected_school`(191)),
KEY `idx_program` (`selected_program`(191)),
KEY `idx_school_prog` (`selected_school`(191),`selected_program`(191)),
KEY `idx_college_user` (`user_id`),
KEY `idx_college_career` (`career_profile_id`),
CONSTRAINT `fk_college_profiles_career` FOREIGN KEY (`career_profile_id`) REFERENCES `career_profiles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_college_profiles_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `context_cache`
--
DROP TABLE IF EXISTS `context_cache`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `context_cache` (
`user_id` bigint NOT NULL,
`career_profile_id` varchar(36) NOT NULL,
`ctx_hash` char(40) NOT NULL,
`ctx_text` mediumtext,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`,`career_profile_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `encryption_canary`
--
DROP TABLE IF EXISTS `encryption_canary`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `encryption_canary` (
`id` tinyint NOT NULL,
`value` text NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `financial_profiles`
--
DROP TABLE IF EXISTS `financial_profiles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `financial_profiles` (
`user_id` int NOT NULL,
`current_salary` varchar(128) DEFAULT NULL,
`additional_income` varchar(128) DEFAULT NULL,
`monthly_expenses` varchar(128) DEFAULT NULL,
`monthly_debt_payments` varchar(128) DEFAULT NULL,
`retirement_savings` varchar(128) DEFAULT NULL,
`emergency_fund` varchar(128) DEFAULT NULL,
`retirement_contribution` varchar(128) DEFAULT NULL,
`emergency_contribution` varchar(128) DEFAULT NULL,
`extra_cash_emergency_pct` varchar(64) DEFAULT NULL,
`extra_cash_retirement_pct` varchar(64) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`),
CONSTRAINT `fk_financial_profiles_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `financial_projections`
--
DROP TABLE IF EXISTS `financial_projections`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `financial_projections` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`career_profile_id` varchar(36) DEFAULT NULL,
`projection_data` text NOT NULL,
`loan_paid_off_month` varchar(20) DEFAULT NULL,
`final_emergency_savings` decimal(10,2) DEFAULT '0.00',
`final_retirement_savings` decimal(10,2) DEFAULT '0.00',
`final_loan_balance` decimal(10,2) DEFAULT '0.00',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_financial_projections_user` (`user_id`),
KEY `fk_financial_projections_career` (`career_profile_id`),
CONSTRAINT `fk_financial_projections_career` FOREIGN KEY (`career_profile_id`) REFERENCES `career_profiles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_financial_projections_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `milestone_impacts`
--
DROP TABLE IF EXISTS `milestone_impacts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `milestone_impacts` (
`id` varchar(36) NOT NULL,
`milestone_id` varchar(36) NOT NULL,
`impact_type` varchar(255) DEFAULT NULL,
`direction` varchar(255) DEFAULT NULL,
`amount` varchar(128) DEFAULT NULL,
`start_date` varchar(255) DEFAULT NULL,
`end_date` varchar(255) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `milestones`
--
DROP TABLE IF EXISTS `milestones`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `milestones` (
`id` varchar(36) NOT NULL,
`user_id` int NOT NULL,
`title` varchar(255) DEFAULT NULL,
`description` mediumtext,
`date` varchar(128) DEFAULT NULL,
`progress` varchar(128) DEFAULT NULL,
`status` varchar(50) DEFAULT 'planned',
`career_profile_id` varchar(36) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
`is_universal` tinyint DEFAULT '0',
`origin_milestone_id` varchar(36) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_milestones_user` (`user_id`),
KEY `fk_milestones_career` (`career_profile_id`),
CONSTRAINT `fk_milestones_career` FOREIGN KEY (`career_profile_id`) REFERENCES `career_profiles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_milestones_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `onboarding_drafts`
--
DROP TABLE IF EXISTS `onboarding_drafts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `onboarding_drafts` (
`user_id` bigint NOT NULL,
`id` char(36) NOT NULL,
`step` tinyint NOT NULL DEFAULT '0',
`data` json NOT NULL,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`),
UNIQUE KEY `uniq_id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `password_resets`
--
DROP TABLE IF EXISTS `password_resets`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `password_resets` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`token_hash` char(64) NOT NULL,
`expires_at` bigint NOT NULL,
`used_at` bigint DEFAULT NULL,
`created_at` bigint NOT NULL,
`ip` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `email` (`email`),
KEY `token_hash` (`token_hash`),
KEY `expires_at` (`expires_at`),
KEY `idx_password_resets_token_hash` (`token_hash`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `reminders`
--
DROP TABLE IF EXISTS `reminders`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `reminders` (
`id` char(36) NOT NULL,
`user_id` int NOT NULL,
`phone_e164` varchar(128) DEFAULT NULL,
`message_body` mediumtext,
`send_at_utc` datetime NOT NULL,
`sent_at` datetime DEFAULT NULL,
`status` enum('pending','sent','failed') DEFAULT 'pending',
`twilio_sid` varchar(64) DEFAULT NULL,
`error_code` varchar(32) DEFAULT NULL,
`error_message` text,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_reminders_user` (`user_id`),
CONSTRAINT `fk_reminders_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `salary_data`
--
DROP TABLE IF EXISTS `salary_data`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `salary_data` (
`AREA` varchar(10) DEFAULT NULL,
`AREA_TITLE` varchar(255) DEFAULT NULL,
`AREA_TYPE` int DEFAULT NULL,
`PRIM_STATE` varchar(10) DEFAULT NULL,
`NAICS` varchar(10) DEFAULT NULL,
`NAICS_TITLE` varchar(255) DEFAULT NULL,
`I_GROUP` varchar(50) DEFAULT NULL,
`OWN_CODE` varchar(10) DEFAULT NULL,
`OCC_CODE` varchar(10) DEFAULT NULL,
`OCC_TITLE` varchar(255) DEFAULT NULL,
`O_GROUP` varchar(50) DEFAULT NULL,
`TOT_EMP` int DEFAULT NULL,
`EMP_PRSE` decimal(10,2) DEFAULT NULL,
`JOBS_1000` decimal(10,2) DEFAULT NULL,
`LOC_QUOTIENT` decimal(10,2) DEFAULT NULL,
`PCT_TOTAL` decimal(10,2) DEFAULT NULL,
`PCT_RPT` decimal(10,2) DEFAULT NULL,
`H_MEAN` decimal(10,2) DEFAULT NULL,
`A_MEAN` int DEFAULT NULL,
`MEAN_PRSE` decimal(10,2) DEFAULT NULL,
`H_PCT10` decimal(10,2) DEFAULT NULL,
`H_PCT25` decimal(10,2) DEFAULT NULL,
`H_MEDIAN` decimal(10,2) DEFAULT NULL,
`H_PCT75` decimal(10,2) DEFAULT NULL,
`H_PCT90` decimal(10,2) DEFAULT NULL,
`A_PCT10` int DEFAULT NULL,
`A_PCT25` int DEFAULT NULL,
`A_MEDIAN` int DEFAULT NULL,
`A_PCT75` int DEFAULT NULL,
`A_PCT90` int DEFAULT NULL,
`ANNUAL` tinyint(1) DEFAULT NULL,
`HOURLY` tinyint(1) DEFAULT NULL,
`JOB_ZONE` int DEFAULT NULL,
`limited_data` tinyint(1) DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `stripe_events`
--
DROP TABLE IF EXISTS `stripe_events`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `stripe_events` (
`id` varchar(255) NOT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `tasks`
--
DROP TABLE IF EXISTS `tasks`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `tasks` (
`id` varchar(36) NOT NULL,
`milestone_id` varchar(36) NOT NULL,
`user_id` int NOT NULL,
`title` varchar(255) DEFAULT NULL,
`description` mediumtext,
`due_date` varchar(255) DEFAULT NULL,
`status` varchar(255) DEFAULT 'not_started',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_tasks_user` (`user_id`),
CONSTRAINT `fk_tasks_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `user_auth`
--
DROP TABLE IF EXISTS `user_auth`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_auth` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`username` varchar(255) NOT NULL,
`hashed_password` text NOT NULL,
`password_changed_at` bigint unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`),
KEY `ix_user_auth_userid_changedat` (`user_id`,`password_changed_at`),
CONSTRAINT `fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=47 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `user_profile`
--
DROP TABLE IF EXISTS `user_profile`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_profile` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`firstname` varchar(400) DEFAULT NULL,
`lastname` varchar(400) DEFAULT NULL,
`email` varchar(512) DEFAULT NULL,
`email_lookup` char(64) NOT NULL DEFAULT '',
`phone_e164` varchar(128) DEFAULT NULL,
`sms_opt_in` tinyint(1) NOT NULL DEFAULT '0',
`zipcode` varchar(64) DEFAULT NULL,
`state` varchar(50) NOT NULL,
`area` varchar(255) NOT NULL,
`is_premium` tinyint DEFAULT '0',
`is_pro_premium` tinyint DEFAULT '0',
`stripe_customer_id` varchar(128) DEFAULT NULL,
`career_situation` text,
`interest_inventory_answers` mediumtext,
`riasec_scores` varchar(768) DEFAULT NULL,
`resume_optimizations_used` int DEFAULT '0',
`resume_limit_reset` date DEFAULT NULL,
`resume_booster_count` int DEFAULT '0',
`career_priorities` mediumtext,
`career_list` mediumtext,
`stripe_customer_id_hash` char(64) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `stripe_customer_id` (`stripe_customer_id`),
KEY `idx_customer_hash` (`stripe_customer_id_hash`),
KEY `idx_user_profile_email_lookup` (`email_lookup`)
) ENGINE=InnoDB AUTO_INCREMENT=64 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping events for database 'user_profile_db'
--
--
-- Dumping routines for database 'user_profile_db'
--
SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-08-27 16:38:46

View File

@ -234,3 +234,17 @@ ALTER TABLE ai_chat_messages
ADD CONSTRAINT fk_messages_thread
FOREIGN KEY (thread_id) REFERENCES ai_chat_threads(id)
ON DELETE CASCADE;
mysqldump \
--host=34.67.180.54 \
--port=3306 \
--user=sqluser \
-p \
--no-data \
--routines \
--triggers \
--events \
user_profile_db > full_schema.sql

View File

@ -4,6 +4,12 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off;
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_req_zone $binary_remote_addr zone=reqperip:10m rate=10r/s;
set_real_ip_from 130.211.0.0/22;
set_real_ip_from 35.191.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# ───────────── upstreams to Docker services ─────────────
upstream backend5000 { server server1:5000; } # auth & free
@ -34,6 +40,43 @@ http {
ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# ==== RUNTIME PROTECTIONS (dev test) ====
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options SAMEORIGIN always;
client_max_body_size 10m;
large_client_header_buffers 4 8k;
client_header_timeout 30s;
client_body_timeout 30s;
send_timeout 35s;
keepalive_timeout 65s;
proxy_set_header X-Request-ID $request_id;
add_header X-Request-ID $request_id always;
proxy_request_buffering off;
proxy_max_temp_file_size 0;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
limit_conn perip 20; # typical users stay << 10
limit_conn_status 429; # surface as 429 Too Many Requests
limit_req zone=reqperip burst=20 nodelay;
limit_req_status 429;
if ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; }
if ($host !~* ^(dev1\.aptivaai\.com)$) { return 444; }
location ~ /\.(?!well-known/) { deny all; }
location ~* \.(?:env|ini|log|sql|sqlite|db|db3|bak|old|orig|swp)$ { deny all; }
# ───── React static assets ─────
root /usr/share/nginx/html;
index index.html;

View File

View File

@ -246,7 +246,7 @@ const confirmLogout = async () => {
// 1) Ask the server to clear the session cookie
try {
// If you created /logout (no /api prefix):
await api.post('/logout'); // axios client is withCredentials: true
await api.post('api/logout'); // axios client is withCredentials: true
// If your route is /api/signout instead, use:
// await api.post('/api/signout');
} catch (e) {

View File

@ -22,6 +22,12 @@ axios.interceptors.request.use((config) => {
axios.interceptors.response.use(
r => r,
(err) => {
// capture request id from server/nginx for troubleshooting
const rid = err?.response?.headers?.['x-request-id'];
if (rid) {
err.requestId = rid;
console.error('Request failed — X-Request-ID:', rid);
}
const status = err?.response?.status;
if (status === 401) {
clearToken();

View File

@ -11,5 +11,13 @@ export default function apiFetch(input, init = {}) {
...init,
headers,
credentials: 'include' // ← send cookie
}).then(async (res) => {
if (res.ok) return res;
const rid = res.headers.get('x-request-id');
const text = await res.text().catch(() => '');
const err = new Error(text || res.statusText || 'Request failed');
err.status = res.status;
if (rid) err.requestId = rid;
throw err;
});
}

View File

@ -66,6 +66,12 @@ function ResumeRewrite() {
setError('Please fill in all fields.');
return;
}
// If a previous error existed, reset the file input to prompt a fresh pick
if (error) {
// reset the native input if accessible
const el = e.target?.querySelector('input[type="file"]');
if (el) el.value = '';
}
setLoading(true);
try {
@ -84,7 +90,20 @@ function ResumeRewrite() {
fetchRemainingOptimizations();
} catch (err) {
console.error('Resume optimization error:', err);
setError(err.response?.data?.error || 'Failed to optimize resume.');
const status = err?.response?.status;
const code = err?.response?.data?.error;
if (status === 413 || code === 'file_too_large') {
setError(`File is too large. Maximum ${MAX_MB}MB.`);
} else if (status === 415 || code === 'unsupported_type') {
setError('Unsupported file type. Please upload a PDF or DOCX.');
} else if (status === 429) {
setError('Too many requests. Please wait a moment and try again.');
} else if (status === 400) {
setError('Bad upload. Please re-select your file and try again.');
} else {
setError('Failed to optimize resume. Please try again.');
}
} finally {
setLoading(false);
}