Files
PartsInventorySystem/README.md
T
thejayman77 59949e91ca Add a browser render check, and fix the login error it found
tools/render_check.py drives the real UI in Playwright's Chromium and asserts
what a person actually sees: the login gate, live search narrowing on a spec
value, the quick-adjust buttons, the category spec template pre-filling and
switching the unit to grams, the settings and password panels, and the phone
layout at a true 390px viewport.

Playwright's own Chromium rather than the installed Chrome: headless Chrome
returns nothing while a desktop Chrome is open, silently enough that it reads
as the app being broken.

It found a real bug on its first run. The fetch helper treated any 401 as a
lapsed session, so a wrong password bounced to "Not authenticated" instead of
saying the password was wrong. 401s from the login call itself are now left to
report their own reason.

It also confirmed the CSP is doing its job from the other direction:
Playwright's wait_for_function compiles predicates with eval() and is refused,
so the check polls from Python rather than the app loosening script-src.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:45:39 -04:00

216 lines
9.7 KiB
Markdown

# Parts Inventory
A catalog of what's actually on the shelf — components, filament, fasteners,
tooling — so "do I have one of those?" is a five-second search instead of an
hour of opening drawers.
Runs as a FastAPI + SQLite container on `mainserver` (`192.168.50.8`) behind
Caddy at **https://parts.tjm77.com**, alongside the other `~/srv` services.
## The data model
One `parts` table holds what every kind of stock has in common — name,
quantity, unit, location, cost, min-stock threshold. Everything type-specific
lives in `part_specs` as key/value rows, so a 0603 resistor, a spool of PLA and
a box of M3 screws share a table without a schema fork.
Categories and locations are both **nestable trees**. `Workshop / Bin A3` is a
real path, and filtering by `Workshop` returns everything in every bin beneath
it. Sidebar counts roll up the same way, so they always match what clicking the
filter actually returns.
Each category carries a **spec template** — the properties worth recording for
that kind of thing. Pick "Filament" on the add form and it pre-fills Material,
Colour, Diameter, Brand, Print Temp, and switches the unit to grams. That's what
keeps manual entry from being a blank page. Templates are suggestions only:
delete any row, add your own, ignore them entirely.
Every quantity change is written to `stock_log`, so a part's history answers
"where did those 40 headers go" instead of just showing a smaller number than
you remembered.
Search is SQLite FTS5 over name, description, manufacturer, MPN, spec values,
tags, category and location, with prefix matching so results narrow as you type.
Typing `1.75mm`, `prusament`, `0603` or `Bin A3` all find the right things.
## Running it locally
```sh
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
PARTS_AUTH=off PARTS_DB=$PWD/data/parts.db .venv/bin/uvicorn app.main:app --port 8123
```
Then open http://127.0.0.1:8123.
## Tests
```sh
.venv/bin/python -m tests.test_api # 172 checks, in-process
.venv/bin/python -m tests.test_concurrency # 25 checks, against a real uvicorn
```
`test_api` exercises the API end to end against a throwaway database — the auth
gate and session-token signing, nested categories and locations, search across
every indexed field, filter rollups, stock adjustment and history, patch
semantics including explicit nulls, taxonomy cycle rejection, security headers
and asset versioning, login throttling on both the login and change-password
routes, and the full password-management flow including scrypt hashing, session
invalidation and the recovery CLI.
`tools/render_check.py` drives the real UI in a browser and asserts what a
person would see: that the JavaScript runs under the Content-Security-Policy,
that a wrong password says so, that picking "Filament" pre-fills its spec
template and switches the unit to grams, that the password section works, and
that nothing overflows on a 390px phone.
```sh
.venv/bin/python -m playwright install chromium # once
.venv/bin/python -m tools.render_check --keep-shots /tmp/shots
```
It uses Playwright's own Chromium deliberately: headless Chrome driving the
installed browser returns nothing at all while a desktop Chrome is open, which
is silent enough to look like the app is broken.
`test_concurrency` needs a real server process, because a lost update only shows
up when two requests genuinely overlap inside SQLite. It fires overlapping
adjustments, patches and creates at one part and asserts the stock log always
sums to the stored quantity; races taxonomy renames against reads to check the
search index never describes a name the tree no longer has; and races session
revocations to check no epoch increment is lost.
## Configuration
The password is **managed from the app**, not from a config file. `PARTS_PASSWORD`
is only the bootstrap credential: it works until a password is set through the
UI, and is ignored from then on (otherwise "changing" the password would leave
the old one working). The stored password is a salted scrypt hash in the
`settings` table, which lives in the `/data` volume and survives rebuilds.
| Variable | Meaning |
|---|---|
| `PARTS_PASSWORD` | Bootstrap password, used only until one is set in the app. |
| `PARTS_SECRET` | Signing key for the session cookie. `openssl rand -hex 32`. |
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
| `PARTS_DB` | SQLite path. `/data/parts.db` in the container. |
| `PARTS_LOGIN_MAX_FAILURES` | Failed logins allowed per window, default 10. |
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
Every token carries a session epoch. Changing the password bumps it, which
invalidates every outstanding cookie at once while re-issuing one for the
browser that made the change — so a password change really does sign out other
devices, with no need to touch `PARTS_SECRET` on the host. **Sign out other
devices** in Settings bumps the epoch on its own.
Failed logins are throttled on a **global** window rather than per source
address. Caddy appends to `X-Forwarded-For` instead of replacing it, so the
client-supplied end of that header is forgeable and a per-IP bucket would be
trivially evaded. There is one legitimate user, so a global cap costs nothing
real; the trade-off is that a guessing spray can block the login form for the
window. Existing sessions keep working throughout.
## Deploying
Lives at `/home/jay/srv/parts/` on `.8` and follows the same conventions as the
other services there — `build: .`, external `caddy_web` network, named volume
for `/data`.
`~/srv/parts` is a checkout of this repository — `git log` there tells you
exactly what is deployed — but **deploys go over rsync, not `git pull`**:
```sh
rsync -az --delete \
--exclude '.venv' --exclude 'data' --exclude '__pycache__' --exclude '*.pyc' \
--exclude '.env' --exclude '.env.bak-*' \
./ jay@192.168.50.8:/home/jay/srv/parts/
ssh jay@192.168.50.8 'cd ~/srv/parts && docker compose up -d --build'
```
`.env` is excluded from the transfer, and rsync's `--delete` leaves excluded
files alone, so the deployed credentials survive.
The reason it isn't `git pull`: **gitea on `.8` cannot serve a clone or fetch to
`.8` itself.** Auth succeeds and gitea logs `git-upload-pack ... 200 OK`, then
the transfer dies with `fetch-pack: unexpected disconnect while reading sideband
packet`. Reproduced against both `git.tjm77.com:2222` and `127.0.0.1:2222`, with
`--depth 1`, with protocol v0, and with fsck disabled; `git ls-remote` succeeds
every time, and cloning the same repo from a laptop works. Undiagnosed. Until
it's fixed, the checkout in `~/srv/parts` is placed there by rsyncing the
working tree *including* `.git`, which is why `git status` there is clean.
`~/srv` (the infra repo) ignores `parts/`, since this directory is its own
repository.
The database is in the `parts_parts_data` docker volume, which survives
rebuilds.
**Back it up with SQLite's backup API, not `cp`.** The database runs in WAL
mode, so recently committed rows may still live in `parts.db-wal` and copying
`parts.db` alone can silently lose them. `VACUUM INTO` takes a consistent
snapshot of a live database:
```sh
docker exec parts python -c \
"import sqlite3; sqlite3.connect('/data/parts.db').execute(\"VACUUM INTO '/data/backup.db'\")"
docker cp parts:/data/backup.db ./parts-backup-$(date +%F).db
docker exec parts rm /data/backup.db
```
Restore by stopping the container, copying the file back over `/data/parts.db`
and deleting any leftover `-wal`/`-shm` alongside it.
## API
Everything under `/api` is JSON and cookie-authenticated. `GET /healthz` is open.
```
GET /api/parts?q=&category_id=&location_id=&tag=&low_stock=&sort=&limit=&offset=
POST /api/parts
GET /api/parts/{id}
PATCH /api/parts/{id}
DELETE /api/parts/{id}
POST /api/parts/{id}/adjust {delta, reason}
GET /api/parts/{id}/history
GET /api/categories POST /api/categories PATCH|DELETE /api/categories/{id}
GET /api/locations POST /api/locations PATCH|DELETE /api/locations/{id}
GET /api/tags
GET /api/stats
```
The "what could I build with what I'm holding?" idea is meant to arrive as
another consumer of these endpoints — the arbiter on `.8` already has the lane
routing and typed-action machinery for it — rather than as a fork of them.
## Passwords
Open **Settings** (the gear in the header) and use the Password section. It asks
for the current password, takes a new one twice, and signs out every other
device. Until a password has been set in the app, a banner says so.
The only reason to touch a terminal is a forgotten password:
```sh
docker exec -it parts python -m app.admin set-password # prompts, twice
docker exec parts python -m app.admin show-status # where the password comes from
docker exec parts python -m app.admin clear-password # fall back to PARTS_PASSWORD
```
Each of those signs out every session too.
## Hardening notes
Responses carry `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`
and a `Content-Security-Policy` that keeps the page to same-origin scripts and
no framing; HSTS is added when `X-Forwarded-Proto` says the original request was
HTTPS. The session cookie is HttpOnly, SameSite=Lax and Secure, and its
signature is a fixed-width HMAC appended to the payload rather than delimited —
a delimiter byte can occur inside a raw digest, which previously invalidated
about 12% of issued tokens.
Static assets are served under content-hashed URLs (`app.js?v=<sha>`), so
Cloudflare caching them for hours is harmless: a deploy changes the URL. The
bare, unhashed paths are served `no-cache` so nothing can pin stale frontend
code against a newer API.