Files
Arsenal_IQ/backend/src/app.ts
T
2026-03-26 00:24:33 -04:00

1280 lines
40 KiB
TypeScript

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<string, unknown>;
} 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<CaliberRow>(
`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<ProfileRow>(
'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<ProfileRow>(
'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<SessionRow>(
`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<SessionRow & { email: string; name: string }>(
`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<ProviderConfigRow>(
`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<DashboardSummaryRow>(
`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<FirearmRow>(
`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<CaliberRow>(
`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<AmmoInventoryRow>(
`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<ProfileRow>(
'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<ProviderConfigRow>(
`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<UserRow>(
'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<UserRow & { password_hash: string | null }>(
'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<string, unknown> = {};
if (provider.userinfo_endpoint && accessToken) {
const userinfoResponse = await axios.get(provider.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
userinfo = userinfoResponse.data as Record<string, unknown>;
} 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<UserRow>('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<UserRow>(
'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<UserRow>('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<ProviderConfigRow>(
`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<FirearmRow>(
`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<FirearmRow>(
`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<FirearmRow>(
`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<CaliberRow>(
`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<CaliberRow>(
`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<CaliberRow>(
`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<AmmoInventoryRow>(
`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<AmmoInventoryRow>(
`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);
});