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
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=ed1fdbb-202508121553
IMG_TAG=fb2e052-202508131933
ENV_NAME=dev
PROJECT=aptivaai-dev

View File

@ -110,6 +110,14 @@ 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 ── \
@ -127,9 +135,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 \
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; \
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; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"'

View File

@ -2,7 +2,6 @@
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,6 +15,7 @@ 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 (
@ -83,6 +84,7 @@ 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) ──────── */
@ -93,8 +95,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,
@ -227,6 +229,7 @@ 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();
});
@ -284,6 +287,30 @@ 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
@ -578,12 +605,14 @@ 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 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({
message: 'User registered successfully',
profileId: newProfileId,
token,
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
@ -665,13 +694,13 @@ 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);
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.status(200).json({
return res.status(200).json({
message: 'Login successful',
token,
token, // optional
id: row.userProfileId,
user: profile
});
@ -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

View File

@ -21,8 +21,10 @@ 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);
@ -71,6 +73,8 @@ try {
// Create Express app
const app = express();
app.set('trust proxy', 1);
app.use(cookieParser());
const PORT = process.env.SERVER2_PORT || 5001;
function fprPathFromEnv() {
@ -1158,74 +1162,73 @@ chatFreeEndpoint(app, {
* Returns 429 Too Many Requests if limits exceeded
* Supports deduplication for 10 minutes
* *************************************************/
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' });
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]));
}
// Prefer token email; fall back to DB; last resort: body.email
let accountEmail = user.email || user.mail || null;
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' });
// 1) email priority: token → DB decrypted → request body
let accountEmail = req.user?.email || req.user?.mail || null;
if (!accountEmail) {
try {
const row = await userProfileDb.get(
'SELECT email FROM user_profile WHERE id = ?',
const [rows] = await pool.query(
'SELECT email FROM user_profile WHERE id = ? LIMIT 1',
[userId]
);
accountEmail = row?.email || null;
const enc = rows?.[0]?.email || null;
if (enc) {
try { accountEmail = decrypt(enc); } catch {}
}
} catch {}
}
if (!accountEmail) {
accountEmail = (req.body && req.body.email) || null;
}
if (!accountEmail) accountEmail = 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 || {};
// 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();
// Basic validation
const allowedCats = new Set(['general','billing','technical','data','ux']);
const subj = subject.toString().slice(0, 120).trim();
const body = message.toString().trim();
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' });
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' });
}
// Dedupe
const key = makeKey(userId, subj || '(no subject)', body);
if (isDuplicateAndRemember(key)) {
// 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';
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' });
}
const humanSubject =
`[Support • ${category}] ${subj || '(no subject)'} — user ${userId}`;
const textBody =
`User: ${userId}
// 5) send
const humanSubject = `[Support • ${category}] ${subject || '(no subject)'} — user ${userId}`;
const textBody = `User: ${userId}
Email: ${accountEmail}
Category: ${category}
${body}`;
${message}`;
await sgMail.send({
to: TO,
@ -1233,18 +1236,14 @@ ${body}`;
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')]
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${_escape(textBody)}</pre>`,
categories: ['support', category]
});
return res.status(200).json({ ok: true });
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' });
}
}
);
/**************************************************
* Start the Express server

View File

@ -8,7 +8,6 @@ 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';
@ -16,6 +15,9 @@ 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';
@ -27,6 +29,8 @@ 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');
@ -38,6 +42,8 @@ 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
@ -57,7 +63,11 @@ 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) ──
@ -158,6 +168,17 @@ 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(
@ -230,6 +251,17 @@ 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 });
@ -260,6 +292,11 @@ 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);
}
);
@ -312,23 +349,12 @@ app.use((req, res, next) => {
});
// 3) Authentication middleware
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
const authenticatePremiumUser = (req, res, next) =>
requireAuth(req, res, () => {
// preserve existing field name so routes dont change
req.id = req.userId;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
});
/** ------------------------------------------------------------------
* Returns the users stripe_customer_id (or null) given req.id.
@ -742,180 +768,7 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs
}
});
/***************************************************
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) => {
app.post('/api/premium/ai/chat', rlPremiumAI, authenticatePremiumUser, async (req, res) => {
try {
const {
userProfile = {},
@ -1459,7 +1312,7 @@ ${avoidBlock}
`.trim();
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(
@ -1476,8 +1329,14 @@ if (NEEDS_OPS_CARD) {
messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD });
}
if (NEEDS_CTX_CARD || SEND_CTX_CARD)
messagesToSend.push({ role:"system", content: summaryText });
if (SEND_CTX_CARD) {
const systemPromptDetailedContext = `
[DETAILED USER PROFILE & CONTEXT]
${summaryText}
`.trim();
messagesToSend.push({ role: "system", content: systemPromptDetailedContext });
}
// ② Per-turn contextual helpers (small!)
messagesToSend.push(
@ -1692,7 +1551,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',
'/api/premium/retirement/aichat', rlPremiumAI,
authenticatePremiumUser,
async (req, res) => {
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 {
const {
socCode,
careerName,
jobDescription,
jobDescription = '',
tasks = []
} = 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.' });
}
// 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: ${jobDescription}
Tasks: ${tasks.join('; ')}
Description: ${jd}
Tasks: ${safeTasks.join('; ')}
Provide AI automation risk analysis for the next 10 years.
Return JSON exactly in this format:
@ -3617,7 +3481,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 fs.readFile(filePath, 'utf8');
const raw = await readFile(filePath, 'utf8');
onetKsaData = JSON.parse(raw);
// Build a set of unique KSA names for fuzzy search
@ -3763,7 +3627,8 @@ function processChatGPTKsa(chatGptKSA, ksaType) {
// 6) The new route
app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => {
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 {
// 1) Check local data
@ -3857,7 +3722,16 @@ return res.json({
------------------------------------------------------------------ */
// 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) {
// Full ChatGPT prompt for resume optimization:
@ -3905,6 +3779,8 @@ app.post(
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) {
@ -3970,7 +3846,6 @@ 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.' });
}
@ -3996,8 +3871,7 @@ app.post(
const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1);
// remove uploaded file
await fs.unlink(filePath);
res.json({
optimizedResume,
@ -4007,6 +3881,9 @@ app.post(
} 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();
}
}
);

