2026-03-27 10:04:54 -04:00
2026-03-27 10:04:54 -04:00
2026-03-27 10:04:54 -04:00
2026-03-26 09:59:40 -04:00
MVP
2026-03-25 21:54:50 -04:00
2026-03-25 18:03:46 -04:00
2026-03-27 10:04:54 -04:00
2026-03-26 11:14:53 -04:00
2026-03-26 10:49:15 -04:00

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

  1. Copy the environment file.
cp .env.example .env
  1. Create the shared Traefik Docker network if you do not already have it.
docker network create traefik_proxy
  1. 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 users single arsenal/profile

Environment flags

  • ALLOW_REGISTRATION=true|false
    • Controls whether POST /api/auth/register is available
    • When false, the login UI hides self-service account creation
  • ALLOW_DEMO_ACCOUNT=true|false
    • Enables a backend-managed demo account and a demo sign-in button on the login page
  • DEMO_ACCOUNT_EMAIL
  • DEMO_ACCOUNT_PASSWORD
  • DEMO_ACCOUNT_NAME

Database note:

  • In Docker Compose, the backend uses POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD
  • This avoids malformed DATABASE_URL issues when the database password contains URL-sensitive characters

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

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/demo
    • Signs in to the configured demo account when demo mode is enabled
  • 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 users profile list and active profile ID
  • POST /api/profiles
    • Currently disabled and returns 403
  • 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:

  • totalFirearms
  • totalAmmoRounds
  • firearmsInvestment
  • ammoInvestment
  • configuredCalibers

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:

  • Handgun
  • Rifle
  • Shotgun
  • PCC
  • Other

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 rounds adds to the current count
  • Negative rounds removes from the current count
  • The backend clamps the final total to 0
  • If costPerRound is omitted or null, 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
S
Description
Repo for Arsenal_IQ
Readme PolyForm-Noncommercial 362 KiB
Languages
TypeScript 89.6%
CSS 9.3%
Dockerfile 0.6%
HTML 0.3%
JavaScript 0.2%