ab82b5e9a9
Password management moves out of the CLI entirely. The credential is now a salted scrypt hash in the database (so it survives rebuilds, living in the /data volume) rather than an environment variable; PARTS_PASSWORD is demoted to a bootstrap value that stops working the moment a password is set in the UI. Every token carries a session epoch, so changing the password — or "sign out other devices" — invalidates outstanding cookies while keeping the browser that made the change signed in. A banner nags until the handed-over password is replaced. app/admin.py remains for the one case the UI cannot cover, a forgotten password. Audit findings: - Taxonomy update and delete scanned affected parts before taking the write lock, so a concurrent rename could leave the search index matching a name the UI no longer showed. All four routes now lock first; removing the lock again makes the new test fail exactly that way. - History of a missing part returned 200 with an empty list; now 404. - Infinity and NaN passed ge=0 and failed at the database. They are rejected as 422 now, and the validation error handler no longer chokes trying to echo a non-finite value back. Checks go from 134 to 181. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
8.0 KiB
Markdown
183 lines
8.0 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 # 166 checks, in-process
|
|
.venv/bin/python -m tests.test_concurrency # 15 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, and the full password-management flow
|
|
including scrypt hashing, session invalidation and the recovery CLI.
|
|
|
|
`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, and races taxonomy renames against reads to check
|
|
the search index never describes a name the tree no longer has.
|
|
|
|
## 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, so deploying is a pull:
|
|
|
|
```sh
|
|
ssh jay@192.168.50.8
|
|
cd ~/srv/parts && git pull && docker compose up -d --build
|
|
```
|
|
|
|
`.env` is untracked and gitignored, so it survives the pull. `~/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.
|