View File

@ -2,41 +2,54 @@
import jwt from 'jsonwebtoken';
import pool from '../config/mysqlPool.js';
const { JWT_SECRET, TOKEN_MAX_AGE_MS } = process.env;
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled
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;
}
export async function requireAuth(req, res, next) {
try {
const authz = req.headers.authorization || '';
const token = authz.startsWith('Bearer ') ? authz.slice(7) : '';
const cookieToken = req.cookies?.[ACCESS_COOKIE_NAME];
const bearerToken = extractBearer(req.headers.authorization);
const token = cookieToken || bearerToken; // cookie always wins
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.id;
const userId = payload.sub || payload.id || payload.userId;
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 changedAt = rows?.[0]?.password_changed_at || 0;
if (changedAt && iatMs < changedAt) {
const changedAtMs = rows?.[0]?.password_changed_at ? new Date(rows[0].password_changed_at).getTime() : 0;
if (changedAtMs && iatMs < changedAtMs) {
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();
next();
} catch (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 \
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,6 +32,10 @@ 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}
@ -79,6 +83,10 @@ 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}
@ -128,6 +136,10 @@ 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,3 +179,7 @@ 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,6 +25,7 @@
"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",
@ -7328,6 +7329,28 @@
"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,6 +20,7 @@
"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,57 +172,63 @@ const showPremiumCTA = !premiumPaths.some(p =>
setUserEmail(user?.email || '');
}, [user]);
// ==============================
// 1) Single Rehydrate UseEffect
// ==============================
/* Multi-tab signout listener */
useEffect(() => {
// 🚫 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;
}
// 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');
const onStorage = (e) => {
if (e.key === 'token' && !e.newValue) {
// another tab cleared the token
clearToken();
setIsAuthenticated(false);
setUser(null);
navigate('/signin?session=expired');
})
.finally(() => {
// Either success or fail, we're done loading
}
};
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);
});
}, [navigate, location.pathname]);
}
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]);
// ==========================
// 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 {}
});
// Clear in-memory token
try { await fetch('/api/logout', { method: 'POST', credentials: 'include' }); } catch {}
try { clearToken(); } catch {}
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
// Reset React state/context
setFinancialProfile(null);
setScenario(null);
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
// Reset auth
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
// Navigate to Sign In
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
import { getToken } from './authMemory.js';
// 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' });
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 });
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})`;
}

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

View File

@ -4,6 +4,8 @@ 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) {
@ -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?'
);
if (proceed) {
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));
const cur = onboardingState.get();
const next = {
...cur,
collegeData: { ...(cur.collegeData || {}), selectedSchool: school }
};
onboardingState.set(next);
navigate('/career-roadmap', { state: { selectedSchool: school } });
}
};
@ -246,14 +250,7 @@ useEffect(() => {
useEffect(() => {
async function loadUserProfile() {
try {
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}` },
});
const res = await authFetch('/api/user-profile');
if (!res.ok) throw new Error('Failed to fetch user profile');
const data = await res.json();
setUserZip(data.zipcode || '');
@ -588,19 +585,8 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
setKsaError(null);
try {
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}`
}
}
const resp = await authFetch(
`/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`
);
if (!resp.ok) {

View File

@ -1,7 +1,8 @@
import React, { useRef, useState, useEffect, useContext } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
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 }) {
const navigate = useNavigate();
@ -13,7 +14,6 @@ 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);
@ -24,15 +24,21 @@ function SignIn({ setIsAuthenticated, setUser }) {
event.preventDefault();
setError('');
// 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');
// 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 {}
const username = usernameRef.current.value;
const password = passwordRef.current.value;
@ -47,54 +53,48 @@ function SignIn({ setIsAuthenticated, setUser }) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 ---------------- */
const { token, id, user } = data;
// ---------------- success path ----------------
// fetch current user profile immediately
const profileRes = await fetch('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` }
});
const profile = await profileRes.json();
const profile = await apiGetJSON('/api/user-profile');
const { user } = data;
setFinancialProfile(profile);
setScenario(null); // or fetch latest scenario separately
/* purge any leftovers from prior session */
['careerSuggestionsCache',
'lastSelectedCareerProfileId',
'aiClickCount',
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'financialProfile',
'selectedScenario'
].forEach(k => localStorage.removeItem(k));
/* store new session data */
localStorage.setItem('token', token);
localStorage.setItem('id', id);
setScenario(null);
// Mark auth in your app state
setIsAuthenticated(true);
setUser(user);
navigate('/signin-landing');
setUser(profile);
// 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);
setError(err?.message || 'Sign in failed');
}
};
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>

