env file and yml file consistency

This commit is contained in:
Josh 2025-07-16 14:32:50 +00:00
parent a41858427b
commit b5268a0fe8
10 changed files with 213 additions and 154 deletions

View File

@ -1,26 +1,42 @@
# ─── O*NET ───────────────────────────────
ONET_USERNAME=aptivaai
ONET_PASSWORD=2296ahq
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
# ─── Publicfacing React build ───────────
NODE_ENV=development
REACT_APP_ENV=production
APTIVA_API_BASE=https://dev1.aptivaai.com/api
REACT_APP_API_URL=${APTIVA_API_BASE}
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
COLLEGE_SCORECARD_KEY = BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
REACT_APP_OPENAI_API_KEY=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
# ─── Back-end services ───────────────────
OPENAI_API_KEY=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
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
SALARY_DB=/home/jcoakley/aptiva-dev1-app/salary_info.db
# ─── Database (premium server) ───────────
DB_HOST=34.67.180.54
DB_PORT=3306
DB_USER=sqluser
DB_NAME=user_profile_db
DB_PASSWORD=ps<g+2DO-eTb2mb5
APTIVA_API_BASE=https://dev1.aptivaai.com/api
REACT_APP_API_URL=https://dev1.aptivaai.com/api
REACT_APP_ENV=production
REACT_APP_OPENAI_API_KEY=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
OPENAI_API_KEY=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
DB_NAME=user_profile_db
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
# ── Twilio (needed only by server3) ─────────────────────────
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
JWT_SECRET=gW4QsOu4AJA4MooIUC9ld2i71VbBovzV1INsaU6ftxYPrxLIeMq6/OY61j0X2RV7
# ─── Anything new goes here ──────────────
JWT_SECRET=a35F0iFAkkdWvSjnaLzepAl/JIxPRUh4NpcGptJgry2Z3KVLX4ZcYY5KaTf7kJY0
# ------------ CORS ------------
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://34.16.120.118:3000,https://dev1.aptivaai.com
SERVER1_PORT=5000
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=20250716

42
.env.staging Normal file
View File

@ -0,0 +1,42 @@
# ─── O*NET ───────────────────────────────
ONET_USERNAME=aptivaai
ONET_PASSWORD=2296ahq
# ─── Publicfacing React build ───────────
NODE_ENV=staging
REACT_APP_ENV=production
APTIVA_API_BASE=https://staging.aptivaai.com/api
REACT_APP_API_URL=${APTIVA_API_BASE}
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
REACT_APP_OPENAI_API_KEY=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
# ─── Back-end services ───────────────────
OPENAI_API_KEY=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
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
SALARY_DB=/home/jcoakley/aptiva-dev1-app/salary_info.db
# ─── Database (premium server) ───────────
DB_HOST=34.67.180.54
DB_PORT=3306
DB_USER=sqluser
DB_PASSWORD=ps<g+2DO-eTb2mb5
DB_NAME=user_profile_db
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
# ── Twilio (needed only by server3) ─────────────────────────
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
# ─── Anything new goes here ──────────────
JWT_SECRET=a35F0iFAkkdWvSjnaLzepAl/JIxPRUh4NpcGptJgry2Z3KVLX4ZcYY5KaTf7kJY0
# ------------ env/staging.env ------------
CORS_ALLOWED_ORIGINS=https://staging.aptivaai.com,http://34.61.84.49:3000,http://localhost:3000
SERVER1_PORT=5000
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=20250716

View File

