Compare commits

...

2 Commits

Author SHA1 Message Date
761f511601 cookie implementation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-08-13 19:58:24 +00:00
60e2673539 Stop tracking .env.production 2025-08-13 19:48:52 +00:00
26 changed files with 1038 additions and 677 deletions

2
.env
View File

@ -2,7 +2,7 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://
SERVER1_PORT=5000 SERVER1_PORT=5000
SERVER2_PORT=5001 SERVER2_PORT=5001
SERVER3_PORT=5002 SERVER3_PORT=5002
IMG_TAG=ed1fdbb-202508121553 IMG_TAG=fb2e052-202508131933
ENV_NAME=dev ENV_NAME=dev
PROJECT=aptivaai-dev PROJECT=aptivaai-dev

View File

@ -110,6 +110,14 @@ steps:
export DEK_PATH; \ export DEK_PATH; \
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \ SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
export SUPPORT_SENDGRID_API_KEY; \ export SUPPORT_SENDGRID_API_KEY; \
ACCESS_COOKIE_NAME=$(gcloud secrets versions access latest --secret=ACCESS_COOKIE_NAME_$ENV --project=$PROJECT); \
export ACCESS_COOKIE_NAME; \
COOKIE_SECURE=$(gcloud secrets versions access latest --secret=COOKIE_SECURE_$ENV --project=$PROJECT); \
export COOKIE_SECURE; \
COOKIE_SAMESITE=$(gcloud secrets versions access latest --secret=COOKIE_SAMESITE_$ENV --project=$PROJECT); \
export COOKIE_SAMESITE; \
TOKEN_MAX_AGE_MS=$(gcloud secrets versions access latest --secret=TOKEN_MAX_AGE_MS_$ENV --project=$PROJECT); \
export TOKEN_MAX_AGE_MS; \
export FROM_SECRETS_MANAGER=true; \ export FROM_SECRETS_MANAGER=true; \
\ \
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \ # ── DEK sync: copy dev wrapped DEK into staging volume path ── \
@ -127,9 +135,9 @@ steps:
fi; \ fi; \
\ \
cd /home/jcoakley/aptiva-staging-app; \ cd /home/jcoakley/aptiva-staging-app; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,ACCESS_COOKIE_NAME,COOKIE_SECURE,COOKIE_SAMESITE,TOKEN_MAX_AGE_MS \
docker compose pull; \ docker compose pull; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,ACCESS_COOKIE_NAME,COOKIE_SECURE,COOKIE_SAMESITE,TOKEN_MAX_AGE_MS \
docker compose up -d --force-recreate --remove-orphans; \ docker compose up -d --force-recreate --remove-orphans; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"' echo "✅ Staging stack refreshed with tag $IMG_TAG"'

View File

@ -2,7 +2,6 @@
import cron from 'node-cron'; import cron from 'node-cron';
import pool from '../config/mysqlPool.js'; import pool from '../config/mysqlPool.js';
import { sendSMS } from '../utils/smsService.js'; import { sendSMS } from '../utils/smsService.js';
import { query } from '../shared/db/withEncryption.js';
const BATCH_SIZE = 25; // tune as you like const BATCH_SIZE = 25; // tune as you like

View File