View File

@ -1,6 +1,7 @@
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('');
@ -19,46 +20,15 @@ function UserProfile() {
const navigate = useNavigate();
// 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;
};
// --- Load profile (cookies via authFetch) and initial areas (if state present)
useEffect(() => {
const fetchProfileAndAreas = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const res = await authFetch('/api/user-profile', {
method: 'GET',
});
if (!res || !res.ok) return;
const res = await authFetch('/api/user-profile', { method: 'GET' });
if (!res || !res.ok) return; // shim will redirect on 401
const data = await res.json();
// Map exact server fields
setFirstName(data.firstname || '');
setLastName(data.lastname || '');
setEmail(data.email || '');
@ -68,21 +38,21 @@ function UserProfile() {
setCareerSituation(data.career_situation || '');
setPhoneE164(data.phone_e164 || '');
setSmsOptIn(!!data.sms_opt_in);
if (data.is_premium === 1) {
setIsPremiumUser(true);
}
setIsPremiumUser(data.is_premium === 1);
// If we have a state, load its areas
if (data.state) {
setLoadingAreas(true);
try {
const areaRes = await authFetch(`/api/areas?state=${data.state}`);
if (!areaRes || !areaRes.ok) {
throw new Error('Failed to fetch areas');
}
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();
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) {
console.error('Error fetching areas:', areaErr);
setAreas([]);
@ -96,35 +66,28 @@ function UserProfile() {
};
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(() => {
const fetchAreasByState = async () => {
if (!selectedState) {
setAreas([]);
setSelectedArea('');
return;
}
setLoadingAreas(true);
try {
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');
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 areaData = await areaRes.json();
setAreas(areaData.areas || []);
} catch (error) {
console.error('Error fetching areas:', error);
} catch (err) {
console.error('Error fetching areas:', err);
setAreas([]);
} finally {
setLoadingAreas(false);
@ -132,12 +95,14 @@ 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,
@ -152,9 +117,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');
}
@ -185,24 +150,11 @@ 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 (
@ -213,9 +165,7 @@ 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}
@ -227,9 +177,7 @@ 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}
@ -241,9 +189,7 @@ 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}
@ -255,9 +201,7 @@ 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}
@ -267,11 +211,9 @@ function UserProfile() {
/>
</div>
{/* State Dropdown */}
{/* State */}
<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)}
@ -288,16 +230,12 @@ 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 Dropdown */}
{/* Areas */}
{!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)}
@ -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"
>
<option value="">Select an Area</option>
{areas.map((area, index) => (
<option key={index} value={area}>
{areas.map((area, idx) => (
<option key={idx} value={area}>
{area}
</option>
))}
@ -314,6 +252,7 @@ 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
@ -352,6 +291,7 @@ function UserProfile() {
</select>
</div>
{/* Change password */}
<div className="mt-8">
<button
type="button"
@ -378,7 +318,7 @@ function UserProfile() {
</button>
<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"
>
Go Back

View File

@ -5,9 +5,39 @@ 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';
import { installStorageGuard, scrubLegacyPII } from './utils/storageGuard.js';
import { installFetchAuthShim } from './auth/installFetchAuthShim.js';
import { installAxiosAuthShim } from './auth/installAxiosAuthShim.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();
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',
],
});
installAxiosAuthShim({ debug: false }); // axios → cookies + 401 handler
installStorageGuard(); // Initialize storage guard
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
@ -18,8 +48,4 @@ root.render(
</BrowserRouter>
);
// 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();

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) => {
onSessionExpiredCallback = callback;
};
let onExpired = null;
const authFetch = async (url, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
onSessionExpiredCallback?.();
return null;
/** 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 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;
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 {}
}
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
const RESTRICTED_SUBSTRINGS = [
// src/utils/storageGuard.js
// Default substring guards (case-insensitive)
const DEFAULT_DENY_SUBSTRINGS = [
'token','access','refresh','userid','user_id','user','profile','email','phone',
'answers','interest','riasec','salary','ssn','auth'
];
function shouldBlock(key) {
const k = String(key || '').toLowerCase();
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s));
}
function wrap(storage) {
if (!storage) return;
const _set = storage.setItem.bind(storage);
storage.setItem = (k, v) => {
if (shouldBlock(k)) {
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`);
}
return _set(k, v);
// 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));
};
}
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(); }
};
})();