@ -55,14 +55,24 @@ pool.query('SELECT 1', (err) => {
});
const app = express();
const PORT = 5000;
const PORT = process.env.SERVER1_PORT || 5000;
/* ─── Require critical env vars ───────────────────────────────── */
if (!process.env.CORS_ALLOWED_ORIGINS) {
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line
process.exit(1);
}
if (!process.env.APTIVA_API_BASE) {
console.error('FATAL APTIVA_API_BASE is not set'); // eslint-disable-line
process.exit(1);
}
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(Boolean);
// Allowed origins for CORS
const allowedOrigins = [
'http://localhost:3000',
'http://34.16.120.118:3000',
'https://dev1.aptivaai.com',
];
app.disable('x-powered-by');
app.use(bodyParser.json());
@ -101,7 +111,7 @@ app.use(
// Handle preflight requests explicitly
app.options('*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',

View File

@ -38,22 +38,20 @@ const chatLimiter = rateLimit({
keyGenerator: req => req.user?.id || req.ip
});
// Whitelist CORS
const allowedOrigins = [
'http://localhost:3000',
'http://34.16.120.118:3000',
'https://dev1.aptivaai.com',
];
if (!process.env.APTIVA_API_BASE) {
console.error('FATAL APTIVA_API_BASE is not set');
process.exit(1);
}
// CIP->SOC mapping file
const mappingFilePath = '/home/jcoakley/aptiva-dev1-app/public/CIP_to_ONET_SOC.xlsx';
// Institution data
const institutionFilePath = path.resolve(rootPath, 'public', 'Institution_data.json');
const institutionFilePath = 'home/jcoakley/aptiva-dev1-app/public/Institution_data.json';
// Create Express app
const app = express();
const PORT = process.env.PORT || 5001;
const PORT = process.env.SERVER2_PORT || 5001;
// at top of backend/server.js (do once per server codebase)
app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
@ -86,9 +84,23 @@ async function initDatabases() {
await initDatabases();
/**************************************************
* Security, CORS, JSON Body
**************************************************/
/*
* SECURITY, CORS, JSON Body
* */
/* 1 — Require critical env var up-front */
if (!process.env.CORS_ALLOWED_ORIGINS) {
console.error('FATAL CORS_ALLOWED_ORIGINS is not set');
process.exit(1);
}
/* 2 — Build allow-list from env (comma-separated) */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(Boolean);
/* 3 — Security headers */
app.use(
helmet({
contentSecurityPolicy: false,
@ -96,8 +108,11 @@ app.use(
})
);
/* 4 — Dynamic CORS / pre-flight handling */
app.use((req, res, next) => {
const origin = req.headers.origin;
/* 4a — Whitelisted origins (credentials allowed) */
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
@ -105,17 +120,22 @@ app.use((req, res, next) => {
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods'
);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Methods',
'GET, POST, OPTIONS'
);
/* 4b — Public JSON exception */
} else if (req.path.includes('Institution_data')) {
// For that JSON
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, Accept, Origin, X-Requested-With'
);
/* 4c — Default permissive fallback (same as your original) */
} else {
// default
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
@ -123,21 +143,22 @@ app.use((req, res, next) => {
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
}
/* 4d — Short-circuit pre-flight requests */
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
return res.status(204).end();
res.status(204).end();
return;
}
next();
});
/* 5 — JSON parsing & static assets */
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// For completeness
app.use((req, res, next) => {
next();
});
/* 6 — No-op pass-through (kept for completeness) */
app.use((req, res, next) => next());
/**************************************************
* Load CIP->SOC mapping
@ -383,7 +404,7 @@ app.post('/api/onet/submit_answers', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
try {
await axios.post(`${process.env.MAIN_API_URL}/api/user-profile`,
await axios.post(`${process.env.APTIVA_API_BASE}/api/user-profile`,
{
interest_inventory_answers: answers,
riasec: riasecCode
@ -454,7 +475,7 @@ app.get('/api/onet/career-details/:socCode', async (req, res) => {
try {
const response = await axios.get(`https://services.onetcenter.org/ws/mnm/careers/${socCode}`, {
auth: {
username: process.env.ONet_USERNAME,
username: process.env.ONET_USERNAME,
password: process.env.ONET_PASSWORD,
},
headers: { Accept: 'application/json' },
@ -1000,5 +1021,5 @@ chatFreeEndpoint(app, {
* Start the Express server
**************************************************/
app.listen(PORT, () => {
console.log(`Server running on https://34.16.120.118:${PORT}`);
console.log(`Server running on port ${PORT}`);
});

View File

@ -7,7 +7,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import fs from 'fs/promises';
import multer from 'multer';
@ -30,7 +29,7 @@ dotenv.config({ path: envPath });
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
const app = express();
const PORT = process.env.PREMIUM_PORT || 5002;
const PORT = process.env.SERVER3_PORT || 5002;
const { getDocument } = pkg;
const bt = "`".repeat(3);
@ -45,14 +44,55 @@ function internalFetch(req, url, opts = {}) {
});
}
// 2) Basic middlewares
app.use(helmet());
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
app.use(express.json({ limit: '5mb' }));
const allowedOrigins = ['https://dev1.aptivaai.com'];
app.use(cors({ origin: allowedOrigins, credentials: true }));
/* ─── Require critical env vars ─────────────────────────────── */
if (!process.env.CORS_ALLOWED_ORIGINS) {
console.error('FATAL CORS_ALLOWED_ORIGINS is not set');
process.exit(1);
}
if (!process.env.APTIVA_API_BASE) {
console.error('FATAL APTIVA_API_BASE is not set');
process.exit(1);
}
/* ─── Allowed origins for CORS (comma-separated in env) ─────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(Boolean);
/* ─── Dynamic CORS middleware (matches server1 / server2) ────────────── */
app.use((req, res, next) => {
const origin = req.headers.origin;
// A) whitelisted origins (credentials allowed)
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods'
);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
// B) default permissive fallback (same as server2s behaviour)
} else {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
}
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
next();
});
// 3) Authentication middleware
const authenticatePremiumUser = (req, res, next) => {

5
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,5 @@
services:
server1: { env_file: .env.dev }
server2: { env_file: .env.dev }
server3: { env_file: .env.dev }
nginx : { env_file: .env.dev }

View File

@ -1,42 +1,5 @@
services:
# ─── server1 ───
server1:
extends:
file: docker-compose.yml
service: server1
env_file: [ ./env/prod.env ]
environment:
- NODE_ENV=production
- SALARY_DB=/app/data/salary_info.db
volumes:
- /home/jcoakley/aptiva-dev1-app/salary_info.db:/app/data/salary_info.db:ro
- /home/jcoakley/aptiva-dev1-app/public:/home/jcoakley/aptiva-dev1-app/public:ro
# ─── server2 ───
server2:
extends:
file: docker-compose.yml
service: server2
env_file: [ ./env/prod.env ]
environment:
- NODE_ENV=production
- SALARY_DB=/home/jcoakley/aptiva-dev1-app/salary_info.db
volumes:
- /home/jcoakley/aptiva-dev1-app/salary_info.db:/home/jcoakley/aptiva-dev1-app/salary_info.db:ro
- /home/jcoakley/aptiva-dev1-app/user_profile.db:/home/jcoakley/aptiva-dev1-app/user_profile.db
- /home/jcoakley/aptiva-dev1-app/public:/home/jcoakley/aptiva-dev1-app/public:ro
# ─── server3 ───
server3:
extends:
file: docker-compose.yml
service: server3
env_file: [ ./env/prod.env ]
environment:
- NODE_ENV=production
- TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
- TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
- TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
volumes:
- /home/jcoakley/aptiva-dev1-app/public:/home/jcoakley/aptiva-dev1-app/public:ro
- /home/jcoakley/aptiva-dev1-app/user_profile.db:/home/jcoakley/aptiva-dev1-app/user_profile.db
server1: { env_file: .env.prod }
server2: { env_file: .env.prod }
server3: { env_file: .env.prod }
nginx : { env_file: .env.prod }

View File

@ -1,39 +1,5 @@
services:
# ─── server1 ───
server1:
extends:
file: docker-compose.yml
service: server1
env_file: [ ./env/staging.env ]
environment:
- NODE_ENV=production
- SALARY_DB=/app/data/salary_info.db
volumes:
- /home/jcoakley/aptiva-dev1-app/salary_info.db:/app/data/salary_info.db:ro
- /home/jcoakley/aptiva-dev1-app/public:/home/jcoakley/aptiva-dev1-app/public:ro
# ─── server2 ───
server2:
extends:
file: docker-compose.yml
service: server2
env_file: [ ./env/staging.env ]
environment:
- NODE_ENV=production
- SALARY_DB=/home/jcoakley/aptiva-dev1-app/salary_info.db
volumes:
- /home/jcoakley/aptiva-dev1-app/salary_info.db:/home/jcoakley/aptiva-dev1-app/salary_info.db:ro
- /home/jcoakley/aptiva-dev1-app/public:/home/jcoakley/aptiva-dev1-app/public:ro
- /home/jcoakley/aptiva-dev1-app/user_profile.db:/home/jcoakley/aptiva-dev1-app/user_profile.db
# ─── server3 ───
server3:
extends:
file: docker-compose.yml
service: server3
env_file: [ ./env/staging.env ]
environment:
- NODE_ENV=production
volumes:
- /home/jcoakley/aptiva-dev1-app/public:/home/jcoakley/aptiva-dev1-app/public:ro
- /home/jcoakley/aptiva-dev1-app/user_profile.db:/home/jcoakley/aptiva-dev1-app/user_profile.db
server1: { env_file: .env.staging }
server2: { env_file: .env.staging }
server3: { env_file: .env.staging }
nginx : { env_file: .env.staging }

View File

@ -1,50 +1,45 @@
version: "3.9"
services:
server1:
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:prod-20250710
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG}
expose: ["${SERVER1_PORT}"]
restart: unless-stopped
expose: ["5000"]
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5000/healthz || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER1_PORT}/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
server2:
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:prod-20250710
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG}
expose: ["${SERVER2_PORT}"]
restart: unless-stopped
expose: ["5001"]
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5001/healthz || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER2_PORT}/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
server3:
build:
context: .
dockerfile: Dockerfile.server3
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG}
expose: ["${SERVER3_PORT}"]
restart: unless-stopped
expose: ["5002"]
environment:
NODE_ENV: production
JWT_SECRET: gW4QsOu4AJA4MooIUC9ld2i71VbBovzV1INsaU6ftxYPrxLIeMq6/OY61j0X2RV7
TWILIO_ACCOUNT_SID: ACd700c6fb9f691ccd9ccab73f2dd4173d
TWILIO_AUTH_TOKEN: fb8979ccb172032a249014c9c30eba80
TWILIO_MESSAGING_SERVICE_SID: MGMGaa07992a9231c841b1bfb879649026d6
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5002/healthz || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
nginx:
image: nginx:1.25-alpine
command: ["nginx", "-g", "daemon off;"]
command: ["nginx","-g","daemon off;"]
ports:
- "80:80"
- "443:443"
volumes:
- ./build:/usr/share/nginx/html:ro # React build
- ./nginx.conf:/etc/nginx/nginx.conf:ro # overwrite default
- /etc/letsencrypt:/etc/letsencrypt:ro # certs
- ./empty:/etc/nginx/conf.d # hide default.conf
- ./build:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./empty:/etc/nginx/conf.d
depends_on: [server1, server2, server3]

View File

@ -10,6 +10,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
const [error, setError] = useState('');
const [showSessionExpiredMsg, setShowSessionExpiredMsg] = useState(false);
const location = useLocation();
const apiUrl = process.env.REACT_APP_API_URL;
useEffect(() => {
// Check if the URL query param has ?session=expired
@ -42,10 +43,10 @@ function SignIn({ setIsAuthenticated, setUser }) {
}
try {
const resp = await fetch('https://dev1.aptivaai.com/api/signin', {
const resp = await fetch(`${apiUrl}/signin`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ username, password })
body : JSON.stringify(formData),
});
const data = await resp.json(); // ← read ONCE