@ -15,6 +15,7 @@ import sgMail from '@sendgrid/mail';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js'; import { requireAuth } from './shared/requireAuth.js';
import cookieParser from 'cookie-parser';
const CANARY_SQL = ` const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary ( CREATE TABLE IF NOT EXISTS encryption_canary (
@ -83,6 +84,7 @@ try {
Express app & middleware Express app & middleware
---------------------------------------------------------------- */ ---------------------------------------------------------------- */
const app = express(); const app = express();
app.set('trust proxy', 1);
const PORT = process.env.SERVER1_PORT || 5000; const PORT = process.env.SERVER1_PORT || 5000;
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */ /* ─── Allowed origins for CORS (comma-separated in env) ──────── */
@ -93,8 +95,8 @@ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
app.disable('x-powered-by'); app.disable('x-powered-by');
app.use(bodyParser.json());
app.use(express.json()); app.use(express.json());
app.use(cookieParser());
app.use( app.use(
helmet({ helmet({
contentSecurityPolicy: false, contentSecurityPolicy: false,
@ -227,6 +229,7 @@ app.options('*', (req, res) => {
'Access-Control-Allow-Headers', 'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With' 'Authorization, Content-Type, Accept, Origin, X-Requested-With'
); );
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.status(200).end(); res.status(200).end();
}); });
@ -284,6 +287,30 @@ const pwDailyLimiter = rateLimit({
keyGenerator: (req) => req.ip, keyGenerator: (req) => req.ip,
}); });
// ---- Auth cookie / token helper ----
const COOKIE_NAME = process.env.ACCESS_COOKIE_NAME || 'aptiva_access';
const COOKIE_SECURE = String(process.env.COOKIE_SECURE).toLowerCase() === 'true';
const COOKIE_SAMESITE = process.env.COOKIE_SAMESITE || 'Lax';
const COOKIE_DOMAIN = (process.env.COOKIE_DOMAIN || '').trim() || undefined;
// Default max-age: use TOKEN_MAX_AGE_MS if set, else 2h
const MAX_AGE_MS = Number(process.env.TOKEN_MAX_AGE_MS || 0) || (2 * 60 * 60 * 1000);
const EXPIRES_SEC = Math.floor(MAX_AGE_MS / 1000);
// standardize on `sub` (requireAuth also accepts id/userId)
function issueSession(res, userId) {
const token = jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: EXPIRES_SEC });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
domain: COOKIE_DOMAIN, // undefined => host-only cookie
path: '/',
maxAge: MAX_AGE_MS,
});
return token;
}
async function setPasswordByEmail(email, bcryptHash) { async function setPasswordByEmail(email, bcryptHash) {
const sql = ` const sql = `
UPDATE user_auth ua UPDATE user_auth ua
@ -578,17 +605,19 @@ app.post('/api/register', async (req, res) => {
const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`; const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`;
await pool.query(authQuery, [newProfileId, username, hashedPassword]); await pool.query(authQuery, [newProfileId, username, hashedPassword]);
const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' }); const maxAgeMs = Number(process.env.TOKEN_MAX_AGE_MS || 0) || 2 * 60 * 60 * 1000;
const expiresSec = Math.floor(maxAgeMs / 1000);
const token = issueSession(res, newProfileId);
return res.status(201).json({ return res.status(201).json({
message: 'User registered successfully', message: 'User registered successfully',
profileId: newProfileId, profileId: newProfileId,
token, token, // optional; frontend doesnt need it anymore
user: { user: {
username, firstname, lastname, email: emailNorm, zipcode, state, area, username, firstname, lastname, email: emailNorm, zipcode, state, area,
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
} }
}); });
} catch (err) { } catch (err) {
// If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates: // If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates:
if (err.code === 'ER_DUP_ENTRY') { if (err.code === 'ER_DUP_ENTRY') {
@ -665,16 +694,16 @@ app.post('/api/signin', async (req, res) => {
if (profile?.email) { if (profile?.email) {
try { profile.email = decrypt(profile.email); } catch {} try { profile.email = decrypt(profile.email); } catch {}
} }
const maxAgeMs = Number(process.env.TOKEN_MAX_AGE_MS || 0) || 2 * 60 * 60 * 1000;
const expiresSec = Math.floor(maxAgeMs / 1000);
const token = issueSession(res, row.userProfileId);
return res.status(200).json({
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' }); message: 'Login successful',
token, // optional
res.status(200).json({ id: row.userProfileId,
message: 'Login successful', user: profile
token, });
id: row.userProfileId,
user: profile
});
} catch (err) { } catch (err) {
console.error('Error querying user_auth:', err.message); console.error('Error querying user_auth:', err.message);
return res return res
@ -940,6 +969,24 @@ app.post('/api/activate-premium', requireAuth, async (req, res) => {
} }
}); });
/* Logout endpoint */
app.post('/api/logout', (req, res) => {
const cookieName = process.env.ACCESS_COOKIE_NAME || 'aptiva_access';
const isSecure = String(process.env.COOKIE_SECURE).toLowerCase() === 'true';
const sameSite = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = (process.env.COOKIE_DOMAIN || '').trim() || undefined;
res.clearCookie(cookieName, {
httpOnly: true,
secure: isSecure,
sameSite,
domain: cookieDomain, // must match what you set on sign-in
path: '/',
});
res.status(200).json({ ok: true });
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
START SERVER START SERVER

View File

@ -21,8 +21,10 @@ import rateLimit from 'express-rate-limit';
import authenticateUser from './utils/authenticateUser.js'; import authenticateUser from './utils/authenticateUser.js';
import { vectorSearch } from "./utils/vectorSearch.js"; import { vectorSearch } from "./utils/vectorSearch.js";
import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js'; import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
import { requireAuth } from '../shared/auth/requireAuth.js';
import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
import crypto from 'crypto'; import crypto from 'crypto';
import cookieParser from 'cookie-parser';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -71,6 +73,8 @@ try {
// Create Express app // Create Express app
const app = express(); const app = express();
app.set('trust proxy', 1);
app.use(cookieParser());
const PORT = process.env.SERVER2_PORT || 5001; const PORT = process.env.SERVER2_PORT || 5001;
function fprPathFromEnv() { function fprPathFromEnv() {
@ -1158,93 +1162,88 @@ chatFreeEndpoint(app, {
* Returns 429 Too Many Requests if limits exceeded * Returns 429 Too Many Requests if limits exceeded
* Supports deduplication for 10 minutes * Supports deduplication for 10 minutes
* *************************************************/ * *************************************************/
app.post( const _supportSeen = new Map();
'/api/support', function _isDupAndRemember(key, ttlMs = 5 * 60 * 1000) {
authenticateUser, // logged-in only const now = Date.now();
supportBurstLimiter, const last = _supportSeen.get(key);
supportDailyLimiter, _supportSeen.set(key, now);
async (req, res) => { // sweep occasionally
try { for (const [k, t] of _supportSeen) if (now - t > ttlMs) _supportSeen.delete(k);
const user = req.user || {}; return last && (now - last) < ttlMs;
const userId = user.id || user.user_id || user.sub; // depends on your token }
if (!userId) { function _escape(s) {
return res.status(401).json({ error: 'Auth required' }); return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
} }
// Prefer token email; fall back to DB; last resort: body.email app.post('/api/support', requireAuth, async (req, res) => {
let accountEmail = user.email || user.mail || null; try {
if (!accountEmail) { const userId = req.userId || req.user?.id;
try { if (!userId) return res.status(401).json({ error: 'Auth required' });
const row = await userProfileDb.get(
'SELECT email FROM user_profile WHERE id = ?',
[userId]
);
accountEmail = row?.email || null;
} catch {}
}
if (!accountEmail) {
accountEmail = (req.body && req.body.email) || null;
}
if (!accountEmail) {
return res.status(400).json({ error: 'No email on file for this user' });
}
const { subject = '', category = 'general', message = '' } = req.body || {}; // 1) email priority: token → DB decrypted → request body
let accountEmail = req.user?.email || req.user?.mail || null;
// Basic validation if (!accountEmail) {
const allowedCats = new Set(['general','billing','technical','data','ux']); try {
const subj = subject.toString().slice(0, 120).trim(); const [rows] = await pool.query(
const body = message.toString().trim(); 'SELECT email FROM user_profile WHERE id = ? LIMIT 1',
[userId]
);
const enc = rows?.[0]?.email || null;
if (enc) {
try { accountEmail = decrypt(enc); } catch {}
}
} catch {}
}
if (!accountEmail) accountEmail = req.body?.email || null;
if (!accountEmail) {
return res.status(400).json({ error: 'No email on file for this user' });
}
if (!allowedCats.has(String(category))) { // 2) validate payload
return res.status(400).json({ error: 'Invalid category' }); const subject = String(req.body?.subject || '').slice(0, 120).trim();
} const category = String(req.body?.category || 'general');
if (body.length < 5) { const message = String(req.body?.message || '').trim();
return res.status(400).json({ error: 'Message too short' });
}
// Dedupe const allowed = new Set(['general','billing','technical','data','ux']);
const key = makeKey(userId, subj || '(no subject)', body); if (!allowed.has(category)) return res.status(400).json({ error: 'Invalid category' });
if (isDuplicateAndRemember(key)) { if (message.length < 5) return res.status(400).json({ error: 'Message too short' });
return res.status(202).json({ ok: true, deduped: true });
}
// Require mail config // 3) de-dup
const FROM = 'support@aptivaai.com'; const dedupeKey = `${userId}::${category}::${subject}::${message}`;
const TO = 'support@aptivaai.com'; if (_isDupAndRemember(dedupeKey)) {
return res.status(202).json({ ok: true, deduped: true });
}
if (!SENDGRID_KEY) { // 4) email config
const FROM = process.env.SUPPORT_FROM || 'support@aptivaai.com';
const TO = process.env.SUPPORT_TO || 'support@aptivaai.com';
if (!process.env.SENDGRID_KEY) {
return res.status(503).json({ error: 'Support email not configured' }); return res.status(503).json({ error: 'Support email not configured' });
} }
const humanSubject = // 5) send
`[Support • ${category}] ${subj || '(no subject)'} — user ${userId}`; const humanSubject = `[Support • ${category}] ${subject || '(no subject)'} — user ${userId}`;
const textBody = `User: ${userId}
const textBody =
`User: ${userId}
Email: ${accountEmail} Email: ${accountEmail}
Category: ${category} Category: ${category}
${body}`; ${message}`;
await sgMail.send({ await sgMail.send({
to: TO, to: TO,
from: FROM, from: FROM,
replyTo: accountEmail, replyTo: accountEmail,
subject: humanSubject, subject: humanSubject,
text: textBody, text: textBody,
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${textBody}</pre>`, html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${_escape(textBody)}</pre>`,
categories: ['support', String(category || 'general')] categories: ['support', category]
}); });
return res.json({ ok: true });
return res.status(200).json({ ok: true }); } catch (err) {
} catch (err) { console.error('[support] error:', err?.message || err);
console.error('[support] error:', err?.message || err); return res.status(500).json({ error: 'Failed to send support message' });
return res.status(500).json({ error: 'Failed to send support message' });
}
}
);
/************************************************** /**************************************************
* Start the Express server * Start the Express server

View File

@ -8,7 +8,6 @@ const __dirname = path.dirname(__filename);
import express from 'express'; import express from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import fs, { readFile } from 'fs/promises'; // <-- add this
import multer from 'multer'; import multer from 'multer';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import mammoth from 'mammoth'; import mammoth from 'mammoth';
@ -16,6 +15,9 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist'; import pkg from 'pdfjs-dist';
import pool from './config/mysqlPool.js'; import pool from './config/mysqlPool.js';
import * as fsSync from 'fs'; // for safeUnlink()
import { readFile } from 'fs/promises';
import cookieParser from 'cookie-parser';
import OpenAI from 'openai'; import OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
@ -27,6 +29,8 @@ import { hashForLookup } from './shared/crypto/encryption.js';
import './jobs/reminderCron.js'; import './jobs/reminderCron.js';
import { cacheSummary } from "./utils/ctxCache.js"; import { cacheSummary } from "./utils/ctxCache.js";
import { requireAuth } from './shared/requireAuth.js';
import rateLimit from 'express-rate-limit';
const rootPath = path.resolve(__dirname, '..'); const rootPath = path.resolve(__dirname, '..');
const env = (process.env.NODE_ENV || 'production'); const env = (process.env.NODE_ENV || 'production');
@ -38,6 +42,8 @@ if (!process.env.FROM_SECRETS_MANAGER) {
const PORT = process.env.SERVER3_PORT || 5002; const PORT = process.env.SERVER3_PORT || 5002;
const API_BASE = `http://localhost:${PORT}/api`; const API_BASE = `http://localhost:${PORT}/api`;
/* ─── helper: canonical public origin ─────────────────────────── */ /* ─── helper: canonical public origin ─────────────────────────── */
const PUBLIC_BASE = ( const PUBLIC_BASE = (
process.env.APTIVA_AI_BASE process.env.APTIVA_AI_BASE
@ -57,7 +63,11 @@ function isSafeRedirect(url) {
} }
const app = express(); const app = express();
app.set('trust proxy', 1);
app.use(cookieParser());
const { getDocument } = pkg; const { getDocument } = pkg;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' }); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' });
// ── Use raw pool for canary/db checks (avoid DAO wrapper noise) ── // ── Use raw pool for canary/db checks (avoid DAO wrapper noise) ──
@ -158,6 +168,17 @@ function internalFetch(req, urlPath, opts = {}) {
const auth = (req, urlPath, opts = {}) => internalFetch(req, urlPath, opts); const auth = (req, urlPath, opts = {}) => internalFetch(req, urlPath, opts);
const rlAuth = rateLimit({ windowMs: 10 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false }); // sign-in/signup/reset
const rlPublicAI = rateLimit({ windowMs: 10 * 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false }); // /api/public/*
const rlPremiumAI = rateLimit({ windowMs: 10 * 60 * 1000, max: 120, standardHeaders: true, legacyHeaders: false }); // paid AI features
const publicAIRiskLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 min
max: 30, // 30 requests / IP / 15min
standardHeaders: true,
legacyHeaders: false
});
// AI Risk Analysis Helper Functions // AI Risk Analysis Helper Functions
async function getRiskAnalysisFromDB(socCode) { async function getRiskAnalysisFromDB(socCode) {
const [rows] = await pool.query( const [rows] = await pool.query(
@ -230,6 +251,17 @@ app.post(
return res.status(400).end(); return res.status(400).end();
} }
// Idempotency: ignore if we've seen this event.id
try {
const [[seen]] = await pool.query('SELECT id FROM stripe_events WHERE id=?', [event.id]);
if (seen) {
return res.sendStatus(200); // already processed
}
} catch (e) {
console.error('[Stripe] idempotency check failed', e);
// still proceed; worst case Stripe retries
}
const upFlags = async (customerId, premium, pro) => { const upFlags = async (customerId, premium, pro) => {
const h = hashForLookup(customerId); const h = hashForLookup(customerId);
console.log('[Stripe] upFlags', { customerId, premium, pro }); console.log('[Stripe] upFlags', { customerId, premium, pro });
@ -260,7 +292,12 @@ app.post(
default: default:
// Ignore everything else // Ignore everything else
} }
res.sendStatus(200); try {
await pool.query('INSERT INTO stripe_events (id) VALUES (?)', [event.id]);
} catch (e) {
// race-safe: duplicate key just means another worker won
}
res.sendStatus(200);
} }
); );
@ -312,23 +349,12 @@ app.use((req, res, next) => {
}); });
// 3) Authentication middleware // 3) Authentication middleware
const authenticatePremiumUser = (req, res, next) => { const authenticatePremiumUser = (req, res, next) =>
const token = (req.headers.authorization || '') requireAuth(req, res, () => {
.replace(/^Bearer\s+/i, '') // drop “Bearer ” // preserve existing field name so routes dont change
.trim(); // strip CR/LF, spaces req.id = req.userId;
if (!token) {
return res.status(401).json({ error: 'Premium authorization required' });
}
try {
const JWT_SECRET = process.env.JWT_SECRET;
const { id } = jwt.verify(token, JWT_SECRET);
req.id = id; // store user ID in request
next(); next();
} catch (error) { });
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
/** ------------------------------------------------------------------ /** ------------------------------------------------------------------
* Returns the users stripe_customer_id (or null) given req.id. * Returns the users stripe_customer_id (or null) given req.id.
@ -742,180 +768,7 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs
} }
}); });
/*************************************************** app.post('/api/premium/ai/chat', rlPremiumAI, authenticatePremiumUser, async (req, res) => {
AI - NEXT STEPS ENDPOINT (with date constraints,
ignoring scenarioRow.start_date)
****************************************************/
app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res) => {
try {
// 1) Gather user data from request
const {
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {},
previouslyUsedTitles = []
} = req.body;
// 2) Build a summary for ChatGPT
// (We'll ignore scenarioRow.start_date in the prompt)
// 4. Get / build the cached big-context card (one DB hit, or none on cache-hit)
// build the big summary with your local helper
let summaryText = buildUserSummary({
userProfile,
scenarioRow,
financialProfile,
collegeProfile,
aiRisk
});
summaryText = await cacheSummary(req.id, scenarioRow.id, summaryText);
let avoidSection = '';
if (previouslyUsedTitles.length > 0) {
avoidSection = `\nDO NOT repeat the following milestone titles:\n${previouslyUsedTitles
.map((t) => `- ${t}`)
.join('\n')}\n`;
}
// 3) Dynamically compute "today's" date and future cutoffs
const now = new Date();
const isoToday = now.toISOString().slice(0, 10); // e.g. "2025-06-01"
// short-term = within 6 months
const shortTermLimit = new Date(now);
shortTermLimit.setMonth(shortTermLimit.getMonth() + 6);
const isoShortTermLimit = shortTermLimit.toISOString().slice(0, 10);
// long-term = 1-3 years
const oneYearFromNow = new Date(now);
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
const isoOneYearFromNow = oneYearFromNow.toISOString().slice(0, 10);
const threeYearsFromNow = new Date(now);
threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3);
const isoThreeYearsFromNow = threeYearsFromNow.toISOString().slice(0, 10).slice(0, 10);
// 4) Construct ChatGPT messages
const messages = [
{
role: 'system',
content: `
You are an expert career & financial coach.
Today's date: ${isoToday}.
Short-term means any date up to ${isoShortTermLimit} (within 6 months).
Long-term means a date between ${isoOneYearFromNow} and ${isoThreeYearsFromNow} (1-3 years).
All milestone dates must be strictly >= ${isoToday}. Titles must be <= 5 words.
IMPORTANT RESTRICTIONS:
- NEVER suggest specific investments in cryptocurrency, stocks, or other speculative financial instruments.
- NEVER provide specific investment advice without appropriate risk disclosures.
- NEVER provide legal, medical, or psychological advice.
- ALWAYS promote responsible and low-risk financial planning strategies.
- Emphasize skills enhancement, networking, and education as primary pathways to financial success.
Respond ONLY in the requested JSON format.`
},
{
role: 'user',
content: `
Here is the user's current situation:
${summaryText}
Please provide exactly 2 short-term (within 6 months) and 1 long-term (13 years) milestones. Avoid any previously suggested milestones.
Each milestone must have:
- "title" (up to 5 words)
- "date" in YYYY-MM-DD format (>= ${isoToday})
- "description" (1-2 sentences)
${avoidSection}
Return ONLY a JSON array, no extra text:
[
{
"title": "string",
"date": "YYYY-MM-DD",
"description": "string"
},
...
]`
}
];
// 5) Call OpenAI (ignoring scenarioRow.start_date for date logic)
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini', // or 'gpt-4'
messages,
temperature: 0.7,
max_tokens: 600
});
// 6) Extract raw text
const aiAdvice = completion?.choices?.[0]?.message?.content?.trim() || 'No response';
res.json({ recommendations: aiAdvice });
} catch (err) {
console.error('Error in /api/premium/ai/next-steps =>', err);
res.status(500).json({ error: 'Failed to get AI next steps.' });
}
});
/**
* Helper that converts user data into a concise text summary.
* This can still mention scenarioRow, but we do NOT feed
* scenarioRow.start_date to ChatGPT for future date calculations.
*/
function buildUserSummary({
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {},
aiRisk = null
}) {
const location = `${userProfile.state || 'Unknown State'}, ${userProfile.area || 'N/A'}`;
const careerName = scenarioRow.career_name || 'Unknown';
const careerGoals = scenarioRow.career_goals || 'No goals specified';
const status = scenarioRow.status || 'planned';
const currentlyWorking = scenarioRow.currently_working || 'no';
const currentSalary = financialProfile.current_salary || 0;
const monthlyExpenses = financialProfile.monthly_expenses || 0;
const monthlyDebt = financialProfile.monthly_debt_payments || 0;
const retirementSavings = financialProfile.retirement_savings || 0;
const emergencyFund = financialProfile.emergency_fund || 0;
let riskText = '';
if (aiRisk?.riskLevel) {
riskText = `
AI Automation Risk: ${aiRisk.riskLevel}
Reasoning: ${aiRisk.reasoning}`;
}
return `
User Location: ${location}
Career Name: ${careerName}
Career Goals: ${careerGoals}
Career Status: ${status}
Currently Working: ${currentlyWorking}
Financial:
- Salary: \$${currentSalary}
- Monthly Expenses: \$${monthlyExpenses}
- Monthly Debt: \$${monthlyDebt}
- Retirement Savings: \$${retirementSavings}
- Emergency Fund: \$${emergencyFund}
${riskText}
`.trim();
}
// Example: ai/chat with correct milestone-saving logic
// At the top of server3.js, leave your imports and setup as-is
// (No need to import 'pluralize' if we're no longer using it!)
app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
try { try {
const { const {
userProfile = {}, userProfile = {},
@ -1459,7 +1312,7 @@ ${avoidBlock}
`.trim(); `.trim();
const NEEDS_OPS_CARD = !chatHistory.some( const NEEDS_OPS_CARD = !chatHistory.some(
m => m.role === "system" && m.content.includes("APTIVA OPS CHEAT-SHEET") m => m.role === "system" && m.content.includes("APTIVA OPS YOU CAN USE ANY TIME")
); );
const NEEDS_CTX_CARD = !chatHistory.some( const NEEDS_CTX_CARD = !chatHistory.some(
@ -1476,8 +1329,14 @@ if (NEEDS_OPS_CARD) {
messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD }); messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD });
} }
if (NEEDS_CTX_CARD || SEND_CTX_CARD) if (SEND_CTX_CARD) {
messagesToSend.push({ role:"system", content: summaryText }); const systemPromptDetailedContext = `
[DETAILED USER PROFILE & CONTEXT]
${summaryText}
`.trim();
messagesToSend.push({ role: "system", content: systemPromptDetailedContext });
}
// ② Per-turn contextual helpers (small!) // ② Per-turn contextual helpers (small!)
messagesToSend.push( messagesToSend.push(
@ -1692,7 +1551,7 @@ Check your Milestones tab. Let me know if you want any changes!
RETIREMENT AI-CHAT ENDPOINT (clone + patch) RETIREMENT AI-CHAT ENDPOINT (clone + patch)
*/ */
app.post( app.post(
'/api/premium/retirement/aichat', '/api/premium/retirement/aichat', rlPremiumAI,
authenticatePremiumUser, authenticatePremiumUser,
async (req, res) => { async (req, res) => {
try { try {
@ -2134,12 +1993,12 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
} }
}); });
app.post('/api/public/ai-risk-analysis', async (req, res) => { app.post('/api/public/ai-risk-analysis', publicAIRiskLimiter, async (req, res) => {
try { try {
const { const {
socCode, socCode,
careerName, careerName,
jobDescription, jobDescription = '',
tasks = [] tasks = []
} = req.body; } = req.body;
@ -2147,10 +2006,15 @@ app.post('/api/public/ai-risk-analysis', async (req, res) => {
return res.status(400).json({ error: 'socCode and careerName are required.' }); return res.status(400).json({ error: 'socCode and careerName are required.' });
} }
// simple size clamps to keep prompts sane / cheap
const jd = String(jobDescription).slice(0, 2000);
const safeTasks = Array.isArray(tasks)
? tasks.slice(0, 25).map(t => String(t).slice(0, 200))
: [];
const prompt = ` const prompt = `
The user has a career named: ${careerName} The user has a career named: ${careerName}
Description: ${jobDescription} Description: ${jd}
Tasks: ${tasks.join('; ')} Tasks: ${safeTasks.join('; ')}
Provide AI automation risk analysis for the next 10 years. Provide AI automation risk analysis for the next 10 years.
Return JSON exactly in this format: Return JSON exactly in this format:
@ -3617,7 +3481,7 @@ let allKsaNames = []; // an array of unique KSA names (for fuzzy matching)
(async function loadKsaJson() { (async function loadKsaJson() {
try { try {
const filePath = path.join(__dirname, '..', 'public', 'ksa_data.json'); const filePath = path.join(__dirname, '..', 'public', 'ksa_data.json');
const raw = await fs.readFile(filePath, 'utf8'); const raw = await readFile(filePath, 'utf8');
onetKsaData = JSON.parse(raw); onetKsaData = JSON.parse(raw);
// Build a set of unique KSA names for fuzzy search // Build a set of unique KSA names for fuzzy search
@ -3763,7 +3627,8 @@ function processChatGPTKsa(chatGptKSA, ksaType) {
// 6) The new route // 6) The new route
app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => { app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => {
const { socCode } = req.params; const { socCode } = req.params;
const { careerTitle = '' } = req.query; // or maybe from body const { careerTitle: rawTitle = '' } = req.query;
const careerTitle = String(rawTitle).slice(0, 120);
try { try {
// 1) Check local data // 1) Check local data
@ -3857,7 +3722,16 @@ return res.json({
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
// Setup file upload via multer // Setup file upload via multer
const upload = multer({ dest: 'uploads/' }); const upload = multer({
dest: 'uploads/',
limits: { fileSize: 8 * 1024 * 1024 }, // 8 MB
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 file type'), ok);
}
});
function buildResumePrompt(resumeText, jobTitle, jobDescription) { function buildResumePrompt(resumeText, jobTitle, jobDescription) {
// Full ChatGPT prompt for resume optimization: // Full ChatGPT prompt for resume optimization:
@ -3902,9 +3776,11 @@ async function extractTextFromPDF(filePath) {
app.post( app.post(
'/api/premium/resume/optimize', '/api/premium/resume/optimize',
upload.single('resumeFile'), upload.single('resumeFile'),
authenticatePremiumUser, authenticatePremiumUser,
async (req, res) => { async (req, res) => {
const tmpPath = req.file?.path;
const safeUnlink = () => { try { if (tmpPath) fsSync.unlinkSync(tmpPath); } catch {} };
try { try {
const { jobTitle, jobDescription } = req.body; const { jobTitle, jobDescription } = req.body;
if (!jobTitle || !jobDescription || !req.file) { if (!jobTitle || !jobDescription || !req.file) {
@ -3970,7 +3846,6 @@ app.post(
const result = await mammoth.extractRawText({ path: filePath }); const result = await mammoth.extractRawText({ path: filePath });
resumeText = result.value; resumeText = result.value;
} else { } else {
await fs.unlink(filePath);
return res.status(400).json({ error: 'Unsupported or corrupted file upload.' }); return res.status(400).json({ error: 'Unsupported or corrupted file upload.' });
} }
@ -3996,8 +3871,7 @@ app.post(
const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1); const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1);
// remove uploaded file
await fs.unlink(filePath);
res.json({ res.json({
optimizedResume, optimizedResume,
@ -4005,8 +3879,11 @@ app.post(
resetDate: resetDate.toISOString().slice(0, 10) resetDate: resetDate.toISOString().slice(0, 10)
}); });
} catch (err) { } catch (err) {
console.error('Error optimizing resume:', err); console.error('Error optimizing resume:', err);
res.status(500).json({ error: 'Failed to optimize resume.' }); res.status(500).json({ error: 'Failed to optimize resume.' });
} finally {
// always clean up the temp upload
safeUnlink();
} }
} }
); );

View File

@ -2,41 +2,54 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import pool from '../config/mysqlPool.js'; import pool from '../config/mysqlPool.js';
const { JWT_SECRET, TOKEN_MAX_AGE_MS } = process.env; const { JWT_SECRET, TOKEN_MAX_AGE_MS, ACCESS_COOKIE_NAME = 'aptiva_access' } = process.env;
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0);
function extractBearer(authz) {
if (!authz || typeof authz !== 'string') return '';
if (!authz.toLowerCase().startsWith('bearer ')) return '';
const v = authz.slice(7).trim();
if (!v || v === 'null' || v === 'undefined') return '';
return v;
}
export async function requireAuth(req, res, next) { export async function requireAuth(req, res, next) {
try { try {
const authz = req.headers.authorization || ''; const cookieToken = req.cookies?.[ACCESS_COOKIE_NAME];
const token = authz.startsWith('Bearer ') ? authz.slice(7) : ''; const bearerToken = extractBearer(req.headers.authorization);
const token = cookieToken || bearerToken; // cookie always wins
if (!token) return res.status(401).json({ error: 'Auth required' }); if (!token) return res.status(401).json({ error: 'Auth required' });
let payload; let payload;
try { payload = jwt.verify(token, JWT_SECRET); } try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(401).json({ error: 'Invalid or expired token' }); } catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
const userId = payload.id; const userId = payload.sub || payload.id || payload.userId;
const iatMs = (payload.iat || 0) * 1000; const iatMs = (payload.iat || 0) * 1000;
// Absolute max token age (optional, off by default)
if (MAX_AGE && Date.now() - iatMs > MAX_AGE) { if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
return res.status(401).json({ error: 'Session expired. Please sign in again.' }); return res.status(401).json({ error: 'Session expired. Please sign in again.' });
} }
// Reject tokens issued before last password change
const [rows] = await (pool.raw || pool).query( const [rows] = await (pool.raw || pool).query(
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1', 'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId] [userId]
); );
const changedAt = rows?.[0]?.password_changed_at || 0; const changedAtMs = rows?.[0]?.password_changed_at ? new Date(rows[0].password_changed_at).getTime() : 0;
if (changedAt && iatMs < changedAt) { if (changedAtMs && iatMs < changedAtMs) {
return res.status(401).json({ error: 'Session invalidated. Please sign in again.' }); return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
} }
req.userId = userId; req.user = (payload && typeof payload === 'object')
? { ...payload, id: userId }
: { id: userId };
req.userId = userId;
next();
next(); next();
} catch (e) { } catch (e) {
console.error('[requireAuth]', e?.message || e); console.error('[requireAuth]', e?.message || e);
return res.status(500).json({ error: 'Server error' }); res.status(500).json({ error: 'Server error' });
} }
} }

View File

@ -26,6 +26,7 @@ SECRETS=(
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \ DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \ SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \ TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \
ACCESS_COOKIE_NAME COOKIE_SECURE COOKIE_SAMESITE TOKEN_MAX_AGE_MS \
KMS_KEY_NAME DEK_PATH KMS_KEY_NAME DEK_PATH
) )

View File

@ -32,6 +32,10 @@ services:
environment: environment:
ENV_NAME: ${ENV_NAME} ENV_NAME: ${ENV_NAME}
APTIVA_API_BASE: ${APTIVA_API_BASE} APTIVA_API_BASE: ${APTIVA_API_BASE}
ACCESS_COOKIE_NAME: ${ACCESS_COOKIE_NAME}
COOKIE_SECURE: ${COOKIE_SECURE}
COOKIE_SAMESITE: ${COOKIE_SAMESITE}
TOKEN_MAX_AGE_MS: ${TOKEN_MAX_AGE_MS}
PROJECT: ${PROJECT} PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
@ -79,6 +83,10 @@ services:
PROJECT: ${PROJECT} PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
ACCESS_COOKIE_NAME: ${ACCESS_COOKIE_NAME}
COOKIE_SECURE: ${COOKIE_SECURE}
COOKIE_SAMESITE: ${COOKIE_SAMESITE}
TOKEN_MAX_AGE_MS: ${TOKEN_MAX_AGE_MS}
ONET_USERNAME: ${ONET_USERNAME} ONET_USERNAME: ${ONET_USERNAME}
ONET_PASSWORD: ${ONET_PASSWORD} ONET_PASSWORD: ${ONET_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
@ -128,6 +136,10 @@ services:
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
ACCESS_COOKIE_NAME: ${ACCESS_COOKIE_NAME}
COOKIE_SECURE: ${COOKIE_SECURE}
COOKIE_SAMESITE: ${COOKIE_SAMESITE}
TOKEN_MAX_AGE_MS: ${TOKEN_MAX_AGE_MS}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}

View File

@ -179,3 +179,7 @@ UPDATE user_auth
SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000) SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000)
WHERE user_id = ? WHERE user_id = ?
CREATE TABLE IF NOT EXISTS stripe_events (
id VARCHAR(255) PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

23
package-lock.json generated
View File

@ -25,6 +25,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cra-template": "1.2.0", "cra-template": "1.2.0",
"docx": "^9.5.0", "docx": "^9.5.0",
@ -7328,6 +7329,28 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cra-template": "1.2.0", "cra-template": "1.2.0",
"docx": "^9.5.0", "docx": "^9.5.0",

View File

@ -41,7 +41,7 @@ import BillingResult from './components/BillingResult.js';
import SupportModal from './components/SupportModal.js'; import SupportModal from './components/SupportModal.js';
import ForgotPassword from './components/ForgotPassword.js'; import ForgotPassword from './components/ForgotPassword.js';
import ResetPassword from './components/ResetPassword.js'; import ResetPassword from './components/ResetPassword.js';
import { clearToken } from '../auth/authMemory.js'; import { clearToken } from './auth/authMemory.js';
@ -172,57 +172,63 @@ const showPremiumCTA = !premiumPaths.some(p =>
setUserEmail(user?.email || ''); setUserEmail(user?.email || '');
}, [user]); }, [user]);
// ============================== /* Multi-tab signout listener */
// 1) Single Rehydrate UseEffect
// ==============================
useEffect(() => { useEffect(() => {
// 🚫 Never hydrate auth while on the reset page const onStorage = (e) => {
if (location.pathname.startsWith('/reset-password')) { if (e.key === 'token' && !e.newValue) {
try { // another tab cleared the token
localStorage.removeItem('token'); clearToken();
localStorage.removeItem('id'); setIsAuthenticated(false);
} catch {} setUser(null);
setIsAuthenticated(false); navigate('/signin?session=expired');
setUser(null);
setIsLoading(false);
return;
}
const token = localStorage.getItem('token');
if (!token) {
// No token => not authenticated
setIsLoading(false);
return;
} }
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [navigate]);
// ==============================
// 1) Single Rehydrate UseEffect (cookie mode)
// ==============================
useEffect(() => {
let cancelled = false;
(async () => {
const isAuthRoute =
location.pathname === '/signin' ||
location.pathname === '/signup' ||
location.pathname === '/forgot-password' ||
location.pathname.startsWith('/reset-password');
if (isAuthRoute) {
try { localStorage.removeItem('token'); localStorage.removeItem('id'); } catch {}
if (!cancelled) {
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
}
return;
}
try {
// Cookie goes automatically; shim sends credentials:'include'
const res = await fetch('/api/user-profile', { credentials: 'include' });
if (!res.ok) throw new Error('unauthorized');
const profile = await res.json();
if (cancelled) return;
setUser(profile);
setFinancialProfile(profile);
setIsAuthenticated(true);
} catch {
if (cancelled) return;
setIsAuthenticated(false);
setUser(null);
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => { cancelled = true; };
}, [location.pathname]);
// If we have a token, validate it by fetching user
fetch('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('Token invalid on server side');
return res.json();
})
.then((profile) => {
// Successfully got user profile => user is authenticated
setUser(profile);
setIsAuthenticated(true);
})
.catch((err) => {
console.error(err);
// Invalid token => remove it, force sign in
localStorage.removeItem('token');
setIsAuthenticated(false);
setUser(null);
navigate('/signin?session=expired');
})
.finally(() => {
// Either success or fail, we're done loading
setIsLoading(false);
});
}, [navigate, location.pathname]);
// ========================== // ==========================
// 2) Logout Handler + Modal // 2) Logout Handler + Modal
@ -237,31 +243,35 @@ const showPremiumCTA = !premiumPaths.some(p =>
} }
}; };
const confirmLogout = async () => {
// Clear any sensitive values from Web Storage
[
'token',
'id',
'careerSuggestionsCache',
'lastSelectedCareerProfileId',
'selectedCareer',
'aiClickCount',
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'financialProfile'
].forEach(k => {
try { localStorage.removeItem(k); } catch {}
});
const confirmLogout = () => { // Clear in-memory token
localStorage.removeItem('token'); try { await fetch('/api/logout', { method: 'POST', credentials: 'include' }); } catch {}
localStorage.removeItem('id'); try { clearToken(); } catch {}
localStorage.removeItem('careerSuggestionsCache');
localStorage.removeItem('lastSelectedCareerProfileId');
localStorage.removeItem('selectedCareer');
localStorage.removeItem('aiClickCount');
localStorage.removeItem('aiClickDate');
localStorage.removeItem('aiRecommendations');
localStorage.removeItem('premiumOnboardingState'); // ← NEW
localStorage.removeItem('financialProfile'); // ← if you cache it
setFinancialProfile(null); // ← reset any React-context copy // Reset React state/context
setFinancialProfile(null);
setScenario(null); setScenario(null);
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setShowLogoutWarning(false); setShowLogoutWarning(false);
// Reset auth // Navigate to Sign In
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
navigate('/signin'); navigate('/signin');
}; };

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { getToken } from './authMemory.js';
export default function ProtectedRoute({ children }) {
const location = useLocation();
const token = getToken();
if (!token) {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/signin?next=${next}`} replace />;
}
return children;
}

View File

@ -1,9 +1,82 @@
// apiFetch.js // src/auth/apiFetch.js
import { getToken } from './authMemory.js'; //
// A tiny wrapper around window.fetch.
// - NEVER sets Authorization (shim does that).
// - Smart JSON handling (auto stringify, auto parse in helpers).
// - Optional timeout via AbortController.
// - Optional shim bypass: { bypassAuth: true } adds X-Bypass-Auth: 1.
// - Leaves error semantics up to caller or helper.
//
// Use:
// const res = await apiFetch('/api/user-profile');
// const json = await res.json();
//
// Or helpers:
// const data = await apiGetJSON('/api/user-profile');
// const out = await apiPostJSON('/api/premium/thing', { foo: 'bar' });
export async function apiFetch(input, init = {}) { const DEFAULT_TIMEOUT_MS = 25000; // 25s
const headers = new Headers(init.headers || {});
const t = getToken(); export async function apiFetch(url, options = {}) {
if (t) headers.set('Authorization', `Bearer ${t}`); const headers = new Headers(options.headers || {});
return fetch(input, { ...init, headers }); const init = { ...options, credentials: options.credentials || 'include' };
}
// If body is a plain object and no Content-Type set, send JSON
if (!headers.has('Content-Type') &&
options.body &&
typeof options.body === 'object' &&
!(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
init.body = JSON.stringify(options.body);
}
if (headers.size) init.headers = headers;
// This must always return a Response, never null
return fetch(url, init);
}
export async function apiGetJSON(url) {
const res = await apiFetch(url);
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
return res.json();
}
export async function apiPostJSON(url, payload) {
const res = await apiFetch(url, { method: 'POST', body: payload });
if (!res.ok) {
const errBody = await res.json().catch(() => ({}));
const msg = errBody?.error || `POST ${url} failed: ${res.status}`;
throw new Error(msg);
}
return res.json().catch(() => ({}));
}
/** PUT JSON → parse JSON, throw on !ok. */
export async function apiPutJSON(url, payload, opts = {}) {
const res = await apiFetch(url, { ...opts, method: 'PUT', body: payload });
const text = await res.text();
const data = safeJSON(text);
if (!res.ok) throw new Error(errorFromServer(data, text, res.status));
return data;
}
/** DELETE → parse JSON (if any), throw on !ok. */
export async function apiDeleteJSON(url, opts = {}) {
const res = await apiFetch(url, { ...opts, method: 'DELETE' });
const text = await res.text();
const data = safeJSON(text);
if (!res.ok) throw new Error(errorFromServer(data, text, res.status));
return data || { ok: true };
}
/* -------------------- utils -------------------- */
function safeJSON(text) {
if (!text) return null;
try { return JSON.parse(text); } catch { return null; }
}
function errorFromServer(json, text, status) {
if (json && typeof json === 'object' && json.error) return json.error;
if (text) return `Request failed (${status}): ${text.slice(0, 240)}`;
return `Request failed (${status})`;
}

View File

@ -0,0 +1,31 @@
// src/auth/installAxiosAuthShim.js
import axios from 'axios';
export function installAxiosAuthShim({ debug = false } = {}) {
axios.defaults.withCredentials = true;
axios.interceptors.request.use((config) => {
try {
const url = new URL(config.url, window.location.origin);
const isSameOrigin = url.origin === window.location.origin;
const isApi = url.pathname.startsWith('/api/');
if (isSameOrigin && isApi && config.headers) {
const auth = String(config.headers.Authorization || '').trim();
if (/^Bearer(\s*(null|undefined)?)?$/i.test(auth)) {
delete config.headers.Authorization; // let cookie flow
if (debug) console.debug('[axiosShim] stripped bad Authorization');
}
}
} catch {}
return config;
});
axios.interceptors.response.use(r => r, (err) => {
const s = err?.response?.status;
if ([401,403,419,440].includes(s) && !window.location.pathname.startsWith('/signin')) {
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.replace(`/signin?session=expired&next=${next}`);
}
return Promise.reject(err);
});
}

View File

@ -0,0 +1,135 @@
// src/auth/installFetchAuthShim.js
import { getToken, clearToken } from './authMemory.js';
/**
* Monkey-patches window.fetch to auto-attach Authorization for same-origin /api/* calls,
* while never attaching to explicitly public endpoints.
*
* Usage (in index.js):
* import { installFetchAuthShim } from './auth/installFetchAuthShim.js';
* installFetchAuthShim({ debug: false, publicPaths: [...] });
*/
export function installFetchAuthShim(opts = {}) {
if (window.__aptivaFetchShimInstalled) return;
window.__aptivaFetchShimInstalled = true;
const {
debug = false,
attachBearer = false,
// Add/override from App-level knowledge if needed
publicPaths = [
'/api/signin',
'/api/signup',
'/api/register',
'/api/check-username',
'/api/areas',
'/api/auth/password-reset/request',
'/api/auth/password-reset/confirm',
'/api/public',
'/livez',
'/readyz',
'/healthz',
// public salary lookup
],
} = opts;
if (!window || !window.fetch) return;
const originalFetch = window.fetch;
let redirecting = false; // loop guard
window.fetch = async (input, init = {}) => {
try {
// Build a URL object for robust origin/path checks
const requestUrl = (() => {
if (typeof input === 'string') {
// Relative or absolute
return new URL(input, window.location.origin);
}
// Request or URL object
return new URL(input.url, window.location.origin);
})();
const isSameOrigin = requestUrl.origin === window.location.origin;
const isApiCall = requestUrl.pathname.startsWith('/api/');
const fullPath = requestUrl.pathname + requestUrl.search;
// Respect explicit bypass header for one-offs (handy in staging/debug)
const existingHeaders = new Headers(init.headers || (typeof input === 'object' ? input.headers : undefined) || {});
const bypass = existingHeaders.get('X-Bypass-Auth') === '1';
// Never attach to any configured public path
const isPublic = publicPaths.some((p) => fullPath.startsWith(p));
// Only attach if same-origin, /api/*, not public, and not bypassed
const shouldAttachAuth =
isSameOrigin &&
isApiCall &&
!isPublic &&
!bypass;
// Clone headers to avoid mutating caller's object
const headers = new Headers(existingHeaders);
if (attachBearer && shouldAttachAuth && !headers.has('Authorization')) {
const token = getToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
if (debug) {
// Minimal, non-sensitive log
// (do not log the token or any body)
// eslint-disable-next-line no-console
console.debug(`[authShim] → ${init.method || 'GET'} ${requestUrl.pathname} (auth attached)`);
}
} else if (debug) {
// eslint-disable-next-line no-console
console.debug(`[authShim] → ${init.method || 'GET'} ${requestUrl.pathname} (no token available)`);
}
} else if (debug) {
// eslint-disable-next-line no-console
console.debug(
`[authShim] → ${init.method || 'GET'} ${requestUrl.pathname} (auth NOT attached: ` +
`${isSameOrigin ? '' : 'cross-origin '} ${isApiCall ? '' : 'non-/api '} ${isPublic ? 'public ' : ''}${bypass ? 'bypass ' : ''})`
);
}
// If caller already set Authorization, we never overwrite it.
const finalInit = {
...init,
headers: headers.size ? headers : init.headers,
credentials: init.credentials || 'include', // send cookies
};
const res = await originalFetch(input, finalInit);
// Centralized expired/unauthorized handling (same-origin, non-public API)
const expired = [401, 403, 419, 440].includes(res.status);
if (isSameOrigin && isApiCall && !isPublic && expired) {
try { clearToken(); } catch {}
// NEW: call the optional global handler (back-compat with setSessionExpiredCallback)
const handler = window.__aptivaOnSessionExpired;
if (typeof handler === 'function') {
try { handler({ path: requestUrl.pathname, status: res.status, response: res }); } catch {}
}
// Fallback redirect if the app didn't handle it
const onSignin = window.location.pathname.startsWith('/signin');
if (!redirecting && !onSignin) {
redirecting = true;
const next = encodeURIComponent(window.location.pathname + window.location.search);
if (debug) console.debug('[authShim] 401 → redirecting to /signin');
window.location.replace(`/signin?session=expired&next=${next}`);
}
}
return res;
} catch (e) {
// On any unexpected error, fall back to original fetch without blocking the request
if (debug) {
// eslint-disable-next-line no-console
console.debug('[authShim] error', e);
}
return originalFetch(input, init);
}
};
}

16
src/auth/useAuthGuard.js Normal file
View File

@ -0,0 +1,16 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { getToken } from './authMemory.js';
export function useAuthGuard() {
const nav = useNavigate();
const loc = useLocation();
return () => {
const token = getToken();
if (!token) {
const next = encodeURIComponent(loc.pathname + loc.search);
nav(`/signin?next=${next}`, { replace: true });
return false;
}
return true;
};
}

View File

@ -291,10 +291,7 @@ function CareerExplorer() {
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
try { try {
const token = localStorage.getItem('token'); const res = await axios.get('/api/user-profile');
const res = await axios.get('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 200) { if (res.status === 200) {
const profileData = res.data; const profileData = res.data;
@ -1054,7 +1051,7 @@ const handleSelectForEducation = async (career) => {
defaultMeaning={modalData.defaultMeaning} defaultMeaning={modalData.defaultMeaning}
/> />
{selectedCareer && ( {selectedCareer && careerDetails && (
<CareerModal <CareerModal
career={selectedCareer} career={selectedCareer}
careerDetails={careerDetails} careerDetails={careerDetails}

View File

@ -4,6 +4,8 @@ import CareerSearch from './CareerSearch.js';
import { ONET_DEFINITIONS } from './definitions.js'; import { ONET_DEFINITIONS } from './definitions.js';
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js'; import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
import ChatCtx from '../contexts/ChatCtx.js'; import ChatCtx from '../contexts/ChatCtx.js';
import { onboardingState } from '../utils/onboardingState.js';
import authFetch from '../utils/authFetch.js';
// Helper to combine IM and LV for each KSA // Helper to combine IM and LV for each KSA
function combineIMandLV(rows) { function combineIMandLV(rows) {
@ -143,10 +145,12 @@ function normalizeCipList(arr) {
'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' 'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
); );
if (proceed) { if (proceed) {
const storedOnboarding = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}'); const cur = onboardingState.get();
storedOnboarding.collegeData = storedOnboarding.collegeData || {}; const next = {
storedOnboarding.collegeData.selectedSchool = school; // or any property name ...cur,
localStorage.setItem('premiumOnboardingState', JSON.stringify(storedOnboarding)); collegeData: { ...(cur.collegeData || {}), selectedSchool: school }
};
onboardingState.set(next);
navigate('/career-roadmap', { state: { selectedSchool: school } }); navigate('/career-roadmap', { state: { selectedSchool: school } });
} }
}; };
@ -246,14 +250,7 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
async function loadUserProfile() { async function loadUserProfile() {
try { try {
const token = localStorage.getItem('token'); const res = await authFetch('/api/user-profile');
if (!token) {
console.warn('No token found, cannot load user-profile.');
return;
}
const res = await fetch('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to fetch user profile'); if (!res.ok) throw new Error('Failed to fetch user profile');
const data = await res.json(); const data = await res.json();
setUserZip(data.zipcode || ''); setUserZip(data.zipcode || '');
@ -588,19 +585,8 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
setKsaError(null); setKsaError(null);
try { try {
const token = localStorage.getItem('token'); const resp = await authFetch(
if (!token) { `/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`
throw new Error('No auth token found; cannot fetch AI-based KSAs.');
}
// Call the new endpoint in server3.js
const resp = await fetch(
`/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
); );
if (!resp.ok) { if (!resp.ok) {

View File

@ -1,7 +1,8 @@
import React, { useRef, useState, useEffect, useContext } from 'react'; import React, { useRef, useState, useEffect, useContext } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { ProfileCtx } from '../App.js'; import { ProfileCtx } from '../App.js';
import { setToken } from '../auth/authMemory.js'; import { clearToken } from '../auth/authMemory.js';
import { apiFetch, apiGetJSON } from '../auth/apiFetch.js';
function SignIn({ setIsAuthenticated, setUser }) { function SignIn({ setIsAuthenticated, setUser }) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -13,7 +14,6 @@ function SignIn({ setIsAuthenticated, setUser }) {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
// Check if the URL query param has ?session=expired
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
if (query.get('session') === 'expired') { if (query.get('session') === 'expired') {
setShowSessionExpiredMsg(true); setShowSessionExpiredMsg(true);
@ -21,80 +21,80 @@ function SignIn({ setIsAuthenticated, setUser }) {
}, [location.search]); }, [location.search]);
const handleSignIn = async (event) => { const handleSignIn = async (event) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
// 0⃣ clear everything that belongs to the *previous* user // 0⃣ Clear anything that might carry over from a previous user/session
localStorage.removeItem('careerSuggestionsCache'); try {
localStorage.removeItem('lastSelectedCareerProfileId'); [
localStorage.removeItem('aiClickCount'); 'careerSuggestionsCache',
localStorage.removeItem('aiClickDate'); 'lastSelectedCareerProfileId',
localStorage.removeItem('aiRecommendations'); 'aiClickCount',
localStorage.removeItem('premiumOnboardingState'); 'aiClickDate',
localStorage.removeItem('financialProfile'); // if you cache it 'aiRecommendations',
localStorage.removeItem('selectedScenario'); 'premiumOnboardingState',
'financialProfile',
'selectedScenario',
'token', // legacy cleanup
'id' // legacy cleanup
].forEach((k) => localStorage.removeItem(k));
} catch {}
const username = usernameRef.current.value; const username = usernameRef.current.value;
const password = passwordRef.current.value; const password = passwordRef.current.value;
if (!username || !password) { if (!username || !password) {
setError('Please enter both username and password'); setError('Please enter both username and password');
return; return;
} }
try { try {
const resp = await fetch('/api/signin', { const resp = await fetch('/api/signin', {
method : 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({username, password}), body: JSON.stringify({ username, password }),
}); credentials: 'include',
});
const data = await resp.json(); // ← read ONCE // Always read body once
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || 'Failed to sign in'); if (!resp.ok) {
throw new Error(data?.error || 'Failed to sign in');
}
/* ---------------- success path ---------------- */ // ---------------- success path ----------------
const { token, id, user } = data;
// fetch current user profile immediately const profile = await apiGetJSON('/api/user-profile');
const profileRes = await fetch('/api/user-profile', { const { user } = data;
headers: { Authorization: `Bearer ${token}` } setFinancialProfile(profile);
}); setScenario(null);
const profile = await profileRes.json();
setFinancialProfile(profile);
setScenario(null); // or fetch latest scenario separately
/* purge any leftovers from prior session */ // Mark auth in your app state
['careerSuggestionsCache', setIsAuthenticated(true);
'lastSelectedCareerProfileId', setUser(profile);
'aiClickCount',
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'financialProfile',
'selectedScenario'
].forEach(k => localStorage.removeItem(k));
/* store new session data */ // Navigate to your post-signin landing
localStorage.setItem('token', token); // Respect `next` and clear ?session=expired
localStorage.setItem('id', id); const params = new URLSearchParams(window.location.search);
const next = params.get('next');
setIsAuthenticated(true); const url = new URL(window.location.href);
setUser(user); url.searchParams.delete('session');
navigate('/signin-landing'); window.history.replaceState({}, '', url); // remove banner flag
} catch (err) { navigate(next ? decodeURIComponent(next) : '/signin-landing', { replace: true });
setError(err.message); } catch (err) {
} setError(err?.message || 'Sign in failed');
}; }
};
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4"> <div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
{showSessionExpiredMsg && ( {showSessionExpiredMsg && (
<div className="mb-4 p-2 bg-red-100 border border-red-300 text-red-700 rounded"> <div className="mb-4 p-2 bg-red-100 border border-red-300 text-red-700 rounded">
Your session has expired. Please sign in again. Your session has expired. Please sign in again.
</div> </div>
)} )}
<div className="w-full max-w-sm rounded-md bg-white p-6 shadow-md"> <div className="w-full max-w-sm rounded-md bg-white p-6 shadow-md">
<h1 className="mb-6 text-center text-2xl font-semibold">Sign In</h1> <h1 className="mb-6 text-center text-2xl font-semibold">Sign In</h1>
@ -141,7 +141,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
Forgot your password? Forgot your password?
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );

View File

@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from './ChangePasswordForm.js'; import ChangePasswordForm from './ChangePasswordForm.js';
import authFetch from '../utils/authFetch.js';
function UserProfile() { function UserProfile() {
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [zipCode, setZipCode] = useState(''); const [zipCode, setZipCode] = useState('');
const [selectedState, setSelectedState] = useState(''); const [selectedState, setSelectedState] = useState('');
const [areas, setAreas] = useState([]); const [areas, setAreas] = useState([]);
const [selectedArea, setSelectedArea] = useState(''); const [selectedArea, setSelectedArea] = useState('');
@ -14,51 +15,20 @@ function UserProfile() {
const [loadingAreas, setLoadingAreas] = useState(false); const [loadingAreas, setLoadingAreas] = useState(false);
const [isPremiumUser, setIsPremiumUser] = useState(false); const [isPremiumUser, setIsPremiumUser] = useState(false);
const [phoneE164, setPhoneE164] = useState(''); const [phoneE164, setPhoneE164] = useState('');
const [smsOptIn, setSmsOptIn] = useState(false); const [smsOptIn, setSmsOptIn] = useState(false);
const [showChangePw, setShowChangePw] = useState(false); const [showChangePw, setShowChangePw] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
// Helper to do authorized fetch // --- Load profile (cookies via authFetch) and initial areas (if state present)
const authFetch = async (url, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/signin');
return null;
}
const res = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if ([401, 403].includes(res.status)) {
console.warn('Token invalid or expired. Redirecting to Sign In.');
navigate('/signin');
return null;
}
return res;
};
useEffect(() => { useEffect(() => {
const fetchProfileAndAreas = async () => { const fetchProfileAndAreas = async () => {
try { try {
const token = localStorage.getItem('token'); const res = await authFetch('/api/user-profile', { method: 'GET' });
if (!token) return; if (!res || !res.ok) return; // shim will redirect on 401
const res = await authFetch('/api/user-profile', {
method: 'GET',
});
if (!res || !res.ok) return;
const data = await res.json(); const data = await res.json();
// Map exact server fields
setFirstName(data.firstname || ''); setFirstName(data.firstname || '');
setLastName(data.lastname || ''); setLastName(data.lastname || '');
setEmail(data.email || ''); setEmail(data.email || '');
@ -68,21 +38,21 @@ function UserProfile() {
setCareerSituation(data.career_situation || ''); setCareerSituation(data.career_situation || '');
setPhoneE164(data.phone_e164 || ''); setPhoneE164(data.phone_e164 || '');
setSmsOptIn(!!data.sms_opt_in); setSmsOptIn(!!data.sms_opt_in);
setIsPremiumUser(data.is_premium === 1);
if (data.is_premium === 1) {
setIsPremiumUser(true);
}
// If we have a state, load its areas // If we have a state, load its areas
if (data.state) { if (data.state) {
setLoadingAreas(true); setLoadingAreas(true);
try { try {
const areaRes = await authFetch(`/api/areas?state=${data.state}`); const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(data.state)}`);
if (!areaRes || !areaRes.ok) { if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
throw new Error('Failed to fetch areas');
}
const areaData = await areaRes.json(); const areaData = await areaRes.json();
setAreas(areaData.areas); const list = Array.isArray(areaData.areas) ? areaData.areas : [];
setAreas(list);
// If current selectedArea isn't in the new list, clear it
if (list.length && !list.includes(data.area)) {
setSelectedArea('');
}
} catch (areaErr) { } catch (areaErr) {
console.error('Error fetching areas:', areaErr); console.error('Error fetching areas:', areaErr);
setAreas([]); setAreas([]);
@ -96,35 +66,28 @@ function UserProfile() {
}; };
fetchProfileAndAreas(); fetchProfileAndAreas();
// eslint-disable-next-line react-hooks/exhaustive-deps }, []);
}, []); // only runs once
// Whenever user changes "selectedState", re-fetch areas // --- When user changes state, re-fetch areas (cookies only)
useEffect(() => { useEffect(() => {
const fetchAreasByState = async () => { const fetchAreasByState = async () => {
if (!selectedState) { if (!selectedState) {
setAreas([]); setAreas([]);
setSelectedArea('');
return; return;
} }
setLoadingAreas(true); setLoadingAreas(true);
try { try {
const token = localStorage.getItem('token'); const res = await authFetch(`/api/areas?state=${encodeURIComponent(selectedState)}`);
if (!token) return; if (!res || !res.ok) throw new Error('Failed to fetch areas');
const data = await res.json();
const areaRes = await fetch(`/api/areas?state=${selectedState}`, { const list = Array.isArray(data.areas) ? data.areas : [];
headers: { setAreas(list);
Authorization: `Bearer ${token}`, if (list.length && !list.includes(selectedArea)) {
}, setSelectedArea('');
});
if (!areaRes.ok) {
throw new Error('Failed to fetch areas');
} }
} catch (err) {
const areaData = await areaRes.json(); console.error('Error fetching areas:', err);
setAreas(areaData.areas || []);
} catch (error) {
console.error('Error fetching areas:', error);
setAreas([]); setAreas([]);
} finally { } finally {
setLoadingAreas(false); setLoadingAreas(false);
@ -132,12 +95,14 @@ function UserProfile() {
}; };
fetchAreasByState(); fetchAreasByState();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedState]); }, [selectedState]);
const handleFormSubmit = async (e) => { const handleFormSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
const profileData = { const profileData = {
// keep the POST field names youre already using server-side
firstName, firstName,
lastName, lastName,
email, email,
@ -152,9 +117,9 @@ function UserProfile() {
try { try {
const response = await authFetch('/api/user-profile', { const response = await authFetch('/api/user-profile', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profileData), body: JSON.stringify(profileData),
}); });
if (!response || !response.ok) { if (!response || !response.ok) {
throw new Error('Failed to save profile'); throw new Error('Failed to save profile');
} }
@ -185,24 +150,11 @@ function UserProfile() {
{ name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' },
]; ];
// The updated career situations (same as in SignUp.js)
const careerSituations = [ const careerSituations = [
{ { id: 'planning', title: 'Planning Your Career' },
id: 'planning', { id: 'preparing', title: 'Preparing for Your (Next) Career' },
title: 'Planning Your Career', { id: 'enhancing', title: 'Enhancing Your Career' },
}, { id: 'retirement', title: 'Retirement Planning' },
{
id: 'preparing',
title: 'Preparing for Your (Next) Career',
},
{
id: 'enhancing',
title: 'Enhancing Your Career',
},
{
id: 'retirement',
title: 'Retirement Planning',
},
]; ];
return ( return (
@ -213,9 +165,7 @@ function UserProfile() {
<form onSubmit={handleFormSubmit} className="space-y-4"> <form onSubmit={handleFormSubmit} className="space-y-4">
{/* First Name */} {/* First Name */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">First Name:</label>
First Name:
</label>
<input <input
type="text" type="text"
value={firstName} value={firstName}
@ -227,9 +177,7 @@ function UserProfile() {
{/* Last Name */} {/* Last Name */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">Last Name:</label>
Last Name:
</label>
<input <input
type="text" type="text"
value={lastName} value={lastName}
@ -241,9 +189,7 @@ function UserProfile() {
{/* Email */} {/* Email */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">Email:</label>
Email:
</label>
<input <input
type="email" type="email"
value={email} value={email}
@ -255,9 +201,7 @@ function UserProfile() {
{/* ZIP Code */} {/* ZIP Code */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">ZIP Code:</label>
ZIP Code:
</label>
<input <input
type="text" type="text"
value={zipCode} value={zipCode}
@ -267,11 +211,9 @@ function UserProfile() {
/> />
</div> </div>
{/* State Dropdown */} {/* State */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">State:</label>
State:
</label>
<select <select
value={selectedState} value={selectedState}
onChange={(e) => setSelectedState(e.target.value)} onChange={(e) => setSelectedState(e.target.value)}
@ -288,16 +230,12 @@ function UserProfile() {
</div> </div>
{/* Loading indicator for areas */} {/* Loading indicator for areas */}
{loadingAreas && ( {loadingAreas && <p className="text-sm text-gray-500">Loading areas...</p>}
<p className="text-sm text-gray-500">Loading areas...</p>
)}
{/* Areas Dropdown */} {/* Areas */}
{!loadingAreas && areas.length > 0 && ( {!loadingAreas && areas.length > 0 && (
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">Area:</label>
Area:
</label>
<select <select
value={selectedArea} value={selectedArea}
onChange={(e) => setSelectedArea(e.target.value)} onChange={(e) => setSelectedArea(e.target.value)}
@ -305,8 +243,8 @@ function UserProfile() {
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none" className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none"
> >
<option value="">Select an Area</option> <option value="">Select an Area</option>
{areas.map((area, index) => ( {areas.map((area, idx) => (
<option key={index} value={area}> <option key={idx} value={area}>
{area} {area}
</option> </option>
))} ))}
@ -314,6 +252,7 @@ function UserProfile() {
</div> </div>
)} )}
{/* Phone + SMS opt-in */}
<div className="mt-4"> <div className="mt-4">
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label> <label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
<input <input
@ -352,22 +291,23 @@ function UserProfile() {
</select> </select>
</div> </div>
<div className="mt-8"> {/* Change password */}
<button <div className="mt-8">
type="button" <button
onClick={() => setShowChangePw(s => !s)} type="button"
className="rounded border px-3 py-2 text-sm hover:bg-gray-100" onClick={() => setShowChangePw(s => !s)}
> className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
{showChangePw ? 'Cancel password change' : 'Change password'} >
</button> {showChangePw ? 'Cancel password change' : 'Change password'}
</button>
{showChangePw && (
<div className="mt-4">
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
</div>
)}
</div>
{showChangePw && (
<div className="mt-4">
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
</div>
)}
</div>
{/* Form Buttons */} {/* Form Buttons */}
<div className="mt-6 flex items-center justify-end space-x-3"> <div className="mt-6 flex items-center justify-end space-x-3">
<button <button
@ -378,7 +318,7 @@ function UserProfile() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => navigate('/getting-started')} onClick={() => navigate(-1)}
className="rounded bg-gray-300 px-5 py-2 text-gray-700 hover:bg-gray-400" className="rounded bg-gray-300 px-5 py-2 text-gray-700 hover:bg-gray-400"
> >
Go Back Go Back

View File

@ -1,25 +1,51 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App.js'; import App from './App.js';
import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter
import reportWebVitals from './reportWebVitals.js'; import reportWebVitals from './reportWebVitals.js';
import { PageFlagsProvider } from './utils/PageFlagsContext.js'; import { PageFlagsProvider } from './utils/PageFlagsContext.js';
import { installStorageGuard } from './utils/storageGuard.js'; import { installStorageGuard, scrubLegacyPII } from './utils/storageGuard.js';
import { installFetchAuthShim } from './auth/installFetchAuthShim.js';
import { installAxiosAuthShim } from './auth/installAxiosAuthShim.js';
installStorageGuard(); // Initialize storage guard installStorageGuard({
mode: 'divert',
debug: false,
denyAll: true, // ⟵ this is the big lever
// keep only truly harmless stuff on disk (optional)
allowKeys: ['ui_theme', 'cookieConsent', 'careerSuggestionsCache'],
allowPrefixes: ['coachChat:']
});
scrubLegacyPII();
const root = ReactDOM.createRoot(document.getElementById('root')); installFetchAuthShim({
root.render( debug: false, // flip to true to trace requests
<BrowserRouter> publicPaths: [
<PageFlagsProvider> '/api/signin',
<App /> '/api/signup',
</PageFlagsProvider> '/api/register',
</BrowserRouter> '/api/check-username',
); '/api/areas',
'/api/auth/password-reset/request',
'/api/auth/password-reset/confirm',
'/api/public',
'/livez',
'/readyz',
'/healthz',
],
});
// If you want to start measuring performance in your app, pass a function installAxiosAuthShim({ debug: false }); // axios → cookies + 401 handler
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<PageFlagsProvider>
<App />
</PageFlagsProvider>
</BrowserRouter>
);
reportWebVitals();

View File

@ -1,35 +1,19 @@
let onSessionExpiredCallback = null; // keep this file name/location so existing imports keep working
import { apiFetch } from '../auth/apiFetch.js';
export const setSessionExpiredCallback = (callback) => { let onExpired = null;
onSessionExpiredCallback = callback;
};
const authFetch = async (url, options = {}) => { /** Back-compat: allow callers to register a session-expired handler. */
const token = localStorage.getItem('token'); export function setSessionExpiredCallback(fn) {
onExpired = (typeof fn === 'function') ? fn : null;
// expose for the fetch shim so it can call into here
try { window.__aptivaOnSessionExpired = onExpired; } catch {}
}
if (!token) { export default async function authFetch(url, options = {}) {
onSessionExpiredCallback?.(); const res = await apiFetch(url, options);
return null; if ([401, 403, 419, 440].includes(res.status) && typeof onExpired === 'function') {
try { onExpired({ url, status: res.status, response: res }); } catch {}
} }
const method = options.method?.toUpperCase() || 'GET';
const shouldIncludeContentType = ['POST', 'PUT', 'PATCH'].includes(method);
const res = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...(shouldIncludeContentType && { 'Content-Type': 'application/json' }),
...options.headers,
},
});
if ([401, 403].includes(res.status)) {
onSessionExpiredCallback?.();
return null;
}
return res; return res;
}; }
export default authFetch;

View File

@ -0,0 +1,15 @@
import { safeStorage } from './storageGuard.js';
const KEY = 'premiumOnboardingState';
export const onboardingState = {
get() {
try { return JSON.parse(safeStorage.get(KEY) || '{}'); }
catch { return {}; }
},
set(patch) {
const cur = onboardingState.get();
const next = { ...cur, ...patch };
safeStorage.set(KEY, JSON.stringify(next));
},
clear() { safeStorage.remove(KEY); }
};

View File

@ -1,23 +1,173 @@
// storageGuard.js // src/utils/storageGuard.js
const RESTRICTED_SUBSTRINGS = [
// Default substring guards (case-insensitive)
const DEFAULT_DENY_SUBSTRINGS = [
'token','access','refresh','userid','user_id','user','profile','email','phone', 'token','access','refresh','userid','user_id','user','profile','email','phone',
'answers','interest','riasec','salary','ssn','auth' 'answers','interest','riasec','salary','ssn','auth'
]; ];
function shouldBlock(key) {
const k = String(key || '').toLowerCase(); // A small set of explicit keys we know should never hit disk.
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s)); const DEFAULT_DENY_KEYS = [
} 'financialProfile',
function wrap(storage) { 'premiumOnboardingState',
if (!storage) return; 'selectedScenario',
const _set = storage.setItem.bind(storage); 'aiRecommendations',
storage.setItem = (k, v) => { 'careerSuggestionsCache',
if (shouldBlock(k)) { 'lastSelectedCareerProfileId',
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`); 'selectedCareer',
} // legacy
return _set(k, v); 'token','id'
];
/**
* Decide if a key should be diverted/blocked
*/
function buildShouldDeny({ denyKeys, denySubstrings }) {
const denySet = new Set((denyKeys || []).map(k => String(k || '')));
const subs = (denySubstrings || []).map(s => String(s || '').toLowerCase());
return function shouldDeny(key) {
const k = String(key || '');
if (denySet.has(k)) return true;
const lower = k.toLowerCase();
return subs.some(s => s && lower.includes(s));
}; };
} }
export function installStorageGuard() {
try { wrap(window.localStorage); } catch {} /**
try { wrap(window.sessionStorage); } catch {} * Wrap a Storage object (localStorage/sessionStorage)
} * mode: 'divert' (default, production-safe) or 'block' (throw; great in dev)
* debug: console.debug minimal notices
*/
function wrapStorage(storage, {
shouldDeny,
mode = 'divert',
debug = false,
vault
}) {
if (!storage) return;
const _setItem = storage.setItem.bind(storage);
const _getItem = storage.getItem.bind(storage);
const _removeItem = storage.removeItem.bind(storage);
const _clear = storage.clear.bind(storage);
const _key = storage.key.bind(storage);
storage.setItem = (k, v) => {
if (shouldDeny(k)) {
if (mode === 'block') {
throw new Error(`[storageGuard] Blocked setItem("${k}"). Sensitive data is not allowed in Web Storage.`);
}
// divert to memory
vault.set(k, String(v ?? ''));
if (debug) console.debug(`[storageGuard] diverted setItem("${k}") to memory`);
// fire a custom event for app tooling/tests if desired
try {
window.dispatchEvent(new CustomEvent('aptiva:storage-divert', { detail: { key: k } }));
} catch {}
return;
}
return _setItem(k, v);
};
storage.getItem = (k) => {
if (shouldDeny(k)) {
return vault.has(k) ? vault.get(k) : null;
}
return _getItem(k);
};
storage.removeItem = (k) => {
if (shouldDeny(k)) {
vault.delete(k);
if (debug) console.debug(`[storageGuard] diverted removeItem("${k}")`);
return;
}
return _removeItem(k);
};
// Clear disk entries but keep diverted keys strictly in memory semantics
storage.clear = () => {
// clear the in-memory vault too
vault.clear();
return _clear();
};
// NOTE: we keep .key()/.length behavior as native (disk only).
// Calls to .key() won't enumerate diverted keys. That's ok for our app usage.
storage.key = (i) => _key(i);
}
/**
* Public install function
*
* @param {Object} options
* @param {('divert'|'block')} options.mode - default 'divert' (safe). Use 'block' in dev to catch offenders.
* @param {boolean} options.debug - console.debug notices
* @param {string[]} options.denyKeys - explicit keys to deny
* @param {string[]} options.denySubstrings - substrings to deny
*/
export function installStorageGuard(options = {}) {
const {
mode = (process.env.NODE_ENV === 'production' ? 'divert' : 'divert'),
debug = false,
denyKeys = DEFAULT_DENY_KEYS,
denySubstrings = DEFAULT_DENY_SUBSTRINGS,
// NEW: strong default deny with small allowlist
denyAll = false,
allowKeys = [],
allowPrefixes = [],
shouldDeny: customShouldDeny
} = options;
const allowSet = new Set(allowKeys.map(String));
const allowPref = allowPrefixes.map(String);
const baseShouldDeny = customShouldDeny || buildShouldDeny({ denyKeys, denySubstrings });
const shouldDeny = denyAll
? (key) => {
const k = String(key || '');
if (allowSet.has(k)) return false;
if (allowPref.some(p => p && k.startsWith(p))) return false;
return true; // deny everything else
}
: baseShouldDeny;
// a single vault per tab/session for both storages
const vault = new Map();
try { wrapStorage(window.localStorage, { shouldDeny, mode, debug, vault }); } catch {}
try { wrapStorage(window.sessionStorage, { shouldDeny, mode, debug, vault }); } catch {}
// expose for rare debugging/migration
try { window.__aptivaMemVault = vault; } catch {}
if (debug) console.debug('[storageGuard] installed', { mode, denyKeys, denySubstringsCount: denySubstrings.length });
}
/**
* One-time scrub of legacy PII left on disk
* Call this once on boot BEFORE React mounts (after install is fine too).
*/
export function scrubLegacyPII(overrideKeys) {
const keys = overrideKeys && overrideKeys.length ? overrideKeys : DEFAULT_DENY_KEYS;
for (const k of keys) {
try { window.localStorage.removeItem(k); } catch {}
try { window.sessionStorage.removeItem(k); } catch {}
}
}
/**
* Optional helper API for future refactors:
* Use this when you want an explicit PII-safe stash w/o touching Web Storage.
*/
export const safeStorage = (() => {
const vault = (typeof window !== 'undefined' && window.__aptivaMemVault) ? window.__aptivaMemVault : new Map();
return {
set(key, value) { vault.set(String(key), String(value ?? '')); },
get(key) { return vault.get(String(key)) ?? null; },
remove(key) { vault.delete(String(key)); },
has(key) { return vault.has(String(key)); },
clear() { vault.clear(); }
};
})();