Compare commits

..

No commits in common. "761f511601f3e9ae14ef6e06b0430afa011196bc" and "fb2e0522d37f38d02e97af64725a3a96043178fd" have entirely different histories.

26 changed files with 673 additions and 1034 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
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=fb2e052-202508131933
IMG_TAG=ed1fdbb-202508121553
ENV_NAME=dev
PROJECT=aptivaai-dev

View File

@ -110,14 +110,6 @@ steps:
export DEK_PATH; \
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
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; \
\
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \
@ -135,9 +127,9 @@ steps:
fi; \
\
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,ACCESS_COOKIE_NAME,COOKIE_SECURE,COOKIE_SAMESITE,TOKEN_MAX_AGE_MS \
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 \
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,ACCESS_COOKIE_NAME,COOKIE_SECURE,COOKIE_SAMESITE,TOKEN_MAX_AGE_MS \
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 \
docker compose up -d --force-recreate --remove-orphans; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"'

View File

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

View File

@ -15,7 +15,6 @@ import sgMail from '@sendgrid/mail';
import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js';
import cookieParser from 'cookie-parser';
const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary (
@ -84,7 +83,6 @@ try {
Express app & middleware
---------------------------------------------------------------- */
const app = express();
app.set('trust proxy', 1);
const PORT = process.env.SERVER1_PORT || 5000;
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
@ -95,8 +93,8 @@ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
app.disable('x-powered-by');
app.use(bodyParser.json());
app.use(express.json());
app.use(cookieParser());
app.use(
helmet({
contentSecurityPolicy: false,
@ -229,7 +227,6 @@ app.options('*', (req, res) => {
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.status(200).end();
});
@ -287,30 +284,6 @@ const pwDailyLimiter = rateLimit({
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) {
const sql = `
UPDATE user_auth ua
@ -605,19 +578,17 @@ app.post('/api/register', async (req, res) => {
const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`;
await pool.query(authQuery, [newProfileId, username, hashedPassword]);
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);
const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' });
return res.status(201).json({
message: 'User registered successfully',
profileId: newProfileId,
token, // optional; frontend doesnt need it anymore
user: {
username, firstname, lastname, email: emailNorm, zipcode, state, area,
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
}
});
return res.status(201).json({
message: 'User registered successfully',
profileId: newProfileId,
token,
user: {
username, firstname, lastname, email: emailNorm, zipcode, state, area,
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
}
});
} catch (err) {
// If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates:
if (err.code === 'ER_DUP_ENTRY') {
@ -694,16 +665,16 @@ app.post('/api/signin', async (req, res) => {
if (profile?.email) {
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({
message: 'Login successful',
token, // optional
id: row.userProfileId,
user: profile
});
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.status(200).json({
message: 'Login successful',
token,
id: row.userProfileId,
user: profile
});
} catch (err) {
console.error('Error querying user_auth:', err.message);
return res
@ -969,24 +940,6 @@ 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

View File

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

View File

@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename);
import express from 'express';
import helmet from 'helmet';
import fs, { readFile } from 'fs/promises'; // <-- add this
import multer from 'multer';
import fetch from 'node-fetch';
import mammoth from 'mammoth';
@ -15,9 +16,6 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist';
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 Fuse from 'fuse.js';
@ -29,8 +27,6 @@ import { hashForLookup } from './shared/crypto/encryption.js';
import './jobs/reminderCron.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 env = (process.env.NODE_ENV || 'production');
@ -42,8 +38,6 @@ if (!process.env.FROM_SECRETS_MANAGER) {
const PORT = process.env.SERVER3_PORT || 5002;
const API_BASE = `http://localhost:${PORT}/api`;
/* ─── helper: canonical public origin ─────────────────────────── */
const PUBLIC_BASE = (
process.env.APTIVA_AI_BASE
@ -63,11 +57,7 @@ function isSafeRedirect(url) {
}
const app = express();
app.set('trust proxy', 1);
app.use(cookieParser());
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) ──
@ -168,17 +158,6 @@ function 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
async function getRiskAnalysisFromDB(socCode) {
const [rows] = await pool.query(
@ -251,17 +230,6 @@ app.post(
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 h = hashForLookup(customerId);
console.log('[Stripe] upFlags', { customerId, premium, pro });
@ -292,12 +260,7 @@ app.post(
default:
// Ignore everything else
}
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);
res.sendStatus(200);
}
);
@ -349,12 +312,23 @@ app.use((req, res, next) => {
});
// 3) Authentication middleware
const authenticatePremiumUser = (req, res, next) =>
requireAuth(req, res, () => {
// preserve existing field name so routes dont change
req.id = req.userId;
const authenticatePremiumUser = (req, res, next) => {
const token = (req.headers.authorization || '')
.replace(/^Bearer\s+/i, '') // drop “Bearer ”
.trim(); // strip CR/LF, spaces
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();
});
} catch (error) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
/** ------------------------------------------------------------------
* Returns the users stripe_customer_id (or null) given req.id.
@ -768,7 +742,180 @@ 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 {
const {
userProfile = {},
@ -1312,7 +1459,7 @@ ${avoidBlock}
`.trim();
const NEEDS_OPS_CARD = !chatHistory.some(
m => m.role === "system" && m.content.includes("APTIVA OPS YOU CAN USE ANY TIME")
m => m.role === "system" && m.content.includes("APTIVA OPS CHEAT-SHEET")
);
const NEEDS_CTX_CARD = !chatHistory.some(
@ -1329,14 +1476,8 @@ if (NEEDS_OPS_CARD) {
messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD });
}
if (SEND_CTX_CARD) {
const systemPromptDetailedContext = `
[DETAILED USER PROFILE & CONTEXT]
${summaryText}
`.trim();
messagesToSend.push({ role: "system", content: systemPromptDetailedContext });
}
if (NEEDS_CTX_CARD || SEND_CTX_CARD)
messagesToSend.push({ role:"system", content: summaryText });
// ② Per-turn contextual helpers (small!)
messagesToSend.push(
@ -1551,7 +1692,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', rlPremiumAI,
'/api/premium/retirement/aichat',
authenticatePremiumUser,
async (req, res) => {
try {
@ -1993,12 +2134,12 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
}
});
app.post('/api/public/ai-risk-analysis', publicAIRiskLimiter, async (req, res) => {
app.post('/api/public/ai-risk-analysis', async (req, res) => {
try {
const {
socCode,
careerName,
jobDescription = '',
jobDescription,
tasks = []
} = req.body;
@ -2006,15 +2147,10 @@ app.post('/api/public/ai-risk-analysis', publicAIRiskLimiter, async (req, res) =
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 = `
The user has a career named: ${careerName}
Description: ${jd}
Tasks: ${safeTasks.join('; ')}
Description: ${jobDescription}
Tasks: ${tasks.join('; ')}
Provide AI automation risk analysis for the next 10 years.
Return JSON exactly in this format:
@ -3481,7 +3617,7 @@ let allKsaNames = []; // an array of unique KSA names (for fuzzy matching)
(async function loadKsaJson() {
try {
const filePath = path.join(__dirname, '..', 'public', 'ksa_data.json');
const raw = await readFile(filePath, 'utf8');
const raw = await fs.readFile(filePath, 'utf8');
onetKsaData = JSON.parse(raw);
// Build a set of unique KSA names for fuzzy search
@ -3627,8 +3763,7 @@ function processChatGPTKsa(chatGptKSA, ksaType) {
// 6) The new route
app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => {
const { socCode } = req.params;
const { careerTitle: rawTitle = '' } = req.query;
const careerTitle = String(rawTitle).slice(0, 120);
const { careerTitle = '' } = req.query; // or maybe from body
try {
// 1) Check local data
@ -3722,16 +3857,7 @@ return res.json({
------------------------------------------------------------------ */
// Setup file upload via multer
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);
}
});
const upload = multer({ dest: 'uploads/' });
function buildResumePrompt(resumeText, jobTitle, jobDescription) {
// Full ChatGPT prompt for resume optimization:
@ -3776,11 +3902,9 @@ async function extractTextFromPDF(filePath) {
app.post(
'/api/premium/resume/optimize',
upload.single('resumeFile'),
upload.single('resumeFile'),
authenticatePremiumUser,
async (req, res) => {
const tmpPath = req.file?.path;
const safeUnlink = () => { try { if (tmpPath) fsSync.unlinkSync(tmpPath); } catch {} };
try {
const { jobTitle, jobDescription } = req.body;
if (!jobTitle || !jobDescription || !req.file) {
@ -3846,6 +3970,7 @@ app.post(
const result = await mammoth.extractRawText({ path: filePath });
resumeText = result.value;
} else {
await fs.unlink(filePath);
return res.status(400).json({ error: 'Unsupported or corrupted file upload.' });
}
@ -3871,7 +3996,8 @@ app.post(
const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1);
// remove uploaded file
await fs.unlink(filePath);
res.json({
optimizedResume,
@ -3879,11 +4005,8 @@ app.post(
resetDate: resetDate.toISOString().slice(0, 10)
});
} catch (err) {
console.error('Error optimizing resume:', err);
res.status(500).json({ error: 'Failed to optimize resume.' });
} finally {
// always clean up the temp upload
safeUnlink();
console.error('Error optimizing resume:', err);
res.status(500).json({ error: 'Failed to optimize resume.' });
}
}
);

View File

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

View File

@ -26,7 +26,6 @@ SECRETS=(
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \
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
)

View File

@ -32,10 +32,6 @@ services:
environment:
ENV_NAME: ${ENV_NAME}
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}
KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH}
@ -83,10 +79,6 @@ services:
PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME}
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_PASSWORD: ${ONET_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
@ -136,10 +128,6 @@ services:
KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH}
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}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}

View File

@ -179,7 +179,3 @@ UPDATE user_auth
SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000)
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,7 +25,6 @@
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cra-template": "1.2.0",
"docx": "^9.5.0",
@ -7329,28 +7328,6 @@
"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": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

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

View File

@ -41,7 +41,7 @@ import BillingResult from './components/BillingResult.js';
import SupportModal from './components/SupportModal.js';
import ForgotPassword from './components/ForgotPassword.js';
import ResetPassword from './components/ResetPassword.js';
import { clearToken } from './auth/authMemory.js';
import { clearToken } from '../auth/authMemory.js';
@ -172,63 +172,57 @@ const showPremiumCTA = !premiumPaths.some(p =>
setUserEmail(user?.email || '');
}, [user]);
/* Multi-tab signout listener */
// ==============================
// 1) Single Rehydrate UseEffect
// ==============================
useEffect(() => {
const onStorage = (e) => {
if (e.key === 'token' && !e.newValue) {
// another tab cleared the token
clearToken();
setIsAuthenticated(false);
setUser(null);
navigate('/signin?session=expired');
// 🚫 Never hydrate auth while on the reset page
if (location.pathname.startsWith('/reset-password')) {
try {
localStorage.removeItem('token');
localStorage.removeItem('id');
} catch {}
setIsAuthenticated(false);
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
@ -243,35 +237,31 @@ 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 {}
});
// Clear in-memory token
try { await fetch('/api/logout', { method: 'POST', credentials: 'include' }); } catch {}
try { clearToken(); } catch {}
// Reset React state/context
setFinancialProfile(null);
const confirmLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('id');
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
setScenario(null);
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
// Navigate to Sign In
// Reset auth
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
navigate('/signin');
};

View File

@ -1,14 +0,0 @@
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,82 +1,9 @@
// src/auth/apiFetch.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' });
// apiFetch.js
import { getToken } from './authMemory.js';
const DEFAULT_TIMEOUT_MS = 25000; // 25s
export async function apiFetch(url, options = {}) {
const headers = new Headers(options.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})`;
export async function apiFetch(input, init = {}) {
const headers = new Headers(init.headers || {});
const t = getToken();
if (t) headers.set('Authorization', `Bearer ${t}`);
return fetch(input, { ...init, headers });
}

View File

@ -1,31 +0,0 @@
// 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

@ -1,135 +0,0 @@
// 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);
}
};
}

View File

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

View File

@ -4,8 +4,6 @@ import CareerSearch from './CareerSearch.js';
import { ONET_DEFINITIONS } from './definitions.js';
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.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
function combineIMandLV(rows) {
@ -145,12 +143,10 @@ 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?'
);
if (proceed) {
const cur = onboardingState.get();
const next = {
...cur,
collegeData: { ...(cur.collegeData || {}), selectedSchool: school }
};
onboardingState.set(next);
const storedOnboarding = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}');
storedOnboarding.collegeData = storedOnboarding.collegeData || {};
storedOnboarding.collegeData.selectedSchool = school; // or any property name
localStorage.setItem('premiumOnboardingState', JSON.stringify(storedOnboarding));
navigate('/career-roadmap', { state: { selectedSchool: school } });
}
};
@ -250,7 +246,14 @@ useEffect(() => {
useEffect(() => {
async function loadUserProfile() {
try {
const res = await authFetch('/api/user-profile');
const token = localStorage.getItem('token');
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');
const data = await res.json();
setUserZip(data.zipcode || '');
@ -585,8 +588,19 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
setKsaError(null);
try {
const resp = await authFetch(
`/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`
const token = localStorage.getItem('token');
if (!token) {
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) {

View File

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

View File

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

View File

@ -1,51 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App.js';
import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter
import reportWebVitals from './reportWebVitals.js';
import { PageFlagsProvider } from './utils/PageFlagsContext.js';
import { installStorageGuard, scrubLegacyPII } from './utils/storageGuard.js';
import { installFetchAuthShim } from './auth/installFetchAuthShim.js';
import { installAxiosAuthShim } from './auth/installAxiosAuthShim.js';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App.js';
import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter
import reportWebVitals from './reportWebVitals.js';
import { PageFlagsProvider } from './utils/PageFlagsContext.js';
import { installStorageGuard } from './utils/storageGuard.js';
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();
installStorageGuard(); // Initialize storage guard
installFetchAuthShim({
debug: false, // flip to true to trace requests
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',
],
});
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<PageFlagsProvider>
<App />
</PageFlagsProvider>
</BrowserRouter>
);
installAxiosAuthShim({ debug: false }); // axios → cookies + 401 handler
// If you want to start measuring performance in your app, pass a function
// 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,19 +1,35 @@
// keep this file name/location so existing imports keep working
import { apiFetch } from '../auth/apiFetch.js';
let onSessionExpiredCallback = null;
let onExpired = null;
export const setSessionExpiredCallback = (callback) => {
onSessionExpiredCallback = callback;
};
/** Back-compat: allow callers to register a session-expired handler. */
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 {}
}
const authFetch = async (url, options = {}) => {
const token = localStorage.getItem('token');
export default async function authFetch(url, options = {}) {
const res = await apiFetch(url, options);
if ([401, 403, 419, 440].includes(res.status) && typeof onExpired === 'function') {
try { onExpired({ url, status: res.status, response: res }); } catch {}
if (!token) {
onSessionExpiredCallback?.();
return null;
}
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;
}
};
export default authFetch;

View File

@ -1,15 +0,0 @@
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,173 +1,23 @@
// src/utils/storageGuard.js
// Default substring guards (case-insensitive)
const DEFAULT_DENY_SUBSTRINGS = [
// storageGuard.js
const RESTRICTED_SUBSTRINGS = [
'token','access','refresh','userid','user_id','user','profile','email','phone',
'answers','interest','riasec','salary','ssn','auth'
];
// A small set of explicit keys we know should never hit disk.
const DEFAULT_DENY_KEYS = [
'financialProfile',
'premiumOnboardingState',
'selectedScenario',
'aiRecommendations',
'careerSuggestionsCache',
'lastSelectedCareerProfileId',
'selectedCareer',
// legacy
'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));
};
function shouldBlock(key) {
const k = String(key || '').toLowerCase();
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s));
}
/**
* 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
}) {
function wrap(storage) {
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);
const _set = storage.setItem.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;
if (shouldBlock(k)) {
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`);
}
return _setItem(k, v);
return _set(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 });
export function installStorageGuard() {
try { wrap(window.localStorage); } catch {}
try { wrap(window.sessionStorage); } catch {}
}
/**
* 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(); }
};
})();