Star Citizen Inventory

A Star Citizen item catalog built from the scunpacked game data (mirrored at StarCitizenWiki/scunpacked-data).

Ships, ship equipment, and FPS items are browsable with search, manufacturer / type / size filters, pagination, and a detail view per item.

Users can optionally create an account and build a personal inventory: items from the catalog are added to their own list with a quantity and notes. The catalog itself stays immutable read-only reference data; personal inventories live in PostgreSQL and reference catalog items by class_name only, so the dataset can be replaced wholesale without touching user data.

Stack

Piece Tech Role
backend FastAPI (python:3.12-slim container) Serves the read-only catalog and the account/inventory API
frontend nginx:1.27-alpine + vanilla JS/CSS Inventory UI; proxies /api/ and /healthz
database PostgreSQL 16 Accounts, sessions, and per-user inventory rows
deploy Ansible + Podman (containers.podman) Builds images and runs the three containers

The backend image downloads the full scunpacked dataset (~83 MB) at build time, so the catalog needs no database — only user data does.

Layout

playbook.yml               Ansible deployment (hosts: webservers)
inventory.ini              Inventory (localhost + commented remote example)
trade-hub/
  podman-compose.yml       Local three-service stack (db + backend + frontend)
  backend/                 FastAPI app, tests, data fetch script, Dockerfile
  frontend/                nginx config + static inventory UI

Run locally with Podman Compose

cd trade-hub
podman-compose up -d --build
# or: podman compose up -d --build

Then open http://localhost:8080

PostgreSQL data is kept in the pgdata volume. Set a non-default database password before deploying anywhere real:

POSTGRES_PASSWORD='choose-something-strong' podman-compose up -d --build

Deploy with Ansible

Deploys to every host in the webservers group (defaults to localhost):

POSTGRES_PASSWORD='choose-something-strong' ansible-playbook playbook.yml

To deploy to a remote server, edit inventory.ini:

[webservers]
server1 ansible_host=203.0.113.10 ansible_user=deploy

Then run the same command from this directory. The playbook:

  1. installs podman if missing,
  2. removes the legacy my-apache-server container if present,
  3. builds sc-inventory-backend / sc-inventory-frontend images (skipped when they already exist; set -e force_rebuild=true to rebuild),
  4. creates the sc-inventory podman network and the sc-inventory-data volume,
  5. runs PostgreSQL, the backend, and the frontend containers (only the frontend publishes a port: 8080),
  6. verifies http://localhost:8080/healthz returns 200.

Reset for testing (remove everything)

# preview only, changes nothing
ansible-playbook teardown.yml -e confirm_destroy=true --check

# remove containers, all project images, the network and the database volume
ansible-playbook teardown.yml -e confirm_destroy=true

The teardown is scoped by name (sc-inventory*, sc-inv-verify*, trade-hub*) and never runs podman system prune, so unrelated containers, images and volumes on the host are left alone. It removes every tag of the project images (so stale builds cannot resurface) and deletes the sc-inventory-data volume — all accounts and inventories are destroyed. Run playbook.yml afterwards to deploy a clean install; the images rebuild from scratch.

Accounts and personal inventory

  • Registration is open: anyone who can reach the site can create an account.
  • A new account starts with an empty inventory; nothing is seeded or shared between accounts.
  • Passwords are hashed with Argon2 and never returned by the API.
  • Sessions are opaque random tokens delivered in an httpOnly, SameSite=Lax cookie; only the SHA-256 of the token is stored server-side.
  • Failed logins are throttled per username and per client IP, keyed on the X-Real-IP header that nginx sets from the actual peer address.
  • Mutating requests are rejected when the Origin header does not match the request host.
  • Catalog browsing works without an account; the catalog endpoints remain read-only.

The inventory view can be filtered by category and searched by name/class name, and sorted by name (default), category, quantity (high to low) or updated (most recently changed first).

