Manage the password in the app; close remaining audit findings

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>
This commit is contained in:
Jay
2026-08-24 10:31:06 -04:00
parent 7bdf276342
commit ab82b5e9a9
11 changed files with 626 additions and 49 deletions
+1
View File
@@ -7,3 +7,4 @@ __pycache__/
data/
.venv/
.DS_Store
.env.bak-*
+40 -9
View File
@@ -45,26 +45,34 @@ 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
.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, and login throttling.
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.
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` | The single shared password. Required when auth is on. |
| `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). |
@@ -73,9 +81,11 @@ sums to the stored quantity.
| `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.
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
@@ -90,11 +100,16 @@ 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 && sudo docker compose up -d --build
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.
@@ -135,6 +150,22 @@ 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`
+65
View File
@@ -0,0 +1,65 @@
"""Recovery CLI.
Everyday password changes happen in the UI. This exists for the one case the UI
cannot help with — a forgotten password — and for the initial handover.
docker exec -it parts python -m app.admin set-password
docker exec parts python -m app.admin show-status
"""
import getpass
import sys
from . import auth, db
def set_password(argv):
password = argv[0] if argv else None
if password is None:
password = getpass.getpass("New password: ")
if password != getpass.getpass("Repeat: "):
print("Passwords did not match.", file=sys.stderr)
return 1
problem = auth.password_problem(password)
if problem:
print(problem, file=sys.stderr)
return 1
with db.session() as conn:
auth.set_password(conn, password)
print("Password set. All existing sessions have been signed out.")
return 0
def clear_password(_argv):
"""Fall back to the PARTS_PASSWORD environment variable again."""
with db.session() as conn:
conn.execute("DELETE FROM settings WHERE key = ?", (auth.PASSWORD_KEY,))
auth.bump_epoch(conn)
print("Stored password cleared; PARTS_PASSWORD from the environment is live again.")
print("All existing sessions have been signed out.")
return 0
def show_status(_argv):
with db.session() as conn:
stored = auth.stored_hash(conn)
print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var")
print("session epoch :", auth.current_epoch(conn))
print("auth enabled :", auth.auth_enabled())
return 0
COMMANDS = {"set-password": set_password, "clear-password": clear_password, "show-status": show_status}
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] not in COMMANDS:
print("usage: python -m app.admin {" + "|".join(COMMANDS) + "}", file=sys.stderr)
return 2
db.init()
return COMMANDS[argv[0]](argv[1:])
if __name__ == "__main__":
raise SystemExit(main())
+112 -9
View File
@@ -9,10 +9,13 @@ import base64
import hashlib
import hmac
import os
import secrets
import threading
import time
from fastapi import Cookie, HTTPException, Request
from fastapi import Cookie, Depends, HTTPException, Request
from . import db
COOKIE_NAME = "parts_session"
DIGEST_SIZE = hashlib.sha256().digest_size
@@ -46,13 +49,103 @@ def session_days() -> int:
return _env_int("PARTS_SESSION_DAYS", 30)
def check_password(candidate: str) -> bool:
# --- stored password --------------------------------------------------------
#
# The password lives in the database, not the environment, so it can be changed
# from the UI and survives a container rebuild (it is in the /data volume).
# PARTS_PASSWORD is only the bootstrap credential: it works until a password has
# been set through the app, and is ignored from then on — otherwise "changing"
# the password would leave the old one working.
PASSWORD_KEY = "password_hash"
EPOCH_KEY = "session_epoch"
MIN_PASSWORD_LENGTH = 8
_SCRYPT_N = 1 << 14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SCRYPT_MAXMEM = 64 * 1024 * 1024
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
dklen=32, maxmem=_SCRYPT_MAXMEM,
)
return "scrypt${}${}${}${}${}".format(
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P,
base64.b64encode(salt).decode(), base64.b64encode(digest).decode(),
)
def verify_hash(password: str, stored: str) -> bool:
try:
scheme, n, r, pp, salt_b64, digest_b64 = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode(), salt=base64.b64decode(salt_b64),
n=int(n), r=int(r), p=int(pp), dklen=len(base64.b64decode(digest_b64)),
maxmem=_SCRYPT_MAXMEM,
)
return hmac.compare_digest(digest, base64.b64decode(digest_b64))
except Exception:
return False
def stored_hash(conn) -> str | None:
return db.get_setting(conn, PASSWORD_KEY)
def using_bootstrap_password(conn) -> bool:
"""True while the env-provided password is still the live one."""
return stored_hash(conn) is None
def check_password(conn, candidate: str) -> bool:
stored = stored_hash(conn)
if stored:
return verify_hash(candidate, stored)
expected = _env("PARTS_PASSWORD")
if not expected:
return False
return hmac.compare_digest(candidate.encode(), expected.encode())
def set_password(conn, new_password: str):
"""Store a new password and invalidate every outstanding session."""
db.set_setting(conn, PASSWORD_KEY, hash_password(new_password))
bump_epoch(conn)
def password_problem(new_password: str) -> str | None:
if len(new_password) < MIN_PASSWORD_LENGTH:
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
if not new_password.strip():
return "Password must not be blank"
return None
# --- session epoch ----------------------------------------------------------
#
# Baked into every token. Bumping it invalidates all outstanding cookies at
# once, which is what makes "change my password" and "sign out everywhere"
# work without having to rotate PARTS_SECRET by hand on the host.
def current_epoch(conn) -> int:
try:
return int(db.get_setting(conn, EPOCH_KEY, "0") or "0")
except ValueError:
return 0
def bump_epoch(conn) -> int:
epoch = current_epoch(conn) + 1
db.set_setting(conn, EPOCH_KEY, epoch)
return epoch
# --- session tokens ---------------------------------------------------------
#
# The signature is raw HMAC bytes, which can contain any byte value including
@@ -61,14 +154,14 @@ def check_password(candidate: str) -> bool:
# split in the wrong place and failed their own validator. The digest is a fixed
# 32 bytes, so slice by length instead of looking for a delimiter.
def issue_token() -> str:
def issue_token(conn) -> str:
expires = int(time.time()) + session_days() * 86400
payload = str(expires).encode()
payload = f"{expires}:{current_epoch(conn)}".encode()
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(payload + sig).decode()
def token_valid(token: str) -> bool:
def token_valid(token: str, conn=None) -> bool:
try:
raw = base64.urlsafe_b64decode(token.encode())
if len(raw) <= DIGEST_SIZE:
@@ -77,7 +170,12 @@ def token_valid(token: str) -> bool:
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return False
return int(payload) > time.time()
expires_s, _, epoch_s = payload.decode().partition(":")
if int(expires_s) <= time.time():
return False
if conn is not None and int(epoch_s or 0) != current_epoch(conn):
return False
return True
except Exception:
return False
@@ -149,10 +247,15 @@ def is_https(request: Request) -> bool:
return request.url.scheme == "https"
def require_auth(parts_session: str | None = Cookie(default=None)):
"""FastAPI dependency guarding every data route."""
def require_auth(parts_session: str | None = Cookie(default=None), conn=Depends(db.get_db)):
"""FastAPI dependency guarding every data route.
Takes the same request-scoped connection the route uses (FastAPI caches
dependency results per request), so checking the session epoch costs one
indexed lookup rather than a second connection.
"""
if not auth_enabled():
return True
if parts_session and token_valid(parts_session):
if parts_session and token_valid(parts_session, conn):
return True
raise HTTPException(status_code=401, detail="Not authenticated")
+21
View File
@@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS stock_log (
);
CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC);
-- Small key/value store for things that must outlive a container rebuild and
-- be changeable without editing a file on the host: the password hash and the
-- session epoch live here, in the /data volume.
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
name, description, manufacturer, mpn, specs, tags, category, location,
tokenize='unicode61 remove_diacritics 2'
@@ -131,6 +139,19 @@ def init():
_initialised = True
def get_setting(conn, key: str, default: str | None = None) -> str | None:
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else default
def set_setting(conn, key: str, value: str):
conn.execute(
"INSERT INTO settings(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, str(value)),
)
@contextmanager
def session():
conn = connect()
+125 -22
View File
@@ -9,12 +9,14 @@ consumer of the same endpoints rather than a fork of them.
import asyncio
import hashlib
import json
import math
import os
import sqlite3
from contextlib import asynccontextmanager
from typing import Annotated, Any, Literal
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import AfterValidator, BaseModel, Field
@@ -69,10 +71,10 @@ class PartIn(BaseModel):
location_id: int | None = None
manufacturer: Text = Field(default="", max_length=200)
mpn: Text = Field(default="", max_length=120)
quantity: float = Field(default=0, ge=0)
quantity: float = Field(default=0, ge=0, allow_inf_nan=False)
unit: RequiredText = Field(default="pcs", max_length=20)
min_quantity: float | None = Field(default=None, ge=0)
cost_each: float | None = Field(default=None, ge=0)
min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
datasheet_url: Text = Field(default="", max_length=1000)
product_url: Text = Field(default="", max_length=1000)
notes: Text = Field(default="", max_length=4000)
@@ -87,10 +89,10 @@ class PartPatch(BaseModel):
location_id: int | None = None
manufacturer: Text | None = Field(default=None, max_length=200)
mpn: Text | None = Field(default=None, max_length=120)
quantity: float | None = Field(default=None, ge=0)
quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
unit: RequiredText | None = Field(default=None, max_length=20)
min_quantity: float | None = Field(default=None, ge=0)
cost_each: float | None = Field(default=None, ge=0)
min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
datasheet_url: Text | None = Field(default=None, max_length=1000)
product_url: Text | None = Field(default=None, max_length=1000)
notes: Text | None = Field(default=None, max_length=4000)
@@ -109,7 +111,7 @@ PART_COLUMNS = [
class AdjustIn(BaseModel):
delta: float
delta: float = Field(allow_inf_nan=False)
reason: Text = Field(default="", max_length=300)
@@ -126,6 +128,11 @@ class LoginIn(BaseModel):
password: str = Field(max_length=500)
class PasswordChangeIn(BaseModel):
current_password: str = Field(max_length=500)
new_password: str = Field(max_length=500)
# --- helpers ----------------------------------------------------------------
def _specs_for(conn, part_ids: list[int]) -> dict[int, list[dict]]:
@@ -240,6 +247,32 @@ def _check_parent(conn, table: str, node_id: int, parent_id: int | None, label:
raise HTTPException(400, f"A {label} cannot be moved beneath itself")
# --- error handling ---------------------------------------------------------
def _json_safe(value):
"""Make a validation-error payload encodable.
FastAPI echoes the rejected value back in the error detail. When that value
is Infinity or NaN — the very thing being rejected — the JSON encoder
refuses it and a clean 422 turns into a serialisation failure. Stringify
anything the encoder can't represent.
"""
if isinstance(value, float):
return value if math.isfinite(value) else str(value)
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_json_safe(v) for v in value]
if isinstance(value, (str, int, bool)) or value is None:
return value
return str(value)
@app.exception_handler(RequestValidationError)
def validation_error(request: Request, exc: RequestValidationError):
return JSONResponse({"detail": _json_safe(exc.errors())}, status_code=422)
# --- middleware -------------------------------------------------------------
@app.middleware("http")
@@ -268,19 +301,35 @@ async def security_headers(request: Request, call_next):
# --- auth routes ------------------------------------------------------------
def _set_session_cookie(response: Response, request: Request, conn):
response.set_cookie(
auth.COOKIE_NAME,
auth.issue_token(conn),
max_age=auth.session_days() * 86400,
httponly=True,
samesite="lax",
secure=auth.is_https(request),
path="/",
)
@app.get("/api/me")
def me(request: Request):
def me(request: Request, conn=Depends(db.get_db)):
if not auth.auth_enabled():
return {"authenticated": True, "auth_required": False}
return {"authenticated": True, "auth_required": False, "using_bootstrap_password": False}
token = request.cookies.get(auth.COOKIE_NAME)
authenticated = bool(token and auth.token_valid(token, conn))
return {
"authenticated": bool(token and auth.token_valid(token)),
"authenticated": authenticated,
"auth_required": True,
# Surfaced so the UI can nag until the handed-over password is replaced.
"using_bootstrap_password": authenticated and auth.using_bootstrap_password(conn),
"min_password_length": auth.MIN_PASSWORD_LENGTH,
}
@app.post("/api/login")
async def login(body: LoginIn, response: Response, request: Request):
async def login(body: LoginIn, response: Response, request: Request, conn=Depends(db.get_db)):
if not auth.auth_enabled():
return {"authenticated": True, "auth_required": False}
@@ -290,7 +339,7 @@ async def login(body: LoginIn, response: Response, request: Request):
429, "Too many failed attempts", headers={"Retry-After": str(retry_after)}
)
if not auth.check_password(body.password):
if not auth.check_password(conn, body.password):
auth.record_failure()
# Async sleep, so a burst of guesses can't tie up the worker threadpool
# that real requests need.
@@ -298,16 +347,52 @@ async def login(body: LoginIn, response: Response, request: Request):
raise HTTPException(401, "Incorrect password")
auth.clear_failures()
response.set_cookie(
auth.COOKIE_NAME,
auth.issue_token(),
max_age=auth.session_days() * 86400,
httponly=True,
samesite="lax",
secure=auth.is_https(request),
path="/",
)
return {"authenticated": True, "auth_required": True}
_set_session_cookie(response, request, conn)
return {
"authenticated": True,
"auth_required": True,
"using_bootstrap_password": auth.using_bootstrap_password(conn),
}
@app.post("/api/password", dependencies=[Depends(auth.require_auth)])
async def change_password(
body: PasswordChangeIn, response: Response, request: Request, conn=Depends(db.get_db)
):
"""Change the password from inside the app.
Requires the current password even though the caller already holds a valid
session: a borrowed browser tab should not be enough to lock the owner out.
"""
if not auth.auth_enabled():
raise HTTPException(400, "Authentication is disabled, so there is no password to change")
if not auth.check_password(conn, body.current_password):
auth.record_failure()
await asyncio.sleep(0.5)
raise HTTPException(403, "Current password is incorrect")
problem = auth.password_problem(body.new_password)
if problem:
raise HTTPException(422, problem)
if body.new_password == body.current_password:
raise HTTPException(422, "New password must be different from the current one")
auth.clear_failures()
# Bumps the session epoch, so every cookie issued before now stops working.
auth.set_password(conn, body.new_password)
# Re-issue for the caller, so changing the password doesn't log you out of
# the tab you changed it in.
_set_session_cookie(response, request, conn)
return {"changed": True, "other_sessions_signed_out": True}
@app.post("/api/sessions/revoke", dependencies=[Depends(auth.require_auth)])
def revoke_sessions(response: Response, request: Request, conn=Depends(db.get_db)):
"""Sign out every other device, keeping this one signed in."""
auth.bump_epoch(conn)
_set_session_cookie(response, request, conn)
return {"revoked": True}
@app.post("/api/logout")
@@ -503,6 +588,8 @@ def adjust_part(part_id: int, body: AdjustIn, conn=Depends(db.get_db)):
@app.get("/api/parts/{part_id}/history", dependencies=[Depends(auth.require_auth)])
def part_history(part_id: int, conn=Depends(db.get_db), limit: int = Query(default=50, ge=1, le=500)):
if conn.execute("SELECT 1 FROM parts WHERE id = ?", (part_id,)).fetchone() is None:
raise HTTPException(404, "Part not found")
rows = conn.execute(
"SELECT * FROM stock_log WHERE part_id = ? ORDER BY id DESC LIMIT ?", (part_id, limit)
).fetchall()
@@ -578,6 +665,10 @@ def create_category(body: NodeIn, conn=Depends(db.get_db)):
@app.patch("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
# Before the first read: the affected-parts scan and the reindex that
# follows it have to see one consistent tree, or a concurrent write can slip
# between them and leave the search index describing the old taxonomy.
db.begin_immediate(conn)
_require_node(conn, "categories", cat_id, "Category")
_check_parent(conn, "categories", cat_id, body.parent_id, "category")
affected = db.parts_under(conn, "categories", cat_id)
@@ -596,6 +687,10 @@ def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
@app.delete("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
def delete_category(cat_id: int, conn=Depends(db.get_db)):
# Before the first read: the affected-parts scan and the reindex that
# follows it have to see one consistent tree, or a concurrent write can slip
# between them and leave the search index describing the old taxonomy.
db.begin_immediate(conn)
_require_node(conn, "categories", cat_id, "Category")
# Capture the parts first: ON DELETE SET NULL means they survive as
# uncategorised rather than vanish, but their indexed path must be rebuilt.
@@ -626,6 +721,10 @@ def create_location(body: NodeIn, conn=Depends(db.get_db)):
@app.patch("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
# Before the first read: the affected-parts scan and the reindex that
# follows it have to see one consistent tree, or a concurrent write can slip
# between them and leave the search index describing the old taxonomy.
db.begin_immediate(conn)
_require_node(conn, "locations", loc_id, "Location")
_check_parent(conn, "locations", loc_id, body.parent_id, "location")
affected = db.parts_under(conn, "locations", loc_id)
@@ -642,6 +741,10 @@ def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
@app.delete("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
def delete_location(loc_id: int, conn=Depends(db.get_db)):
# Before the first read: the affected-parts scan and the reindex that
# follows it have to see one consistent tree, or a concurrent write can slip
# between them and leave the search index describing the old taxonomy.
db.begin_immediate(conn)
_require_node(conn, "locations", loc_id, "Location")
affected = db.parts_under(conn, "locations", loc_id)
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
+15
View File
@@ -89,6 +89,21 @@ header .brand { font-weight: 700; letter-spacing: -.01em; white-space: nowrap; }
header .search { flex: 1 1 240px; min-width: 160px; }
header .stat { color: var(--dim); font-size: 13px; white-space: nowrap; }
.banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin: 12px 16px -4px;
padding: 10px 14px;
border: 1px solid var(--warn);
border-radius: var(--radius);
background: rgba(240, 168, 72, .10);
color: var(--warn);
font-size: 14px;
}
.layout { display: flex; align-items: flex-start; gap: 18px; padding: 16px; max-width: 1400px; margin: 0 auto; }
aside {
+78 -4
View File
@@ -23,6 +23,8 @@ const state = {
locations: [],
tags: [],
authRequired: true,
usingBootstrap: false,
minPasswordLength: 8,
};
// --- plumbing ---------------------------------------------------------------
@@ -70,6 +72,7 @@ function showApp() {
$("#login").classList.add("hidden");
$("#app").classList.remove("hidden");
$("#logout-btn").classList.toggle("hidden", !state.authRequired);
$("#bootstrap-banner").classList.toggle("hidden", !state.usingBootstrap);
}
$("#login-form").addEventListener("submit", async (e) => {
@@ -77,8 +80,9 @@ $("#login-form").addEventListener("submit", async (e) => {
const err = $("#login-error");
err.classList.add("hidden");
try {
await api("/api/login", { method: "POST", body: { password: $("#password").value } });
const session = await api("/api/login", { method: "POST", body: { password: $("#password").value } });
$("#password").value = "";
state.usingBootstrap = !!session.using_bootstrap_password;
showApp();
await boot();
} catch (ex) {
@@ -493,13 +497,36 @@ function openPartModal(part) {
// --- manage categories / locations ------------------------------------------
function openManageModal() {
function openManageModal(focusPassword = false) {
const overlay = openModal(`
<header>
<strong>Categories &amp; locations</strong>
<strong>Settings</strong>
<button class="ghost small" data-close>✕</button>
</header>
<div class="content">
${state.authRequired ? `
<section id="password-section" style="margin-bottom:24px">
<label>Password</label>
${state.usingBootstrap
? '<p style="color:var(--warn);font-size:13px;margin:0 0 10px">You\'re still using the password you were handed. Setting your own here also signs out every other device.</p>'
: '<p style="color:var(--dim);font-size:13px;margin:0 0 10px">Changing your password signs out every other device.</p>'}
<div class="grid">
<div class="full">
<input id="pw-current" type="password" autocomplete="current-password" placeholder="Current password">
</div>
<div>
<input id="pw-new" type="password" autocomplete="new-password" placeholder="New password">
</div>
<div>
<input id="pw-confirm" type="password" autocomplete="new-password" placeholder="Repeat new password">
</div>
</div>
<div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
<button class="small primary" id="pw-save">Change password</button>
<button class="small ghost" id="pw-revoke" title="Keeps this device signed in">Sign out other devices</button>
<span id="pw-msg" style="font-size:13px"></span>
</div>
</section>` : ""}
<div class="grid">
<div>
<label>New category</label>
@@ -530,6 +557,50 @@ function openManageModal() {
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
const pwSave = overlay.querySelector("#pw-save");
if (pwSave) {
const msg = overlay.querySelector("#pw-msg");
const say = (text, bad) => {
msg.textContent = text;
msg.style.color = bad ? "var(--bad)" : "var(--good)";
};
pwSave.onclick = async () => {
const current = overlay.querySelector("#pw-current").value;
const next = overlay.querySelector("#pw-new").value;
const confirmed = overlay.querySelector("#pw-confirm").value;
if (!current) return say("Enter your current password", true);
if (next !== confirmed) return say("New passwords don't match", true);
if (next.length < state.minPasswordLength)
return say(`At least ${state.minPasswordLength} characters`, true);
pwSave.disabled = true;
try {
await api("/api/password", {
method: "POST",
body: { current_password: current, new_password: next },
});
overlay.querySelectorAll("#pw-current, #pw-new, #pw-confirm").forEach((i) => (i.value = ""));
state.usingBootstrap = false;
$("#bootstrap-banner").classList.add("hidden");
say("Password changed. Other devices signed out.", false);
toast("Password changed");
} catch (ex) {
say(ex.message, true);
} finally {
pwSave.disabled = false;
}
};
overlay.querySelector("#pw-revoke").onclick = async () => {
if (!confirm("Sign out every other device? This one stays signed in.")) return;
try {
await api("/api/sessions/revoke", { method: "POST" });
say("Other devices signed out.", false);
} catch (ex) {
say(ex.message, true);
}
};
if (focusPassword) setTimeout(() => overlay.querySelector("#pw-current").focus(), 50);
}
const renderRows = () => {
const draw = (items, box, kind) => {
box.innerHTML = "";
@@ -645,7 +716,8 @@ $("#sort").addEventListener("change", (e) => {
$("#filters-btn").onclick = () => setSidebar(!$("#sidebar").classList.contains("open"));
$("#add-btn").onclick = () => openPartModal(null);
$("#manage-btn").onclick = openManageModal;
$("#manage-btn").onclick = () => openManageModal(false);
$("#banner-change").onclick = () => openManageModal(true);
$("#more-btn").onclick = () => search(true);
$("#filter-all").onclick = () => {
@@ -669,6 +741,8 @@ async function boot() {
try {
const me = await api("/api/me");
state.authRequired = me.auth_required;
state.usingBootstrap = !!me.using_bootstrap_password;
state.minPasswordLength = me.min_password_length || state.minPasswordLength;
if (!me.authenticated) return showLogin();
showApp();
await boot();
+6 -1
View File
@@ -27,10 +27,15 @@
<span class="stat" id="stats"></span>
<button class="ghost small mobile-only" id="filters-btn">Filters</button>
<button class="primary" id="add-btn">+ Add</button>
<button class="ghost small" id="manage-btn" title="Categories &amp; locations"></button>
<button class="ghost small" id="manage-btn" title="Settings"></button>
<button class="ghost small hidden" id="logout-btn" title="Log out"></button>
</header>
<div id="bootstrap-banner" class="banner hidden">
<span>You're still using the password that was handed to you. Set your own.</span>
<button class="small primary" id="banner-change">Change password</button>
</div>
<div class="layout">
<aside id="sidebar">
<h3>Filters</h3>
+120 -4
View File
@@ -27,8 +27,8 @@ with TestClient(app) as client:
import app.auth as _auth
import base64 as _b64, hashlib as _hl, hmac as _hm
def _issue_at(exp):
payload = str(exp).encode()
def _issue_at(exp, epoch=0):
payload = f"{exp}:{epoch}".encode()
return _b64.urlsafe_b64encode(
payload + _hm.new(_auth._secret(), payload, _hl.sha256).digest()).decode()
@@ -198,13 +198,52 @@ with TestClient(app) as client:
check("deleted part is gone", client.get(f"/api/parts/{screw['id']}").status_code == 404)
check("deleted part leaves the index", "M3x8 socket cap screw" not in ids("M3x8"))
check("missing part 404s", client.get("/api/parts/999999").status_code == 404)
check("history of a missing part 404s",
client.get("/api/parts/999999/history").status_code == 404)
# httpx refuses to serialise inf/nan, so these go as raw bodies — which is
# exactly how a real client would smuggle them in: Python's json.loads
# accepts the bare `Infinity` and `NaN` tokens.
JSONH = {"Content-Type": "application/json"}
def raw_post(path, body):
return client.post(path, content=body, headers=JSONH)
def raw_patch(path, body):
return client.patch(path, content=body, headers=JSONH)
check("Infinity quantity rejected",
raw_post("/api/parts", '{"name":"inf","quantity":Infinity}').status_code == 422)
check("-Infinity quantity rejected",
raw_post("/api/parts", '{"name":"ninf","quantity":-Infinity}').status_code == 422)
check("NaN quantity rejected",
raw_post("/api/parts", '{"name":"nan","quantity":NaN}').status_code == 422)
check("Infinity cost rejected",
raw_post("/api/parts", '{"name":"inf2","cost_each":Infinity}').status_code == 422)
check("Infinity min_quantity rejected",
raw_post("/api/parts", '{"name":"inf3","min_quantity":Infinity}').status_code == 422)
_infp = client.post("/api/parts", json={"name": "inf target", "quantity": 5}).json()["id"]
check("Infinity adjustment delta rejected",
raw_post(f"/api/parts/{_infp}/adjust", '{"delta":Infinity}').status_code == 422)
check("NaN adjustment delta rejected",
raw_post(f"/api/parts/{_infp}/adjust", '{"delta":NaN}').status_code == 422)
check("Infinity PATCH quantity rejected",
raw_patch(f"/api/parts/{_infp}", '{"quantity":Infinity}').status_code == 422)
check("the part is untouched after a rejected Infinity",
client.get(f"/api/parts/{_infp}").json()["quantity"] == 5)
check("adjusting a missing part 404s",
client.post("/api/parts/999999/adjust", json={"delta": 1}).status_code == 404)
# --- stats & validation ---
# Derived from the listing rather than a magic number, so adding a fixture
# part earlier in the file can't silently invalidate it.
s = client.get("/api/stats").json()
check("stats counts remaining parts", s["parts"] == 2, str(s))
check("stats counts low stock", s["low_stock"] == 2, str(s))
listed = client.get("/api/parts", params={"limit": 1000}).json()
check("stats part count matches the listing", s["parts"] == listed["total"], str(s))
check("stats low-stock count matches the listing",
s["low_stock"] == sum(1 for p in listed["items"] if p["low_stock"]), str(s))
check("stats value matches the listing",
s["estimated_value"] == round(sum(p["quantity"] * (p["cost_each"] or 0)
for p in listed["items"]), 2), str(s))
check("whitespace-only name rejected",
client.post("/api/parts", json={"name": " "}).status_code == 422)
check("negative quantity rejected",
@@ -351,6 +390,83 @@ with TestClient(app) as client:
client.post("/api/login", json={"password": "hunter2"}).status_code == 200)
check("a successful login resets the failure budget", auth_mod.login_retry_after() == 0)
# --- password management through the API ---
check("bootstrap password is flagged", client.get("/api/me").json()["using_bootstrap_password"] is True)
check("min length is advertised", client.get("/api/me").json()["min_password_length"] == 8)
check("wrong current password is refused",
client.post("/api/password",
json={"current_password": "nope", "new_password": "brand-new-secret"}).status_code == 403)
auth_mod.clear_failures()
check("short new password rejected",
client.post("/api/password",
json={"current_password": "hunter2", "new_password": "short"}).status_code == 422)
check("reusing the current password rejected",
client.post("/api/password",
json={"current_password": "hunter2", "new_password": "hunter2"}).status_code == 422)
check("still logged in after failed attempts", client.get("/api/parts").status_code == 200)
# A second, independent session that should be cut off by the change.
other = TestClient(app)
other.post("/api/login", json={"password": "hunter2"})
check("second session works before the change", other.get("/api/parts").status_code == 200)
r = client.post("/api/password",
json={"current_password": "hunter2", "new_password": "correct-horse-battery"})
check("password change succeeds", r.status_code == 200, r.text)
check("change reports other sessions signed out", r.json()["other_sessions_signed_out"] is True)
check("the changing session stays signed in", client.get("/api/parts").status_code == 200)
check("other sessions are signed out", other.get("/api/parts").status_code == 401)
check("bootstrap flag clears", client.get("/api/me").json()["using_bootstrap_password"] is False)
fresh = TestClient(app)
check("the old password no longer works",
fresh.post("/api/login", json={"password": "hunter2"}).status_code == 401)
auth_mod.clear_failures()
check("the bootstrap env password is ignored once one is set",
fresh.post("/api/login", json={"password": os.environ["PARTS_PASSWORD"]}).status_code == 401)
auth_mod.clear_failures()
check("the new password works",
fresh.post("/api/login", json={"password": "correct-horse-battery"}).status_code == 200)
check("the new session can read data", fresh.get("/api/parts").status_code == 200)
# --- sign out other devices, without signing out this one ---
third = TestClient(app)
third.post("/api/login", json={"password": "correct-horse-battery"})
check("third session works", third.get("/api/parts").status_code == 200)
check("revoke succeeds", client.post("/api/sessions/revoke").status_code == 200)
check("revoke keeps the calling session", client.get("/api/parts").status_code == 200)
check("revoke cuts off the other session", third.get("/api/parts").status_code == 401)
# --- the hash itself ---
import app.db as _db
with _db.session() as _conn:
stored = auth_mod.stored_hash(_conn)
check("password is stored hashed, not in the clear",
stored is not None and stored.startswith("scrypt$") and "correct-horse-battery" not in stored)
check("the stored hash verifies", auth_mod.verify_hash("correct-horse-battery", stored))
check("the stored hash rejects a wrong password", not auth_mod.verify_hash("wrong", stored))
check("two hashes of the same password differ (salted)",
auth_mod.hash_password("same") != auth_mod.hash_password("same"))
# --- recovery CLI ---
import app.admin as _admin
check("admin set-password works", _admin.main(["set-password", "cli-set-password"]) == 0)
cli = TestClient(app)
check("CLI-set password logs in",
cli.post("/api/login", json={"password": "cli-set-password"}).status_code == 200)
auth_mod.clear_failures()
check("CLI change signed out the API session", client.get("/api/parts").status_code == 401)
check("admin rejects a short password", _admin.main(["set-password", "abc"]) == 1)
check("admin show-status works", _admin.main(["show-status"]) == 0)
check("admin rejects an unknown command", _admin.main(["nonsense"]) == 2)
check("admin clear-password works", _admin.main(["clear-password"]) == 0)
back = TestClient(app)
check("clearing restores the env password",
back.post("/api/login", json={"password": os.environ["PARTS_PASSWORD"]}).status_code == 200)
check("bootstrap flag returns after clearing",
back.get("/api/me").json()["using_bootstrap_password"] is True)
print()
if failures:
print(f"{len(failures)} FAILED: " + "; ".join(failures))
+43
View File
@@ -107,6 +107,49 @@ def main():
check("PATCH log's final quantity_after matches stored",
h3[0]["quantity_after"] == stored, f"log={h3[0]['quantity_after']} stored={stored}")
# --- taxonomy renames must leave the index agreeing with the tree ---
# The scan for affected parts and the reindex that follows have to see
# one consistent tree. Without the write lock a concurrent rename slips
# between them, and search keeps matching a name the UI no longer shows.
# Names are chosen so no token is a prefix of another: search uses prefix
# matching, so "Taxo 1" would legitimately match "Taxo 19" and the test
# would report a race that isn't there.
WORDS = ["alfa", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
"hotel", "india", "juliett", "kilo", "lima", "mike", "november",
"oscar", "papa", "quebec", "romeo", "sierra", "tango"]
cat = req("/api/categories", "POST", {"name": "Taxo zulu"})["id"]
req("/api/parts", "POST", {"name": "taxo widget", "category_id": cat, "quantity": 1})
names = [f"Taxo {w}" for w in WORDS]
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(lambda n: req(f"/api/categories/{cat}", "PATCH", {"name": n}), names))
final = [c["name"] for c in req("/api/categories")["items"] if c["id"] == cat][0]
hits_final = req(f"/api/parts?q={final.replace(' ', '+')}")["total"]
check("search matches the category's final name", hits_final == 1, f"{final} -> {hits_final}")
stale = [n for n in ["Taxo zulu"] + names if n != final
and req(f"/api/parts?q={n.replace(' ', '+')}")["total"] > 0]
check("no superseded category name still matches", not stale, f"stale: {stale}")
# Same race with a location, and with parts being created concurrently.
loc = req("/api/locations", "POST", {"name": "Loc zulu"})["id"]
loc_names = [f"Loc {w}" for w in WORDS[:15]]
def rename_or_add(i):
if i % 3 == 0:
req("/api/parts", "POST", {"name": f"loc widget {i}", "location_id": loc, "quantity": 1})
else:
req(f"/api/locations/{loc}", "PATCH", {"name": loc_names[i % len(loc_names)]})
with ThreadPoolExecutor(max_workers=15) as ex:
list(ex.map(rename_or_add, range(15)))
final_loc = [l["name"] for l in req("/api/locations")["items"] if l["id"] == loc][0]
in_loc = req(f"/api/parts?location_id={loc}&limit=100")["total"]
by_name = req(f"/api/parts?q={final_loc.replace(' ', '+')}&limit=100")["total"]
check("every part in the location is indexed under its final name",
by_name == in_loc, f"filter={in_loc} search={by_name}")
stale_loc = [n for n in ["Loc zulu"] + loc_names if n != final_loc
and req(f"/api/parts?q={n.replace(' ', '+')}&limit=100")["total"] > 0]
check("no superseded location name still matches", not stale_loc, f"stale: {stale_loc}")
# --- concurrent creates don't collide ---
with ThreadPoolExecutor(max_workers=25) as ex:
ids = list(ex.map(