8.7 KiB
Arsenal IQ
Arsenal IQ is a self-hosted ERP-style inventory app for recording firearms and ammunition. It tracks serial numbers, acquisition cost, estimated value, ammunition on hand by caliber, and ammo cost, with a React frontend and a Docker Compose deployment model that supports both local development and Traefik-backed production.
Stack
- Frontend: React + TypeScript + Vite
- Backend: Node.js + Express + TypeScript
- Database: PostgreSQL 16
- Runtime: Docker Compose
- Proxy: optional Traefik production override
Features
- Firearm registry with manufacturer, model, serial number, caliber, category, storage location, and cost basis
- Ammunition lot tracking with caliber, grain, quantity on hand, vendor, cost per round, and reorder thresholds
- Dashboard summaries for inventory counts, invested value, and low-stock alerts
- Seed data so the UI is useful immediately after first startup
Quick start
- Copy the environment file.
cp .env.example .env
- Create the shared Traefik Docker network if you do not already have it.
docker network create traefik_proxy
- Build and start the local development stack.
docker compose up --build
Local endpoints
- Frontend:
http://localhost:3000 - API:
http://localhost:5000/api - Health check:
http://localhost:5000/health - PostgreSQL:
localhost:5432
Development storage
This project now uses local bind mounts instead of named Docker volumes:
- PostgreSQL data:
./data/postgres - Backend dependencies:
./backend/node_modules - Frontend dependencies:
./frontend/node_modules
On first start, the backend and frontend containers will install dependencies into those local node_modules folders before launching.
Production with Traefik
For production, use the dedicated production compose file so Traefik labels and external networking are only enabled there.
docker compose -f docker-compose.prod.yml up --build -d
Configure these values in .env for your production domain names:
TRAEFIK_NETWORK=traefik_proxy
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_WEB_HOST=arsenal.example.com
TRAEFIK_API_HOST=api.arsenal.example.com
If the frontend should call the API through Traefik in production, set:
VITE_API_BASE_URL=https://api.arsenal.example.com/api
FRONTEND_URL=https://arsenal.example.com
To disable self-service account creation and allow only existing users or SSO sign-in, set:
ALLOW_REGISTRATION=false
API
The app includes an Express API in backend/src/app.ts. In local development, the frontend calls:
- Base URL:
http://localhost:5000/api - Health check:
http://localhost:5000/health
Authentication model
- Authenticated requests use
Authorization: Bearer <token> - The token is returned by local login/register or after a successful SSO callback
- The app is currently single-profile per user, even though profile endpoints still exist for compatibility
- Most inventory routes also accept
x-profile-id, but in the current app this resolves to the user’s single arsenal/profile
Environment flags
ALLOW_REGISTRATION=true|false- Controls whether
POST /api/auth/registeris available - When
false, the login UI hides self-service account creation
- Controls whether
Response shape notes
- Validation and business-rule errors generally return:
{ "error": "Human readable message" }
- Successful login/register responses return:
{
"token": "session-token",
"user": {
"id": "uuid",
"email": "owner@example.com",
"name": "Owner Name"
},
"profiles": [
{
"id": "uuid",
"name": "Owner"
}
],
"activeProfileId": "uuid"
}
Core routes
Service and discovery
GET /health- Returns service/database health
GET /api- Returns API metadata and
allowRegistration
- Returns API metadata and
Example:
{
"name": "Arsenal IQ API",
"version": "3.0.0",
"allowRegistration": true,
"resources": [
"/api/auth/login",
"/api/auth/register",
"/api/dashboard",
"/api/firearms",
"/api/calibers",
"/api/ammo"
]
}
Authentication
GET /api/auth/providers- Public list of enabled SSO providers for the login page
POST /api/auth/register- Creates a local account when registration is enabled
POST /api/auth/login- Signs in with local email/password
POST /api/auth/logout- Invalidates the current session token
GET /api/auth/me- Returns the current authenticated user and active profile
GET /api/auth/sso/:providerKey/start- Starts the OIDC login flow and returns an authorization URL
GET /api/auth/sso/:providerKey/callback- Handles the provider callback, creates or links a user, then redirects back to the frontend with a token
Register request:
{
"name": "Owner Name",
"email": "owner@example.com",
"password": "change-me"
}
Login request:
{
"email": "owner@example.com",
"password": "change-me"
}
SSO behavior:
- If the SSO provider returns an email that matches an existing user, the SSO identity is linked to that user
- If the email does not exist yet, a new user is created automatically
- If the account was created via SSO only, local password login is rejected for that user
Profiles
GET /api/profiles- Returns the user’s profile list and active profile ID
POST /api/profiles- Currently disabled and returns
403
- Currently disabled and returns
POST /api/profiles/select- Returns the active profile ID
Note:
- The backend still exposes these endpoints, but the product now behaves as one user per arsenal/profile
Dashboard
GET /api/dashboard- Returns the current user, active profile, firearms, calibers, ammo inventory, defaults, and summary metrics used by the React UI
Summary fields:
totalFirearmstotalAmmoRoundsfirearmsInvestmentammoInvestmentconfiguredCalibers
Firearms
GET /api/firearms- Lists firearms for the current profile
POST /api/firearms- Creates a firearm
PUT /api/firearms/:id- Updates a firearm
DELETE /api/firearms/:id- Deletes a firearm
Firearm body:
{
"manufacturer": "Glock",
"model": "19",
"category": "Handgun",
"caliber": "9mm",
"serialNumber": "ABC123",
"purchasePrice": 550,
"acquiredOn": "2025-06-01",
"imageUrl": "",
"notes": "Carry pistol"
}
Valid firearm categories:
HandgunRifleShotgunPCCOther
Firearm response:
{
"id": "uuid",
"manufacturer": "Glock",
"model": "19",
"category": "Handgun",
"caliber": "9mm",
"serialNumber": "ABC123",
"purchasePrice": 550,
"acquiredOn": "2025-06-01",
"imageUrl": null,
"notes": "Carry pistol"
}
Calibers
GET /api/calibers- Returns configured active calibers plus unused default caliber names
POST /api/calibers- Adds or re-enables a caliber for the current profile
PATCH /api/calibers/:id- Enables or disables a caliber
Create caliber request:
{
"name": "300 BLK"
}
Toggle caliber request:
{
"isActive": false
}
Caliber response:
{
"id": "uuid",
"name": "9mm",
"isDefault": true,
"isActive": true
}
Ammo
GET /api/ammo- Returns active caliber inventory rows for the current profile
PATCH /api/ammo/:caliberId- Adjusts rounds on hand and optionally updates cost per round
Ammo patch request:
{
"rounds": 250,
"costPerRound": 0.24
}
Notes:
- Positive
roundsadds to the current count - Negative
roundsremoves from the current count - The backend clamps the final total to
0 - If
costPerRoundis omitted ornull, the existing value is preserved
Ammo response:
{
"caliberId": "uuid",
"caliber": "9mm",
"roundsOnHand": 750,
"costPerRound": 0.24,
"totalValue": 180
}
Auth provider settings
GET /api/settings/auth-providers- Returns the full editable provider configuration for the authenticated settings page
PUT /api/settings/auth-providers/:providerKey- Updates an auth provider config such as Google, Entra, or another OIDC-compatible provider
Provider update request:
{
"displayName": "Google",
"protocol": "oidc",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"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",
"scopes": "openid profile email",
"enabled": true
}
Current limitations
- There is no password reset or account recovery flow yet
- There is no API versioning beyond the current route structure
- Profile endpoints remain present, but multiple profiles are intentionally disabled in the product