Inventory rows store (user_id, class_name, quantity, notes). Adding an item that is already present increments its quantity; quantity and notes can then be edited or the row removed. Responses are enriched live from the catalog. If a catalog item disappears after a dataset refresh, the row is reported with available: false instead of being silently deleted.

Security: read before exposing this publicly

The stack publishes plain HTTP on port 8080. Session cookies therefore travel in cleartext, and COOKIE_SECURE defaults to false so local development works. Before putting this on the open internet, terminate TLS in front of the frontend (Caddy, nginx with certificates, or a load balancer) and set COOKIE_SECURE=true (compose env var) or -e cookie_secure=true (Ansible). Also change POSTGRES_PASSWORD from its development default.

Verify

curl -fsS http://localhost:8080/healthz
curl -fsS http://localhost:8080/api/v1/stats
curl -fsS "http://localhost:8080/api/v1/items?search=carrack"

Register, then use the cookie jar for authenticated calls:

curl -fsS -c cookies.txt -X POST http://localhost:8080/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"pilot","password":"correct horse battery"}'

curl -fsS -b cookies.txt http://localhost:8080/api/v1/auth/me

curl -fsS -b cookies.txt -X POST http://localhost:8080/api/v1/me/inventory \
  -H 'Content-Type: application/json' \
  -d '{"class_name":"ANVL_Carrack","quantity":1,"notes":"pledged"}'

curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory

Backend development

cd trade-hub/backend
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
.venv/bin/python -m pytest tests/ -q          # 50 tests, no database needed
DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000

Tests run against an in-memory SQLite database configured in tests/conftest.py, so no PostgreSQL server is required for the suite. Production uses PostgreSQL.

  • data/samples/ — small real-schema subset for local dev/tests
  • scripts/fetch_data.py — downloads the full dataset (used by the Dockerfile)

Tables are created on startup with an idempotent create_all and a connection retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does not crash the backend. This does not migrate changed columns — introduce Alembic when the schema starts evolving in place.

API

GET    /healthz
GET    /api/v1/stats
GET    /api/v1/manufacturers
GET    /api/v1/items?category=&search=&manufacturer=&type=&size=&page=&page_size=
GET    /api/v1/items/{class_name}

POST   /api/v1/auth/register      {username, password}        -> 201 + session cookie
POST   /api/v1/auth/login         {username, password}        -> 200 + session cookie
POST   /api/v1/auth/logout                                    -> 204
GET    /api/v1/auth/me                                        -> {user}

GET    /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
GET    /api/v1/me/inventory/summary
POST   /api/v1/me/inventory       {class_name, quantity?, notes?}
PATCH  /api/v1/me/inventory/{class_name}   {quantity?, notes?}
DELETE /api/v1/me/inventory/{class_name}

/api/v1/me/* requires a valid session cookie and returns 401 otherwise.

Configuration

Variable Default Purpose
DATA_DIR backend/data/samples Catalog JSON location
DATABASE_URL postgresql+psycopg://sc_inventory:sc_inventory_dev@db:5432/sc_inventory SQLAlchemy database URL
AUTO_INIT_DB true Create missing tables on startup
DB_WAIT_SECONDS 60 Startup wait for PostgreSQL
COOKIE_SECURE false Mark the session cookie Secure (use with TLS)
COOKIE_NAME sc_session Session cookie name
SESSION_TTL_DAYS 30 Absolute session lifetime
CORS_ORIGINS (empty) Comma-separated cross-origin opt-in
LOGIN_MAX_ATTEMPTS 5 Failed logins before throttling
LOGIN_WINDOW_SECONDS 900 Throttle window

Data source: scunpacked — unofficial Star Citizen fansite content, not affiliated with Cloud Imperium Games.

Description
No description provided
Readme 1.5 MiB
Languages
Python 60.5%
JavaScript 22.7%
CSS 10.3%
HTML 5.9%
Dockerfile 0.6%