import crypto from 'crypto'; import axios from 'axios'; import bcrypt from 'bcryptjs'; import cors from 'cors'; import dotenv from 'dotenv'; import express from 'express'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import morgan from 'morgan'; import pg from 'pg'; dotenv.config(); type UserRow = { id: string; email: string; name: string; }; type ProfileRow = { id: string; name: string; }; type SessionRow = { id: string; user_id: string; active_profile_id: string | null; expires_at: string; }; type ProviderConfigRow = { provider_key: string; display_name: string; protocol: string; client_id: string | null; client_secret: string | null; authorization_endpoint: string | null; token_endpoint: string | null; userinfo_endpoint: string | null; issuer: string | null; scopes: string; enabled: boolean; }; type DashboardSummaryRow = { totalFirearms: number; totalAmmoRounds: number; firearmsInvestment: string; ammoInvestment: string; configuredCalibers: number; }; type FirearmRow = { id: string; manufacturer: string; model: string; category: string; caliber: string; serial_number: string; purchase_price: string; acquired_on: string | null; image_url: string | null; notes: string | null; }; type CaliberRow = { id: string; name: string; is_default: boolean; is_active: boolean; }; type AmmoInventoryRow = { caliber_id: string; caliber_name: string; rounds_on_hand: number; cost_per_round: string; }; type AuthContext = { user: UserRow; session: SessionRow; token: string; }; declare global { namespace Express { interface Request { auth?: AuthContext; } } } const app = express(); const port = Number(process.env.PORT ?? 5000); const databaseUrl = process.env.DATABASE_URL || `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:5432/${process.env.POSTGRES_DB}`; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:5000/api'; const allowRegistration = (process.env.ALLOW_REGISTRATION ?? 'true').toLowerCase() !== 'false'; const { Pool } = pg; const pool = new Pool({ connectionString: databaseUrl }); const defaultCalibers = ['9mm', '.22 LR', '5.56 NATO', '.308 Win', '12 Gauge', '.45 ACP']; const firearmCategories = ['Handgun', 'Rifle', 'Shotgun', 'PCC', 'Other']; const sessionHours = 24 * 7; const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:3000,http://localhost:5173') .split(',') .map((origin) => origin.trim()) .filter(Boolean); const formatCurrency = (value: string | number | null): number => Number(value ?? 0); const getString = (value: unknown, fieldName: string, required = true): string => { if (typeof value !== 'string') { if (required) { throw new Error(`${fieldName} is required`); } return ''; } const trimmed = value.trim(); if (!trimmed && required) { throw new Error(`${fieldName} is required`); } return trimmed; }; const getOptionalString = (value: unknown): string | null => { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); return trimmed ? trimmed : null; }; const getNumber = (value: unknown, fieldName: string): number => { const parsed = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(parsed)) { throw new Error(`${fieldName} must be a valid number`); } return parsed; }; const normalizeEmail = (email: string) => email.trim().toLowerCase(); const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); const createSessionToken = () => crypto.randomBytes(32).toString('hex'); const isUuid = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); const normalizeFirearm = (row: FirearmRow) => ({ id: row.id, manufacturer: row.manufacturer, model: row.model, category: row.category, caliber: row.caliber, serialNumber: row.serial_number, purchasePrice: formatCurrency(row.purchase_price), acquiredOn: row.acquired_on, imageUrl: row.image_url, notes: row.notes, }); const normalizeCaliber = (row: CaliberRow) => ({ id: row.id, name: row.name, isDefault: row.is_default, isActive: row.is_active, }); const normalizeAmmoInventory = (row: AmmoInventoryRow) => ({ caliberId: row.caliber_id, caliber: row.caliber_name, roundsOnHand: Number(row.rounds_on_hand || 0), costPerRound: formatCurrency(row.cost_per_round), totalValue: Number(row.rounds_on_hand || 0) * formatCurrency(row.cost_per_round), }); const serializeProvider = (provider: ProviderConfigRow) => ({ providerKey: provider.provider_key, displayName: provider.display_name, protocol: provider.protocol, clientId: provider.client_id ?? '', clientSecret: provider.client_secret ?? '', authorizationEndpoint: provider.authorization_endpoint ?? '', tokenEndpoint: provider.token_endpoint ?? '', userinfoEndpoint: provider.userinfo_endpoint ?? '', issuer: provider.issuer ?? '', scopes: provider.scopes, enabled: provider.enabled, }); const decodeJwtPayload = (token: string) => { const segments = token.split('.'); if (segments.length < 2) { return null; } const payload = segments[1].replace(/-/g, '+').replace(/_/g, '/'); const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '='); try { return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as Record; } catch { return null; } }; const ensureSchema = async () => { await pool.query(` CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255), name VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS profiles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE (user_id, name) ); CREATE TABLE IF NOT EXISTS auth_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, active_profile_id UUID REFERENCES profiles(id) ON DELETE SET NULL, token_hash VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS auth_provider_configs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_key VARCHAR(100) NOT NULL UNIQUE, display_name VARCHAR(255) NOT NULL, protocol VARCHAR(50) NOT NULL DEFAULT 'oidc', client_id TEXT, client_secret TEXT, authorization_endpoint TEXT, token_endpoint TEXT, userinfo_endpoint TEXT, issuer TEXT, scopes TEXT NOT NULL DEFAULT 'openid profile email', enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS auth_identities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE, provider_subject TEXT NOT NULL, email VARCHAR(255), created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE (provider_key, provider_subject) ); CREATE TABLE IF NOT EXISTS oauth_states ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), provider_key VARCHAR(100) NOT NULL REFERENCES auth_provider_configs(provider_key) ON DELETE CASCADE, state_code VARCHAR(255) NOT NULL UNIQUE, redirect_uri TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS calibers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, name VARCHAR(40) NOT NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE (profile_id, name) ); CREATE TABLE IF NOT EXISTS firearms ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, manufacturer VARCHAR(120) NOT NULL, model VARCHAR(120) NOT NULL, category VARCHAR(80) NOT NULL, caliber VARCHAR(40) NOT NULL, serial_number VARCHAR(120) NOT NULL, purchase_price NUMERIC(10, 2) NOT NULL DEFAULT 0, acquired_on DATE, image_url TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS ammo_inventory ( profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, caliber_id UUID NOT NULL REFERENCES calibers(id) ON DELETE CASCADE, rounds_on_hand INT NOT NULL DEFAULT 0 CHECK (rounds_on_hand >= 0), cost_per_round NUMERIC(10, 2) NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (profile_id, caliber_id) ); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id); CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON auth_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires_at ON auth_sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_calibers_profile_id ON calibers(profile_id); CREATE INDEX IF NOT EXISTS idx_firearms_profile_id ON firearms(profile_id); `); const providers = [ { providerKey: 'google', displayName: 'Google', authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token', userinfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo', issuer: 'https://accounts.google.com', }, { providerKey: 'entra', displayName: 'Microsoft Entra ID', authorizationEndpoint: '', tokenEndpoint: '', userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo', issuer: '', }, { providerKey: 'oidc', displayName: 'Custom OIDC', authorizationEndpoint: '', tokenEndpoint: '', userinfoEndpoint: '', issuer: '', }, ]; for (const provider of providers) { await pool.query( `INSERT INTO auth_provider_configs (provider_key, display_name, protocol, authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled) VALUES ($1, $2, 'oidc', $3, $4, $5, $6, 'openid profile email', FALSE) ON CONFLICT (provider_key) DO NOTHING`, [ provider.providerKey, provider.displayName, provider.authorizationEndpoint, provider.tokenEndpoint, provider.userinfoEndpoint, provider.issuer, ], ); } }; const ensureProfileDefaults = async (profileId: string) => { for (const caliber of defaultCalibers) { const caliberResult = await pool.query( `INSERT INTO calibers (profile_id, name, is_default, is_active) VALUES ($1, $2, TRUE, TRUE) ON CONFLICT (profile_id, name) DO UPDATE SET is_default = TRUE RETURNING id, name, is_default, is_active`, [profileId, caliber], ); await pool.query( `INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round) VALUES ($1, $2, 0, 0) ON CONFLICT (profile_id, caliber_id) DO NOTHING`, [profileId, caliberResult.rows[0].id], ); } }; const getUserProfiles = async (userId: string) => { const result = await pool.query( 'SELECT id, name FROM profiles WHERE user_id = $1 ORDER BY created_at ASC', [userId], ); return result.rows; }; const ensureDefaultProfile = async (userId: string, userName: string) => { const profiles = await getUserProfiles(userId); if (profiles.length > 0) { await ensureProfileDefaults(profiles[0].id); return profiles[0]; } const created = await pool.query( 'INSERT INTO profiles (user_id, name) VALUES ($1, $2) RETURNING id, name', [userId, `${userName.split(' ')[0] || 'Primary'} Arsenal`], ); await ensureProfileDefaults(created.rows[0].id); return created.rows[0]; }; const createSession = async (userId: string, activeProfileId: string) => { const token = createSessionToken(); const tokenHash = hashToken(token); const expiresAt = new Date(Date.now() + sessionHours * 60 * 60 * 1000); const result = await pool.query( `INSERT INTO auth_sessions (user_id, active_profile_id, token_hash, expires_at) VALUES ($1, $2, $3, $4) RETURNING id, user_id, active_profile_id, expires_at`, [userId, activeProfileId, tokenHash, expiresAt.toISOString()], ); return { token, session: result.rows[0], }; }; const getSessionFromToken = async (rawToken: string) => { const tokenHash = hashToken(rawToken); const result = await pool.query( `SELECT s.id, s.user_id, s.active_profile_id, s.expires_at, u.email, u.name FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW()`, [tokenHash], ); if ((result.rowCount ?? 0) === 0) { return null; } return { user: { id: result.rows[0].user_id, email: result.rows[0].email, name: result.rows[0].name, }, session: { id: result.rows[0].id, user_id: result.rows[0].user_id, active_profile_id: result.rows[0].active_profile_id, expires_at: result.rows[0].expires_at, }, }; }; const getAuthProvider = async (providerKey: string) => { const result = await pool.query( `SELECT provider_key, display_name, protocol, client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled FROM auth_provider_configs WHERE provider_key = $1`, [providerKey], ); return result.rows[0] ?? null; }; const buildDashboard = async (profileId: string) => { await ensureProfileDefaults(profileId); const [summaryResult, firearmsResult, calibersResult, ammoResult] = await Promise.all([ pool.query( `SELECT (SELECT COUNT(*)::int FROM firearms WHERE profile_id = $1) AS "totalFirearms", COALESCE((SELECT SUM(rounds_on_hand)::int FROM ammo_inventory WHERE profile_id = $1), 0) AS "totalAmmoRounds", COALESCE((SELECT SUM(purchase_price) FROM firearms WHERE profile_id = $1), 0) AS "firearmsInvestment", COALESCE((SELECT SUM(rounds_on_hand * cost_per_round) FROM ammo_inventory WHERE profile_id = $1), 0) AS "ammoInvestment", (SELECT COUNT(*)::int FROM calibers WHERE profile_id = $1 AND is_active = TRUE) AS "configuredCalibers"`, [profileId], ), pool.query( `SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes FROM firearms WHERE profile_id = $1 ORDER BY acquired_on DESC NULLS LAST, created_at DESC`, [profileId], ), pool.query( `SELECT id, name, is_default, is_active FROM calibers WHERE profile_id = $1 ORDER BY is_active DESC, is_default DESC, name ASC`, [profileId], ), pool.query( `SELECT ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round FROM ammo_inventory ai INNER JOIN calibers c ON c.id = ai.caliber_id WHERE ai.profile_id = $1 AND c.profile_id = $1 AND c.is_active = TRUE ORDER BY c.name ASC`, [profileId], ), ]); const summary = summaryResult.rows[0]; return { summary: { totalFirearms: Number(summary.totalFirearms || 0), totalAmmoRounds: Number(summary.totalAmmoRounds || 0), firearmsInvestment: formatCurrency(summary.firearmsInvestment), ammoInvestment: formatCurrency(summary.ammoInvestment), configuredCalibers: Number(summary.configuredCalibers || 0), }, firearms: firearmsResult.rows.map(normalizeFirearm), calibers: calibersResult.rows.map(normalizeCaliber), ammoInventory: ammoResult.rows.map(normalizeAmmoInventory), defaultCalibers, }; }; const resolveProfileId = async (req: express.Request) => { const requestedProfileId = String(req.header('x-profile-id') || ''); if (requestedProfileId && isUuid(requestedProfileId)) { const result = await pool.query( 'SELECT id, name FROM profiles WHERE id = $1 AND user_id = $2', [requestedProfileId, req.auth!.user.id], ); if ((result.rowCount ?? 0) > 0) { await ensureProfileDefaults(requestedProfileId); await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [ requestedProfileId, req.auth!.session.id, ]); req.auth!.session.active_profile_id = requestedProfileId; return requestedProfileId; } } if (req.auth!.session.active_profile_id) { await ensureProfileDefaults(req.auth!.session.active_profile_id); return req.auth!.session.active_profile_id; } const fallbackProfile = await ensureDefaultProfile(req.auth!.user.id, req.auth!.user.name); await pool.query('UPDATE auth_sessions SET active_profile_id = $1 WHERE id = $2', [ fallbackProfile.id, req.auth!.session.id, ]); req.auth!.session.active_profile_id = fallbackProfile.id; return fallbackProfile.id; }; const requireAuth = async (req: express.Request, res: express.Response, next: express.NextFunction) => { try { const header = req.header('authorization'); const token = header?.startsWith('Bearer ') ? header.slice(7) : ''; if (!token) { res.status(401).json({ error: 'Authentication required' }); return; } const sessionData = await getSessionFromToken(token); if (!sessionData) { res.status(401).json({ error: 'Session expired or invalid' }); return; } req.auth = { user: sessionData.user, session: sessionData.session, token, }; next(); } catch (error) { next(error); } }; app.use(helmet()); app.use( cors({ origin(origin, callback) { if (!origin || allowedOrigins.includes(origin)) { callback(null, true); return; } callback(new Error('Origin not allowed by CORS')); }, }), ); app.use( rateLimit({ windowMs: 15 * 60 * 1000, max: 250, standardHeaders: true, legacyHeaders: false, }), ); app.use(morgan('combined')); app.use(express.json()); app.get('/health', async (_req, res, next) => { try { const result = await pool.query('SELECT NOW() AS now'); res.json({ status: 'ok', database: 'connected', timestamp: result.rows[0].now }); } catch (error) { next(error); } }); app.get('/api', (_req, res) => { res.json({ name: 'Arsenal IQ API', version: '3.0.0', allowRegistration, resources: [ '/api/auth/login', '/api/auth/register', '/api/dashboard', '/api/firearms', '/api/calibers', '/api/ammo', ], }); }); app.get('/api/auth/providers', async (_req, res, next) => { try { const result = await pool.query( `SELECT provider_key, display_name, protocol, client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled FROM auth_provider_configs WHERE enabled = TRUE ORDER BY display_name ASC`, ); res.json( result.rows.map((provider) => ({ providerKey: provider.provider_key, displayName: provider.display_name, })), ); } catch (error) { next(error); } }); app.post('/api/auth/register', async (req, res, next) => { try { if (!allowRegistration) { res.status(403).json({ error: 'Registration is disabled' }); return; } const email = normalizeEmail(getString(req.body?.email, 'email')); const password = getString(req.body?.password, 'password'); const name = getString(req.body?.name, 'name'); const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]); if ((existing.rowCount ?? 0) > 0) { res.status(409).json({ error: 'An account with that email already exists' }); return; } const passwordHash = await bcrypt.hash(password, 10); const userResult = await pool.query( 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name', [email, passwordHash, name], ); const user = userResult.rows[0]; const profile = await ensureDefaultProfile(user.id, user.name); const { token, session } = await createSession(user.id, profile.id); res.status(201).json({ token, user, profiles: [profile], activeProfileId: session.active_profile_id, }); } catch (error) { next(error); } }); app.post('/api/auth/login', async (req, res, next) => { try { const email = normalizeEmail(getString(req.body?.email, 'email')); const password = getString(req.body?.password, 'password'); const result = await pool.query( 'SELECT id, email, name, password_hash FROM users WHERE email = $1', [email], ); if ((result.rowCount ?? 0) === 0) { res.status(401).json({ error: 'Invalid credentials' }); return; } const user = result.rows[0]; if (!user.password_hash) { res.status(401).json({ error: 'This account uses SSO. Use your identity provider to sign in.' }); return; } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { res.status(401).json({ error: 'Invalid credentials' }); return; } const profile = await ensureDefaultProfile(user.id, user.name); const { token, session } = await createSession(user.id, profile.id); const profiles = await getUserProfiles(user.id); res.json({ token, user: { id: user.id, email: user.email, name: user.name }, profiles, activeProfileId: session.active_profile_id, }); } catch (error) { next(error); } }); app.post('/api/auth/logout', requireAuth, async (req, res, next) => { try { await pool.query('DELETE FROM auth_sessions WHERE id = $1', [req.auth!.session.id]); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/auth/me', requireAuth, async (req, res, next) => { try { const activeProfileId = await resolveProfileId(req); const profiles = await getUserProfiles(req.auth!.user.id); res.json({ user: req.auth!.user, profiles, activeProfileId, }); } catch (error) { next(error); } }); app.get('/api/auth/sso/:providerKey/start', async (req, res, next) => { try { const provider = await getAuthProvider(req.params.providerKey); if (!provider?.enabled) { res.status(404).json({ error: 'SSO provider is not enabled' }); return; } if (!provider.client_id || !provider.authorization_endpoint) { res.status(400).json({ error: 'SSO provider is not fully configured' }); return; } const state = crypto.randomBytes(24).toString('hex'); const redirectUri = `${apiBaseUrl}/auth/sso/${provider.provider_key}/callback`; await pool.query( 'INSERT INTO oauth_states (provider_key, state_code, redirect_uri) VALUES ($1, $2, $3)', [provider.provider_key, state, redirectUri], ); const url = new URL(provider.authorization_endpoint); url.searchParams.set('client_id', provider.client_id); url.searchParams.set('redirect_uri', redirectUri); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', provider.scopes || 'openid profile email'); url.searchParams.set('state', state); if (provider.provider_key === 'google') { url.searchParams.set('access_type', 'offline'); url.searchParams.set('prompt', 'select_account'); } res.json({ authorizationUrl: url.toString() }); } catch (error) { next(error); } }); app.get('/api/auth/sso/:providerKey/callback', async (req, res, next) => { try { const provider = await getAuthProvider(req.params.providerKey); const code = String(req.query.code || ''); const state = String(req.query.state || ''); if (!provider?.enabled || !code || !state) { res.status(400).send('Invalid SSO callback'); return; } const stateResult = await pool.query<{ redirect_uri: string }>( 'SELECT redirect_uri FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [provider.provider_key, state], ); if ((stateResult.rowCount ?? 0) === 0) { res.status(400).send('Invalid or expired SSO state'); return; } await pool.query('DELETE FROM oauth_states WHERE provider_key = $1 AND state_code = $2', [ provider.provider_key, state, ]); if (!provider.client_id || !provider.client_secret || !provider.token_endpoint) { res.status(400).send('Provider is missing token configuration'); return; } const redirectUri = stateResult.rows[0].redirect_uri; const tokenResponse = await axios.post( provider.token_endpoint, new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: provider.client_id, client_secret: provider.client_secret, }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, ); const accessToken = String(tokenResponse.data.access_token || ''); const idToken = tokenResponse.data.id_token ? String(tokenResponse.data.id_token) : ''; let userinfo: Record = {}; if (provider.userinfo_endpoint && accessToken) { const userinfoResponse = await axios.get(provider.userinfo_endpoint, { headers: { Authorization: `Bearer ${accessToken}` }, }); userinfo = userinfoResponse.data as Record; } else if (idToken) { userinfo = decodeJwtPayload(idToken) ?? {}; } const subject = String(userinfo.sub || ''); const email = normalizeEmail(String(userinfo.email || '')); const name = String(userinfo.name || '').trim() || `${String(userinfo.given_name || '').trim()} ${String(userinfo.family_name || '').trim()}`.trim() || email || `${provider.display_name} User`; if (!subject || !email) { res.status(400).send('Provider did not return a subject and email'); return; } const identityResult = await pool.query<{ user_id: string }>( 'SELECT user_id FROM auth_identities WHERE provider_key = $1 AND provider_subject = $2', [provider.provider_key, subject], ); let userId = identityResult.rows[0]?.user_id; if (!userId) { const existingUser = await pool.query('SELECT id, email, name FROM users WHERE email = $1', [email]); if ((existingUser.rowCount ?? 0) > 0) { userId = existingUser.rows[0].id; } else { const createdUser = await pool.query( 'INSERT INTO users (email, password_hash, name) VALUES ($1, NULL, $2) RETURNING id, email, name', [email, name], ); userId = createdUser.rows[0].id; } await pool.query( `INSERT INTO auth_identities (user_id, provider_key, provider_subject, email) VALUES ($1, $2, $3, $4) ON CONFLICT (provider_key, provider_subject) DO NOTHING`, [userId, provider.provider_key, subject, email], ); } const userResult = await pool.query('SELECT id, email, name FROM users WHERE id = $1', [userId]); const user = userResult.rows[0]; const profile = await ensureDefaultProfile(user.id, user.name); const { token } = await createSession(user.id, profile.id); res.redirect(`${frontendUrl}/?token=${encodeURIComponent(token)}`); } catch (error) { next(error); } }); app.get('/api/profiles', requireAuth, async (req, res, next) => { try { const profiles = await getUserProfiles(req.auth!.user.id); res.json({ profiles, activeProfileId: await resolveProfileId(req) }); } catch (error) { next(error); } }); app.post('/api/profiles', requireAuth, async (req, res, next) => { try { res.status(403).json({ error: 'Multiple profiles are disabled. Each user has a single arsenal.' }); } catch (error) { next(error); } }); app.post('/api/profiles/select', requireAuth, async (req, res, next) => { try { const activeProfileId = await resolveProfileId(req); res.json({ activeProfileId }); } catch (error) { next(error); } }); app.get('/api/settings/auth-providers', requireAuth, async (_req, res, next) => { try { const result = await pool.query( `SELECT provider_key, display_name, protocol, client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, issuer, scopes, enabled FROM auth_provider_configs ORDER BY display_name ASC`, ); res.json(result.rows.map(serializeProvider)); } catch (error) { next(error); } }); app.put('/api/settings/auth-providers/:providerKey', requireAuth, async (req, res, next) => { try { const providerKey = getString(req.params.providerKey, 'providerKey'); const existing = await getAuthProvider(providerKey); if (!existing) { res.status(404).json({ error: 'Provider not found' }); return; } await pool.query( `UPDATE auth_provider_configs SET display_name = $2, protocol = $3, client_id = $4, client_secret = $5, authorization_endpoint = $6, token_endpoint = $7, userinfo_endpoint = $8, issuer = $9, scopes = $10, enabled = $11, updated_at = NOW() WHERE provider_key = $1`, [ providerKey, String(req.body?.displayName || existing.display_name), String(req.body?.protocol || existing.protocol), String(req.body?.clientId ?? existing.client_id ?? ''), typeof req.body?.clientSecret === 'string' ? req.body.clientSecret : (existing.client_secret ?? ''), String(req.body?.authorizationEndpoint ?? existing.authorization_endpoint ?? ''), String(req.body?.tokenEndpoint ?? existing.token_endpoint ?? ''), String(req.body?.userinfoEndpoint ?? existing.userinfo_endpoint ?? ''), String(req.body?.issuer ?? existing.issuer ?? ''), String(req.body?.scopes ?? existing.scopes ?? 'openid profile email'), Boolean(req.body?.enabled), ], ); const updated = await getAuthProvider(providerKey); res.json(serializeProvider(updated!)); } catch (error) { next(error); } }); app.get('/api/dashboard', requireAuth, async (req, res, next) => { try { const activeProfileId = await resolveProfileId(req); const profiles = await getUserProfiles(req.auth!.user.id); const dashboard = await buildDashboard(activeProfileId); res.json({ user: req.auth!.user, profiles, activeProfileId, ...dashboard, }); } catch (error) { next(error); } }); app.get('/api/firearms', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const result = await pool.query( `SELECT id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes FROM firearms WHERE profile_id = $1 ORDER BY acquired_on DESC NULLS LAST, created_at DESC`, [profileId], ); res.json(result.rows.map(normalizeFirearm)); } catch (error) { next(error); } }); app.post('/api/firearms', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const manufacturer = getString(req.body?.manufacturer, 'manufacturer'); const model = getString(req.body?.model, 'model'); const category = getString(req.body?.category, 'category'); const caliber = getString(req.body?.caliber, 'caliber'); const serialNumber = getString(req.body?.serialNumber, 'serialNumber'); const purchasePrice = getNumber(req.body?.purchasePrice, 'purchasePrice'); const acquiredOn = getOptionalString(req.body?.acquiredOn); const imageUrl = getOptionalString(req.body?.imageUrl); const notes = getOptionalString(req.body?.notes); if (!firearmCategories.includes(category)) { res.status(400).json({ error: 'Unsupported firearm category' }); return; } const result = await pool.query( `INSERT INTO firearms (profile_id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`, [profileId, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes], ); res.status(201).json(normalizeFirearm(result.rows[0])); } catch (error) { next(error); } }); app.put('/api/firearms/:id', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const manufacturer = getString(req.body?.manufacturer, 'manufacturer'); const model = getString(req.body?.model, 'model'); const category = getString(req.body?.category, 'category'); const caliber = getString(req.body?.caliber, 'caliber'); const serialNumber = getString(req.body?.serialNumber, 'serialNumber'); const purchasePrice = getNumber(req.body?.purchasePrice, 'purchasePrice'); const acquiredOn = getOptionalString(req.body?.acquiredOn); const imageUrl = getOptionalString(req.body?.imageUrl); const notes = getOptionalString(req.body?.notes); const result = await pool.query( `UPDATE firearms SET manufacturer = $3, model = $4, category = $5, caliber = $6, serial_number = $7, purchase_price = $8, acquired_on = $9, image_url = $10, notes = $11, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND profile_id = $2 RETURNING id, manufacturer, model, category, caliber, serial_number, purchase_price, acquired_on, image_url, notes`, [req.params.id, profileId, manufacturer, model, category, caliber, serialNumber, purchasePrice, acquiredOn, imageUrl, notes], ); if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Firearm not found' }); return; } res.json(normalizeFirearm(result.rows[0])); } catch (error) { next(error); } }); app.delete('/api/firearms/:id', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const result = await pool.query('DELETE FROM firearms WHERE id = $1 AND profile_id = $2', [ req.params.id, profileId, ]); if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Firearm not found' }); return; } res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/calibers', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const result = await pool.query( `SELECT id, name, is_default, is_active FROM calibers WHERE profile_id = $1 ORDER BY is_active DESC, is_default DESC, name ASC`, [profileId], ); res.json({ configured: result.rows.filter((row) => row.is_active).map(normalizeCaliber), availableDefaults: defaultCalibers.filter( (name) => !result.rows.some((row) => row.name === name && row.is_active), ), }); } catch (error) { next(error); } }); app.post('/api/calibers', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const name = getString(req.body?.name, 'name'); const result = await pool.query( `INSERT INTO calibers (profile_id, name, is_default, is_active) VALUES ($1, $2, $3, TRUE) ON CONFLICT (profile_id, name) DO UPDATE SET is_active = TRUE RETURNING id, name, is_default, is_active`, [profileId, name, defaultCalibers.includes(name)], ); await pool.query( `INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round) VALUES ($1, $2, 0, 0) ON CONFLICT (profile_id, caliber_id) DO NOTHING`, [profileId, result.rows[0].id], ); res.status(201).json(normalizeCaliber(result.rows[0])); } catch (error) { next(error); } }); app.patch('/api/calibers/:id', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const isActive = Boolean(req.body?.isActive); const result = await pool.query( `UPDATE calibers SET is_active = $3 WHERE id = $1 AND profile_id = $2 RETURNING id, name, is_default, is_active`, [req.params.id, profileId, isActive], ); if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Caliber not found' }); return; } if (isActive) { await pool.query( `INSERT INTO ammo_inventory (profile_id, caliber_id, rounds_on_hand, cost_per_round) VALUES ($1, $2, 0, 0) ON CONFLICT (profile_id, caliber_id) DO NOTHING`, [profileId, req.params.id], ); } res.json(normalizeCaliber(result.rows[0])); } catch (error) { next(error); } }); app.get('/api/ammo', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const result = await pool.query( `SELECT ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round FROM ammo_inventory ai INNER JOIN calibers c ON c.id = ai.caliber_id WHERE ai.profile_id = $1 AND c.profile_id = $1 AND c.is_active = TRUE ORDER BY c.name ASC`, [profileId], ); res.json(result.rows.map(normalizeAmmoInventory)); } catch (error) { next(error); } }); app.patch('/api/ammo/:caliberId', requireAuth, async (req, res, next) => { try { const profileId = await resolveProfileId(req); const rounds = getNumber(req.body?.rounds, 'rounds'); const costPerRound = req.body?.costPerRound == null ? null : getNumber(req.body?.costPerRound, 'costPerRound'); const result = await pool.query( `UPDATE ammo_inventory ai SET rounds_on_hand = GREATEST(0, ai.rounds_on_hand + $3), cost_per_round = CASE WHEN $4::numeric IS NULL THEN ai.cost_per_round ELSE $4 END, updated_at = CURRENT_TIMESTAMP FROM calibers c WHERE ai.caliber_id = $2 AND ai.profile_id = $1 AND c.id = ai.caliber_id AND c.profile_id = $1 RETURNING ai.caliber_id, c.name AS caliber_name, ai.rounds_on_hand, ai.cost_per_round`, [profileId, req.params.caliberId, rounds, costPerRound], ); if ((result.rowCount ?? 0) === 0) { res.status(404).json({ error: 'Ammo inventory not found' }); return; } res.json(normalizeAmmoInventory(result.rows[0])); } catch (error) { next(error); } }); app.use((_req, res) => { res.status(404).json({ error: 'Route not found' }); }); app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(error); res.status(500).json({ error: process.env.NODE_ENV === 'production' ? 'Internal server error' : error.message, }); }); void ensureSchema() .then(() => { app.listen(port, () => { console.log(`Arsenal IQ API listening on http://localhost:${port}`); }); }) .catch((error) => { console.error('Failed to initialize schema', error); process.exit(1); });