7bdf276342
Blocking: - Stock adjustments read-modified-wrote outside a transaction, so concurrent changes silently overwrote each other. Verified: 50 concurrent -1 requests moved a quantity of 100 to 99 rather than 50, while all 51 history rows were written, leaving the ledger disagreeing with the stock. Both adjust and patch now take SQLite's write lock up front. - Session tokens joined payload and HMAC with "." and split on the last occurrence. A raw digest can contain that byte, so ~12% of issued tokens failed their own validator (measured 121/1000). The digest is fixed width; slice by length instead. - starlette 0.41.3 and python-multipart 0.0.20 carried 15 advisories between them, including a FileResponse Range-header DoS reachable through the public static assets. Pinned starlette explicitly; python-multipart was unused. Also: - PATCH quantity now writes history, and a floored adjustment logs the delta it applied rather than the one requested, so the log sums to the stock. - Renaming or deleting a category or location rebuilds the search index for every part beneath it; full paths are indexed, so "Workshop" finds Bin A3. - Blank names, negative quantities and explicit nulls on NOT NULL columns are 422s instead of silent writes or 500s; taxonomy routes 404 on missing ids, 409 on duplicates, and reject indirect parent cycles. - Security headers, HSTS behind X-Forwarded-Proto, Secure cookie via the forwarded scheme, content-hashed asset URLs so Cloudflare cannot serve stale frontend code, and a global failed-login throttle. - README documents a WAL-safe backup; cp of parts.db alone could lose commits. Checks go from 68 to 134, including a suite that runs against a real server because lost updates only appear when requests genuinely overlap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
152 lines
6.4 KiB
Markdown
152 lines
6.4 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 # 122 checks, in-process
|
|
.venv/bin/python -m tests.test_concurrency # 12 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, and login throttling.
|
|
|
|
`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.
|
|
|
|
## Configuration
|
|
|
|
| Variable | Meaning |
|
|
|---|---|
|
|
| `PARTS_PASSWORD` | The single shared password. Required when auth is on. |
|
|
| `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. |
|
|
|
|
Rotating `PARTS_PASSWORD` alone does **not** invalidate existing sessions when
|
|
`PARTS_SECRET` is set independently — the cookie is signed with the secret.
|
|
Rotate both to revoke every outstanding cookie.
|
|
|
|
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`.
|
|
|
|
```sh
|
|
ssh jay@192.168.50.8
|
|
cd ~/srv/parts && sudo docker compose up -d --build
|
|
```
|
|
|
|
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.
|
|
|
|
## 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.
|