154 auth tests, up from 101. The new files cover API keys, user administration
and the audit log; two gaps phase 1 recorded are also closed.
test_levels.py said its read-versus-manage coverage was an approximation,
because the anonymous reader was the only low-level principal that could be
built without a command line. POST /auth/user changes that, so there is now a
genuine signed-in read user and a genuine edit user. That distinction matters:
a refusal for a real user can only be about its level, where an anonymous
caller failing the same request proves less, since allow_anonymous could have
been what stopped it. The edit level had never been exercised at all, and it is
the one that carries PUT /spool/{id}/use -- Moonraker's hot path, deliberately
below manage so a printer can record usage without being able to delete.
The route table in test_unauthenticated.py rejected the whole branch until all
thirteen new routes were listed, which is what it is for. It grew from 40
entries to 53.
The a11y audit could not see the auth screens: it runs against an
authentication-disabled build where sign-in never renders, so auditing it there
would have measured the library page twice and reported a pass. a11y-auth.spec
fixes that without a second Playwright config -- it asks the server what it is
and skips itself when auth is off, claims an unclaimed instance, and creates
its own must-change-password account to reach the forced-change screen. The
measurement code moved to audit.ts so both specs share it rather than one
copying the other.
Running it found ten controls under the advisory 44px tap target across the new
screens. Fixed rather than waived, because the phase 1 auth components were
built to 44px on purpose (see PasswordInput) and these belong with them, even
though settings and locations already sit at 30-34px. That left the native
18px checkbox, which LoginScreen has shipped since phase 1: the input is the
tap target, so a 44px label around it does not help. Checkbox.svelte draws an
18px box inside a 44px input with appearance:none, and both call sites use it.
Auth surfaces now report 0 axe violations and 0 undersized targets across 7
surfaces.
Verified: 154 auth tests pass with auth on; 372 regression tests pass with auth
off and no file under tests_integration/tests edited, which is the evidence
that phase 2 stayed additive.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
The screens for what the previous three commits made possible, plus the one
phase 1 flow that was never finished.
/account is the signed-in user's own page: profile, password, API keys. The
key manager's whole job is the moment a key is created. The server hashes the
secret and cannot produce it again, so the reveal panel is a component with no
timer rather than a toast — a notification that expires while the user is
opening their password manager loses the only copy. The same component shows
generated passwords on /users, for the same reason.
/users is the administrator's list. Every control the server would refuse for a
specific account — the owner's level, an administrator's own active toggle — is
disabled with the reason shown next to it, instead of being left enabled to
fail. The rules mirror _protected() in the API; the server stays the authority
and the client only avoids offering what it knows will be rejected.
/audit reads the log, and nothing else. There is no way to write or delete an
entry because the server offers none. Event names are translated for display
and fall back to the raw identifier, so a server that records an event this
build has never heard of still shows the row rather than a blank.
The forced password change is what makes must_change_password mean something.
Phase 1 could set the flag from the CLI and clear it through POST
/auth/password, but nothing ever made the user go through it, so it was
advisory. It now renders ahead of the app, with no way past but changing the
password or signing out: a temporary password set by an administrator is one
the administrator knows.
The three tabs are hidden rather than disabled when they do not apply. With
auth off there is no account for /account to describe and no users for /users
to list, so those pages would be empty rather than merely restricted; each also
guards itself, since all three are reachable by typing the URL. Verified in a
browser: with auth off the top bar is the original four tabs and nothing else
changed.
AuthUserInfo grew an is_active field. The list has to show whether an account
is disabled, and until now the only way to find out was that signing in failed.
Toggle grew a disabled prop and a type="button". It had neither, and inside a
form the missing type would have made every toggle a submit button.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
Phase 1 could only create the owner. Every other account had to be made with
python -m spoolman.cli, which meant shell access on the server was a
prerequisite for having a second user at all. require_admin() has existed since
phase 1 with no callers; these are its first.
Two accounts are shielded from demotion, disabling and deletion, and both
protections exist to stop an instance being administered into a state nobody
can recover from through the interface:
The owner is untouchable by anyone, themselves included. It is the account that
can always fix the others, so it must not be possible to remove the last thing
that can grant rights back.
An administrator cannot demote, disable or delete themselves. Somebody else
with the rights has to do it, which guarantees a second pair of hands still
exists afterwards.
Neither is a security boundary -- both are recoverable with the CLI, which
remains the escape hatch when no working account is left. They are there to
make the obvious mistake impossible, not to withstand an attacker who is
already an administrator.
The protections are consulted only on the changes that actually take something
away. Granting admin is unrestricted, and re-sending an unchanged account from
a form is not refused just because the form included the owner's own level --
_collect_changes compares against the stored row first, so a no-op is a no-op.
Passwords may be supplied or generated. A generated one is returned exactly
once, in the response that created it, which mirrors what the CLI already does
and avoids an administrator inventing "Password123" for somebody. Any reset
ends every session that account holds: whoever knew the old password may be
precisely who the reset is defending against, and their cookie would otherwise
outlive the credential it was obtained with. Disabling an account does the
same, so the state in the browser agrees with the state on disk.
Deleting a user takes its sessions and API keys with it and detaches its audit
entries. The update path goes through a field whitelist in the data layer, so a
mistake in the API layer cannot reach password_hash, is_owner, or the lockout
counters.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
Phase 1 created auth_api_key and left PrincipalKind.APIKEY unproduced. This
fills both in, so a printer or a script has a credential that is not somebody's
password.
A key is presented as spoolman_<prefix>.<secret>. The marker exists so a leaked
key is recognisable in a log or a paste, and the separated prefix is what makes
authentication one indexed lookup instead of a scan that verifies every stored
hash in turn. The separator is a full stop because both halves are url-safe
base64, whose alphabet already contains '-' and '_'. Secrets are hashed with
the same plain SHA-256 as session tokens and for the same reason: 256 bits from
a CSPRNG has no dictionary to attack, so scrypt on the hot path of every
printer poll would cost 50 ms per request to buy nothing.
Three restrictions are the substance of this, and each has a failure mode it
exists to prevent:
A key's level is capped against its owner's *current* level on every request
rather than baked in at creation, so demoting a user weakens their outstanding
keys immediately instead of leaving credentials in the wild that outrank the
account they belong to. Verified against a running server: a key issued at
manage drops to read the moment its owner is demoted, both in enforcement and
in what the listing reports.
A key never carries administrative rights, however senior its owner, so
everything behind require_admin needs a session.
require_user now refuses keys outright, which is what gates the key endpoints
themselves. A credential that can mint another credential can never really be
revoked -- whoever held a leaked key would issue themselves a fresh one ahead
of the withdrawal.
Keys skip CSRF, as cookies.py anticipated in phase 1. The check in
require_level was already conditioned on PrincipalKind.USER, so this needed no
code change; what it needed was the comment saying why a header that a
cross-site page cannot set is not an ambient credential and has nothing to
defend against.
/metrics accepts a key in addition to SPOOLMAN_METRICS_TOKEN rather than
instead of it. The shared token needs no database and no account, which is what
an operator scraping their own instance wants and what keeps working when the
database is down; a key is attributable and revocable through the interface,
which is what you want when the scraper belongs to somebody else. Only keys are
honoured there, never session cookies, so a signed-in browser tab does not
quietly make the endpoint readable.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
Phase 1 created auth_audit_log and deliberately left spoolman/auth/audit.py
unwritten, because an empty stub fails the docstring lint. This writes it.
What is recorded is every sign-in attempt plus anything that alters
permissions. Ordinary data changes are deliberately excluded: creating a spool
is already visible in the spool, and mixing those in would bury the handful of
security-relevant events under thousands of routine ones, which is how audit
logs stop being read.
Three properties are load-bearing:
Writing never breaks the operation it describes. record() swallows and logs
its own failures, and uses its own database session rather than the caller's.
Login attempts have already committed by the time they are audited, several
call sites hold no session at all, and sharing one would let an audit write
roll back the thing it was recording.
The response and the log disagree on purpose. A failed sign-in tells the
caller only "incorrect username or password" -- distinguishing the cases would
let anyone enumerate accounts -- while the audit entry records which case it
was. Its readers are administrators who already know which accounts exist, and
"somebody is trying to sign in to the account you disabled" is exactly what a
log is for.
Entries outlive their subjects. Deleting a user detaches their entries rather
than cascading, since a log that erases what an account did the moment the
account is removed is not evidence of anything. The username survives in the
target column, so the trail stays readable.
Retention is a database setting rather than an environment variable: it is a
policy an administrator revises, not a deployment parameter, and changing it
should not need a restart. The nightly sweep is only scheduled when auth is
enabled, so existing instances do not grow a job that can never do anything.
It runs half an hour after the backup so the two do not contend for SQLite's
single write lock.
Reading the log needs admin. It holds addresses, user agents and the list of
accounts, so serving it at 'read' would hand an anonymous browser a map of the
instance's users.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
101 tests covering the authentication system, run against a server with
SPOOLMAN_AUTH_ENABLED as a separate suite from the regression sweep.
Deliberately one SQLite variant rather than a fifth pass of the existing
four-database matrix. Nothing in spoolman/auth is database-specific, so
re-running the whole regression suite with auth on would quadruple the CI
bill to re-prove something none of those tests are about.
The tests live in tests_integration/tests_auth, a sibling of the regression
suite rather than a subdirectory of it, and the compose file mounts that
directory at the path the tester image's fixed entrypoint collects from. So
the auth suite runs exactly when asked and the regression runs can never
pick it up -- no entrypoint override, no collection guard, no way for the
two to interfere.
"sqlite-auth" is accepted by run.py but is not in the default target list,
so `poe itest` keeps meaning what it meant and `poe itest sqlite-auth` is
opt-in. CI gets its own small job for it, and the release gate now depends
on that job alongside the others.
Coverage:
test_unauthenticated every non-public route answers 401 without a
credential -- the black-box counterpart to the
import-time coverage assertion, and the test most
likely to catch a future route added without a gate
test_setup first claim succeeds, second is refused, username
stored lowercased
test_login success, wrong password, unknown user returning an
identical message, lockout with Retry-After, and
remember-me controlling cookie Max-Age
test_csrf cookie-authenticated writes need the header
test_levels the owner reaches read, edit and manage routes
test_anonymous_read the setting opens reads without opening writes, and
leaves the bulk exports closed
test_websocket rejection arrives as close code 4401, not as a failed
handshake -- the regression test for the uvicorn
behaviour that dictated the whole websocket design
test_password change requires the current password and takes effect
One test asserted that using 10 g leaves used_weight at 10. It is a running
total, and the spool was created with remaining_weight below the filament's
net weight, so it already stood at 500. Rewritten to assert the delta.
Verified by running both suites against a live server: 101 auth tests pass
with auth on, and the existing 372 regression tests still pass with auth off
and no test file edited.
Capability gating across the interface. This is cosmetic — every one of these
operations is enforced server-side regardless — but showing someone a button
that always fails is a worse experience than not showing it.
All of it keys off the auth store's getters, which answer true whenever the
server reports auth disabled. So with the default configuration nothing here
changes: the add button, the settings controls and every inspector field
render exactly as before.
TopBar Add-spool controls need manage. The scanner stays, since it
only navigates to a spool. Adds a sign-out control and the
signed-in name, which render only when auth is actually on.
Layout AddSpoolModal is not mounted below manage.
Settings Every control writes through POST /setting/{key}, which is
gated at manage, so the section block takes `inert` and a
read-only notice appears. One attribute removes the whole
subtree from interaction and from the accessibility tree,
which beats threading a disabled prop through a dozen
components that were never built to take one.
Inspectors 23 controls across the three inspectors go read-only below
edit: EditableField (new disabled prop, which also drops the
dashed underline so it stops inviting an edit), NumberInput,
Combobox and ExtraFieldsSection via their existing props, and
ColorEditor via an inert wrapper since it has none.
Two things the gating pass got wrong and are fixed here rather than left:
the spool adjust field already had its own disabled state, so the two are
combined instead of duplicated; and the filament extra-fields block inside
the spool inspector was already read-only by design, since those fields
belong to the parent filament.
Verified: svelte-check reports 0 errors and 0 warnings across 1322 files,
eslint clean, prettier clean.
Wires the client to the auth API and adds the two screens that let someone
sign in or claim an unclaimed instance.
The load-bearing property is that when the server reports authentication is
disabled, the store answers true to every capability getter. No call site has
to know whether auth is on, and the default configuration renders exactly as
it did before any of this existed.
http.ts
All six verb helpers now send credentials, and the four that carry a body
attach the CSRF token from the readable companion cookie. ensureOk routes
401 to a sign-out and 403 to a toast, except under /auth/ — a rejected
sign-in is an answer, not a sign-out, and routing it globally would make a
typo'd password look like an expired session.
The handlers are registered by the store rather than imported here, because
the store already imports this module and the reverse would be a cycle.
search.ts
Moved onto getJson. It was the only hand-rolled fetch left in the app, so
with auth on it would have sent no cookie and silently returned nothing.
Its one caller already guards with isAbortError; the thrown type widens
from Error to HttpError, which carries status.
live.ts
onclose ignored the close code entirely, so an auth rejection would have
been retried on the backoff loop forever against a server that can never
accept it. 4401 and 4403 now stop the socket and hand off to the store.
Subscribers are kept, so the new rearm() brings every view back after
signing in without re-subscribing — deliberately unlike teardown(), which
drops the map entry. This only works because the backend accepts the
handshake before closing; a pre-accept close never delivers its code.
+layout.svelte
The data effect is split. Live-sync no longer starts until the user can
actually read, since otherwise it opens sockets the server closes on sight.
Nothing renders at all until auth state is known, because showing the app
and then replacing it flashes an interface at someone who cannot use it.
Auth screens swap in place of the shell rather than redirecting to a route,
which keeps the deep link the user arrived on.
Screens are components rather than routes for that reason. They are the
first real form in this codebase — everything else is change-on-blur
auto-save — so PasswordInput, the checkbox and the submit button are new.
They use a real <form> for Enter-to-submit and password-manager support,
carry proper autocomplete attributes, and keep every control at the 44px tap
target the mobile a11y audit enforces.
36 auth.* strings added to the English locale only; every other locale
belongs to Weblate.
Also exempts S105/S106 in tests. Those rules flag string literals that look
like passwords, and authentication tests have to name the credentials they
sign in with; the list already exempts S101 for the same reason.
Verified: svelte-check reports 0 errors and 0 warnings across 1321 files,
eslint is clean, prettier is clean on all tracked source, and the production
build succeeds.
Adds `python -m spoolman.cli`, the way back in when nobody can sign in: a
forgotten owner password, a disabled account, an owner who has left. This is
why the design has no per-user recovery codes.
docker exec -it spoolman python -m spoolman.cli auth status
Commands: auth status; user list/create/reset-password/set-level/enable/
disable; session revoke-all. Generated passwords are printed once.
Module form rather than a console script, because the Docker image runs
Spoolman from source and there is no installed distribution to hang an entry
point on. argparse rather than a dependency: eight subcommands do not
justify adding click or typer to the runtime image.
It never runs migrations. If the auth tables are missing it says so and
exits 2, because the fix is to start the server once and let it migrate --
having two things that can migrate the schema is how databases get hurt.
It deliberately works while SPOOLMAN_AUTH_ENABLED is unset, since the
intended first run is to create an owner and only then turn auth on.
Fixes a bug found by running it: "user create <name> --owner" produced an
owner at level read, because the --level default won over the flag. An owner
who cannot change anything is useless and would have sent someone straight
back to the CLI. --admin and --owner now imply manage, and an explicit lower
--level is reported rather than silently ignored.
Adds database.get_engine(), since the schema check needs the engine and the
Database instance is module-private.
Verified against a real database: refuses to run before migrations (exit 2);
reports an unclaimed instance; creates owner, edit and admin accounts with
the right levels; refuses a second owner and a duplicate username; refuses
to disable the owner; reset-password ends existing sessions and sets
must_change_password. Most importantly, end to end: a password generated by
the CLI signs in through the API, arrives with must_change_password set, and
carries the owner's manage level for both reads and writes.
/metrics
Stays public while authentication is off, so existing Prometheus scrape
configs keep working untouched. With authentication on it wants a shared
token, presented as "Authorization: Bearer <token>" or "X-Api-Key:
<token>", compared in constant time. SPOOLMAN_METRICS_PUBLIC opts back
out.
A shared token rather than a user session because a scraper is not a
browser: it cannot sign in, and a static bearer is what scrape configs
express natively. API keys arrive in phase 2 and will be accepted here too.
With auth on and no token set the endpoint fails closed, and startup warns
so that is not discovered from a silent gap in a dashboard.
CORS
Debug mode set allow_origins=["*"] together with allow_credentials=True.
Starlette emits the wildcard verbatim, and browsers reject it outright for
any request carrying credentials -- so the moment the client starts sending
a session cookie, every cross-origin request from the Vite dev server on
5174 to the backend on 8000 would fail. Nothing noticed while the API was
anonymous. Debug mode now reflects the requesting Origin instead, which is
what the wildcard was already trying to express, and adds Vary: Origin.
The branch order is unchanged, so no existing deployment behaves
differently.
Startup
Logs how many routes are gated and at which levels when auth is on, and
warns about two combinations that would otherwise fail confusingly: the
legacy React client together with authentication, since it has no sign-in
screen and no cookie or CSRF handling and cannot be made to work; and
metrics enabled with neither a token nor the public flag.
Verified: metrics allow/deny is correct across all four configurations
(auth off, auth on without a token, auth on with a token via both header
forms including a wrong one, and the public escape hatch); debug-mode CORS
returns the caller's origin rather than "*" with credentials allowed.
Adds GET /auth/config, POST /auth/setup, POST /auth/login, POST /auth/logout,
GET /auth/session and POST /auth/password, plus the Pydantic models behind
them. This is the commit that makes authentication usable.
Every endpoint returns a JSON body rather than a bare 204, because the
client's fetch helpers parse a body unconditionally and an empty response
would surface as a parse failure. The five that bootstrap a session are
allow-listed as public and check for themselves -- requiring a credential to
obtain a credential would be circular.
Anti-enumeration is the part worth reviewing:
- Every failed sign-in returns one identical message, so "no such user" and
"wrong password" are indistinguishable in the body.
- Unknown, disabled and password-less accounts still spend a full scrypt
verification via dummy_verify, so they are indistinguishable in timing
too. Measured: an unknown username costs 47 ms, matching a real verify.
Skipping it would make a missing account return in about a millisecond and
hand out a list of valid usernames.
- The locked-account path deliberately does NOT spend a hash. The answer is
already decided, and burning 50 ms of CPU per attempt against a
single-process server is a self-inflicted denial of service.
Also corrects a throttling design fault found by testing it. The in-memory
per-username window and the persisted failed_logins/locked_until pair were
two mechanisms for the same job, and the cruder one won: five failures
produced a flat 15 minute lockout from the window, making the graduated
schedule on the user row unreachable dead code. The per-username window is
removed. Per-account throttling is now the persisted lockout alone, which is
graduated and survives a restart, and the per-address window stays for what
the persisted counter cannot see -- guesses sprayed across many usernames,
including ones that do not exist.
Setup resolves a race on the unique username index rather than trusting its
own count check, so two callers reaching an unclaimed instance together
cannot both become owner. Claiming is logged at warning level with the
claiming address.
Verified end to end: config reports setup_required before the first account
and not after; a second claim is rejected with 409; usernames are stored and
matched case-insensitively; cookies come back HttpOnly, SameSite=lax, and
without Secure over plain HTTP; "remember me" adds Max-Age and its absence
does not; a cookie-authenticated POST without the CSRF header is refused and
succeeds with it; sign-out revokes server-side; password change rejects a
wrong current password and the new password works afterwards. Lockout now
follows the intended 60s schedule doubling to a 900s cap, and a correct
password during a lockout is still refused.
The existing integration suite still passes unedited with auth off: 372
tests.
Applies a permission level to all 49 routes and turns on the coverage check,
which from here refuses to import the API if a route is added without one.
Levels follow the design doc, with two deliberate departures:
- GET /export/{spools,filaments,vendors} require manage, not read. The doc's
rule is "read covers every GET", but these three return the entire
database in one request, and the doc's own opening section names exactly
that as a reason this project exists. Left at read they would also be
reachable anonymously once auth_anonymous_read is on.
- GET /setting/ stays at read. The client reads currency, price rounding and
base URL at boot, so raising it would break read-only browsing for the
users the level exists for. Keeping secrets out of the settings table is
handled where it belongs, at the storage rule in settings.py.
PUT /spool/{id}/use sits at edit rather than manage on purpose: it is
Moonraker's and OctoPrint's hot path, so a printer can hold a credential
that records filament usage but cannot delete anything.
Websocket routes take the ws_authenticated decorator instead of a
dependency, and their own accept() calls are removed since the decorator now
owns the handshake. One side effect worth noting: the pre-existing
close(4040) for an unknown setting key in setting.py now runs after the
accept, so that code finally reaches clients instead of being flattened into
an HTTP 403. That is an accidental fix, not the goal.
Also fixes a leak found while testing this: resolve_principal drove
get_db_session with "async for" and then returned out of the loop, leaving
the generator suspended at its yield so the session was closed later by the
garbage collector, off the event loop, where SQLAlchemy cannot await. It
produced a wall of greenlet errors on every authenticated request. Sessions
are now closed deterministically through a context manager.
Verified:
- The full existing integration suite passes against a running server with
auth off: 372 tests, no test file edited. That is the proof that this is
additive and that the disabled path is unchanged.
- Removing a single gate makes the API refuse to import and names the route.
- On a live server with auth on, /health and the SPA stay reachable, every
API route answers 401 with the {"message": ...} shape the rest of the API
uses, and secret.key is created at mode 0600.
- 48 gated routes: 29 read, 6 edit, 13 manage, plus /health public.
Adds the user and session data layer, the dependency gates that enforce
permission levels, and the coverage check that proves no route was left
ungated. Nothing is wired to a route yet -- that is the next commit.
database/auth_user.py, database/auth_session.py
Follow the vendor.py template, with one deliberate departure: no websocket
notifier. websocket_manager.send fans out to every subscriber, so
broadcasting account or session changes would hand every connected client
the details of every account. Both module docstrings say so, because the
omission otherwise looks like an oversight worth "fixing".
create() and set_password() take an already-derived hash rather than a
plaintext password. Deriving one blocks for ~50 ms, and these are called
from async handlers where that would stall the single process; callers
pick the sync or async hashing entry point according to where they run.
Sessions slide their expiry forward on use, but only once last_seen is
older than a minute. Without that throttle every authenticated request
becomes a database write, which on SQLite serialises the whole application
behind session bookkeeping.
auth/dependencies.py
require_level() short-circuits to UNRESTRICTED before any database access
when auth is off, which is what keeps the disabled path identical to
before. It does not declare a get_db_session dependency: route-level
dependencies are solved ahead of handler parameters, so doing so would
open a session on every request including /health and /info, which need no
database today and would start failing when one is unavailable. It opens
its own short-lived session only when a credential is actually present.
CSRF failure is 403, not 401. The credential is valid, so reporting
"unauthenticated" would sign the user out over what is usually a stale tab.
ws_authenticated() accepts the handshake before closing on rejection,
which looks wrong and is load-bearing. uvicorn turns a close sent before
the accept into an HTTP 403 and discards the code, and Starlette routes
WebSocketException from a dependency through that same path, so neither
can deliver 4401/4403. Accepting first is the only way the browser sees
the real code, which is what lets the client tell "signed out, stop
retrying" from "network blipped, retry". The existing close(4040) in
api/v1/setting.py has never reached a client for this reason.
auth/coverage.py
Gating routes individually is fail-open. This inverts it: walk the route
table at import and refuse to start if anything lacks a gate. It descends
through FastAPI's include_router wrappers, which are not flattened into
the parent route list -- iterating app.routes alone sees 4 of the 49
routes and would call the rest covered.
Also registers the auth_anonymous_read setting, with a comment forbidding
secrets in the settings table: settings are readable via GET /setting/ and
every write is broadcast to all websocket subscribers.
Verified: coverage check catches forgotten HTTP and websocket routes,
recognises gates declared either as dependencies= or as a handler
parameter, and honours the allow-list. Websocket matrix end to end -- auth
off passes through, no credential gives 4401, a forged cookie gives 4401, a
read-level session connects to a read route and gets 4403 on a manage
route, and path parameters still inject through functools.wraps. The
anonymous-read lookup fails closed on any error.
Levels, principals, password hashing, cookies and login throttling. Pure
library code -- nothing imports it into a request path yet.
levels.py
An ordered read < edit < manage scale. Ranks live in a separate mapping
rather than as comparison operators on the enum: StrEnum inherits str's
operators, so defining __lt__/__ge__ would shadow string ordering with a
second alphabetical meaning that happens to agree today and would stop
agreeing the moment a level is renamed. Callers use covers().
principal.py
A frozen dataclass describing who is asking and what they may do. The
absence of a credential is None rather than a Principal, so "nobody" can
never be mistaken for a permission holder. Carries the tenant_id seam from
the design doc: always None, present so auth functions already thread a
tenant and adding tenancy later is a filter inside them rather than a
change to forty routers. UNRESTRICTED is what gates return when auth is
off, before any database access.
hashing.py
scrypt at n=2**14, r=8, p=1 -- argon2-cffi and bcrypt ship compiled wheels
that are painful on the armv7 image, and scrypt is memory-hard and in the
standard library. OWASP's headline n=2**17 would be multi-second on the
ARM boards Spoolman runs on; their memory-constrained variant raises p,
which costs the same time without the memory hardness.
Hashes embed their own parameters, so the cost can be raised later without
a migration: verification re-derives with the stored parameters and
needs_rehash() reports staleness for opportunistic upgrade at login.
Deriving blocks for ~50 ms here and several times that on a Pi. Spoolman
is a single process, so a synchronous call in a handler would stall every
other request. Measured 4 hashes serially at 171 ms against 63 ms across
four threads, confirming scrypt releases the GIL and the _async wrappers
are a real offload. Handlers must use those.
Session tokens are hashed with plain unsalted SHA-256, deliberately: they
are 256 bits of cryptographic randomness, so there is no dictionary to
attack and no work factor worth paying. The digest exists so a database
leak does not hand over usable cookies.
cookies.py
Session cookie is HttpOnly, so CSRF is handled by double submit against a
second script-readable cookie. Secure is only ever concluded from positive
evidence -- real TLS, or a proxy listed in SPOOLMAN_TRUSTED_PROXIES saying
so -- because setting it on a plain-HTTP LAN deployment makes the browser
discard the cookie and presents as a login that succeeds and does nothing.
ratelimit.py
In-process sliding windows keyed by username and by client address. Sound
because the deployment contract is one process, the same assumption the
websocket manager and scheduler already make; counters reset on restart,
which is why lockout is also persisted on auth_user. X-Forwarded-For and
X-Forwarded-Proto are honoured only from a configured trusted proxy,
otherwise any client could spoof them to evade the limiter.
Also adds AuthenticationRequiredError and PermissionDeniedError to
exceptions.py, matching the existing bare-Exception style.
Verified: level ordering and rejection of unknown names; principal frozen
and anonymous capped at read; hash round-trip, distinct salts, corrupt
hashes never validating, needs_rehash on stale parameters, and dummy_verify
costing 41 ms against a real verify's 42 ms so unknown usernames are
timing-indistinguishable; window limit/expiry/reset; untrusted
X-Forwarded-For ignored and untrusted proxies unable to force Secure; and
all nine CSRF cases including a lowercase-method bypass attempt.
Adds the five auth_ tables and one DDL-only Alembic revision. Nothing reads
or writes them yet; the data layer and endpoints follow in later commits.
All five tables are created now, not just the two phase 1 needs. The reason
is migrations/env.py's `target_metadata = Base.metadata`: if the database
carried auth_api_key while models.py did not, the next autogenerated
revision anyone produced would emit op.drop_table for it. Models and schema
have to move together, so the phase 2/5 tables land as models now and gain
their business logic later.
Design notes, all following existing conventions in this schema:
- Tables are prefixed auth_ because `user` and `session` are reserved words
in PostgreSQL and MySQL, and quoting them at every call site is a footgun.
- No relationship() on any auth table. Auth lookups are single-row queries
on an indexed column, so there is nothing to eager-load; a `sessions`
collection on AuthUser would fetch every session row on every login, and
lazy-loading one under asyncio raises MissingGreenlet.
- Enum-valued columns (level, actor_kind) are String, matching
multi_color_direction. There is not one sa.Enum in this schema, and adding
one would create a native PostgreSQL type and a MySQL ENUM(...) with three
different ALTER dialects.
- Uniqueness is enforced with unique indexes rather than named UNIQUE
constraints. That is the portable form across all four dialects, and it
keeps the multi-tenancy seam cheap: auth_user.username can later become a
composite (tenant_id, username) index without dropping a constraint whose
generated name differs per dialect.
- The revision is pure DDL with no DML, which is what makes it safe as a
single revision on CockroachDB -- the aafcd7fb0e84 -> 304a32906234 split
was needed only because the second half wrote data.
The revision body is alembic's own autogenerate output against a database at
415a8f855e14, so it matches the conventions of the existing revisions rather
than being hand-approximated.
Verified on SQLite: upgrade from scratch creates all five tables with the
four unique indexes; downgrade -1 drops them all; upgrade replays cleanly.
Critically, running autogenerate again after upgrade emits an empty
migration, proving the models and the schema agree and that no future
autogenerate will try to drop the phase 2/5 tables.
Adds the configuration surface for the opt-in authentication system. Nothing
reads these yet beyond the startup secret-key resolution; the gates that
consume them arrive in later commits.
New variables, all documented in .env.example:
SPOOLMAN_AUTH_ENABLED enforce authentication (default FALSE)
SPOOLMAN_SECRET_KEY[_FILE] instance secret key
SPOOLMAN_TRUSTED_PROXIES proxies whose forwarding headers we believe
SPOOLMAN_COOKIE_SECURE AUTO/TRUE/FALSE override for the Secure flag
SPOOLMAN_METRICS_PUBLIC keep /metrics open while auth is on
SPOOLMAN_METRICS_TOKEN[_FILE] shared token granting /metrics access
Notes:
- A private _parse_bool helper covers the new booleans. The five existing
boolean accessors are deliberately left inlined: each carries a bespoke
error message that users quote in bug reports, and rewriting them would
turn this into an unrelated refactor.
- _read_env_or_file strips file contents, unlike get_password(). Key and
token files are normally produced by a shell redirect, and an unstripped
trailing newline would silently yield a different secret than the one the
operator wrote.
- The generated key file is created with an exclusive open at mode 0600
rather than a write followed by chmod, so its contents are never briefly
world-readable under a permissive umask. An existing file with looser
permissions warns rather than fails, since some bind mounts cannot
represent 0600.
- ensure_secret_key() runs in startup() after the data-directory write
check and before the alembic subprocess: it needs a writable directory
but no schema, so a misconfiguration fails fast with an actionable
message. It is guarded by is_auth_enabled(), so an auth-disabled instance
never grows the file.
Phase 1 does not use the key cryptographically -- session tokens are opaque
random values stored as SHA-256, and CSRF is a double-submit cookie compare.
It is introduced now because phases 3 and 4 consume it, and adding it later
would force every phase-1 adopter to reconfigure.
Verified: generation yields mode 0600, precedence is _FILE > env > generated,
trailing newlines are stripped, a missing key file raises a clear ValueError,
and CSV/AUTO parsing round-trips.
The declared floor was ">=3.10", but nothing enforced it: the Docker image
has pinned python:3.14-slim-bookworm for amd64/arm64/armv7 for some time,
and scripts/install.sh runs "uv sync" without UV_PYTHON_DOWNLOADS=0, so uv
fetches a managed interpreter when the system one is too old. Every
dependency declares a floor of <=3.10 with no upper cap.
Raising it unlocks enum.StrEnum, datetime.UTC and typing.Self for the
authentication work that follows.
Consequences handled here:
- uv.lock regenerated. Four pure-backport packages drop out
(async-timeout, backports-asyncio-runner, exceptiongroup, tomli).
- ruff target-version follows to py314, which switches on PEP 649 deferred
annotations and with them a batch of previously-dormant rules. The safe
automatic fixes are applied: datetime.timezone.utc -> datetime.UTC
(UP017) and redundant quotes dropped from annotations (UP037).
- TC001/TC002/TC003 are ignored rather than applied. They want type-only
imports moved into "if TYPE_CHECKING:" blocks, which is wrong here:
FastAPI resolves Annotated[AsyncSession, Depends(...)] and websocket
parameters at runtime, Pydantic resolves field types at runtime, and
SQLAlchemy resolves Mapped[...] at mapper-configuration time. A
TYPE_CHECKING-only import is absent from the runtime namespace, so
applying them breaks dependency injection. Ruff classifies all 50 as
unsafe fixes for that reason.
Verified: ruff check/format clean, uv lock --check clean, alembic head
resolves, SQLAlchemy mappers configure with all relationships intact and
lazy="selectin" preserved on the extra-field collections.
A tab left open would rack up gigabytes of transfer and heavy lag over
time. Two causes, both fixed:
- live.ts retried a dropped backend every fixed 2s forever (x3 sockets,
no backoff). Each failed connect is a console error the browser can't
GC, so an overnight tab bloated memory and janked. Now backs off
1s->30s with jitter, resets on connect, and reconnects immediately on
'online' / tab-visible (laptop sleep/wake). Measured churn ~15x lower.
- Every live event refetched the group list AND every visible GroupRow
(~21-31 uncached requests) once per event. A burst (bulk import,
printer streaming use-updates, initial sync) detonated that fan-out
per event. A 300ms debounce collapses each burst into one refetch
cycle (~10x fewer requests on a 20-event burst).
The overlay was role=button wrapping the whole dialog, so screen readers
announced every control as nested inside one button. Make the click-outside
catcher a sibling <button> behind the modal instead of a parent, and move
Escape-to-close to a window keydown listener.
Add aria-labels to the Settings language select, currency/base-URL/
low-stock inputs, and the Labels toolbar + preview-bar selects, which
axe-core flagged as unnamed (WCAG select-name/label, critical) under the
mobile a11y audit. Adds a labels.selectDesign message key.
The suggestion list only closed on Escape, outside pointerdown, or
selection, so tabbing focus out of the input left the portaled dropdown
open. Add an onblur handler that closes the list, guarding against blurs
that hand focus into the list itself.
Address the pervasive sub-44px tap-target audit finding and related
mobile UX issues:
- Add a centralized 44px touch-target layer in app.css (gated on the
existing <=860px mobile breakpoint) raising the shared Button/tab/chip/
select/etc. primitives; grow TopBar scan/add to 44px and the number
steppers so they clear the WCAG 24px floor. Audit: 148 -> 46 sub-44px.
- Collapse the mobile search into an icon in the top row that expands to a
full-width overlay on tap, dropping the top bar from three rows to two.
Give the search toggle the same outlined look as the scan button.
- Fix breadcrumb links not vertically centering inside their 44px tap area.
- Stop hiding the spool/filament inspector action rows on narrow panels;
reflow them onto their own row, and drop to icon-only (labels kept in the
a11y tree) below 560px so three actions stay on a single row.
The mobile a11y audit reported color-contrast (serious) on ~11-17 nodes
per page, the nav tabs being the recurring offender. Every flagged node
routed through a design token, so retune the tokens rather than patch
components: --text-muted/--text-dim/--text-faint and light --accent-soft
now clear 4.5:1 on the surfaces they sit on in both themes (down to the
lightest dark surface and darkest light surface), keeping the
muted->dim->faint ramp monotonic. --text-2 already passed and is
unchanged.
app.html shipped `%paraglide.lang%`/`%paraglide.dir%` placeholders, but SSR
is off and there is no handle hook, so they were never substituted — axe
reported html-lang-valid (serious) on every page from the literal value.
Ship a valid `lang="en" dir="ltr"` static default and apply the real locale
(getLocale/getTextDirection) to <html> in +layout.svelte at mount, alongside
where the theme is applied. Verified in-browser: en→ltr, de→ltr, fa→rtl.
Also remove the unused Locale class in paraglide.svelte.ts (dead code, never
imported).
Adds an npm run audit:a11y target that runs axe-core (WCAG 2.0/2.1 A+AA
plus the 2.2 target-size rule) and a custom tap-target measurement across
the main surfaces under Pixel 5 emulation, reusing the dev server on 5174.
Results are aggregated to a11y-report.md via globalTeardown so they survive
Playwright worker restarts.
SvelteKit's adapter-static emits the SPA fallback (200.html) with absolute
asset paths (/_app/...) and a hardcoded base: "", since it can't know the
deploy base path at build time. Prerendered per-route documents use relative
paths and a runtime-computed base, so they work under any base path, but the
fallback is used for non-prerendered dynamic routes (/spool/show/<id>,
/filament/show/<id>) -- exactly the URLs printed into QR labels. Under a base
path those direct loads 404'd every asset and rendered a blank page.
Rewrite the fallback server-side using the base path the backend already
knows, mirroring the legacy-client asset rewrite: prefix /_app/ and /favicon
references and set the runtime base. Prerendered docs are still served
verbatim, and the legacy client is unaffected (its tweaked_html is always
produced when rewrite_asset_paths is on, so the file_response guard change is
equivalent there).
Add an aggregate remaining-weight line to the FilamentInspector spools
section (respects the showArchived toggle), answering the recurring
total-available ask (#572/#347/#265).
Switch inventory weight displays (group totals, library list, both
inspectors) to weightAuto so amounts >=1000 g render in kg. Floor the
kg value to one decimal so the figure never overstates what's on the
spool. Remove the now-unused kg() helper.
Extract the AddSpoolModal material-list building into a shared, session-
cached loadMaterials() helper (mirrors getExternalMaterials' cache), and
use it to give the filament inspector's material field autocomplete via
Combobox. The inspector uses only the name suggestions and never prefills
density/diameter/temps, unlike the add-spool form.
The Combobox location dropdown and DateTimeField calendar are position:fixed
and anchored to their trigger's viewport rect. On mobile the inspector renders
inside DetailPane's bottom sheet (transform) and the .insp panel
(container-type), both of which become the containing block for fixed
descendants, re-basing the popover's coordinates and dropping it far below its
field.
Add a portal action that reparents the popover to <body> so position:fixed
anchors to the viewport regardless of offsetting ancestors. DateTimeField's
outside-click handler now also checks the portaled popover so picking a date
doesn't close it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Group headers rendered a Swatch unconditionally, but only filament
groups carry colors — vendor/material/location groups pass an empty
list, which swatchStyle() paints as a meaningless gray (#555) chip.
Only render the swatch when the group actually has colors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
client_v2 has matured, so enforce its checks like the old client:
- Add ESLint (typescript-eslint + eslint-plugin-svelte) with a flat
config; `lint` now runs prettier --check and eslint.
- Add a "Client v2" group to lefthook pre-commit and ci: eslint,
prettier, and svelte-check type checking (svelte-kit sync first).
- Exclude generated paraglide output from prettier.
Fix the linting warnings this surfaced: drop dead vars and an unused
svelte-ignore, mark intentional reactive reads with `void`, remove a
useless children snippet, and clean up useless string-literal mustaches.
Disable svelte/no-navigation-without-resolve (base-path resolution is
centralized in params.ts helpers, so the rule only false-positives and
wrapping call sites would double-apply the base path). Keep
svelte/prefer-svelte-reactivity on with per-line disables at the
transient-local Set/Map sites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
npm 11 holds back dependency install scripts unless allow-listed, printing
a notice for esbuild's postinstall. Approve it explicitly so `npm install`
runs cleanly; older npm versions ignore the unknown field harmlessly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navigation across the app was done with <button onclick> calling the
params.* router helpers, so the browser never treated these as links: no
middle-click/ctrl-click open-in-new-tab, no copy-link-address, no hover
URL preview. Add href-builder twins of the URL mutators in
library/params.ts (selectHref, selectHrefFromState, pageHrefFromState,
libraryHref, searchResultHref) and point real anchors at them; SvelteKit
intercepts the clicks for SPA nav while data-sveltekit-keepfocus/-noscroll
preserve the old goto() semantics.
Converted: spool/filament rows in the library list and inspectors,
breadcrumbs, filament/vendor group headers (material/location headers,
which navigated nowhere, are now inert divs), the spool inspector's
open-filament link and print-label button (Button gains an optional
href), search results, location chips (still drag items), and pagination.
Genuinely non-navigational actions (modals, sort/filter/group toggles,
expand/collapse, label-canvas selection) stay buttons.
Spoolman v1 QR labels (and the label designer's `url` encoding) point at
/spool/show/<id>, a route that no longer exists in client_v2. Add a
non-prerendered route whose load 307-redirects to /?sel=spool:<id>, so the
SPA fallback boots the app and the client-side redirect opens the spool.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
The QR "URL" encoding was hardcoded to <base_url>/spool/show/{id}. Add a
per-QR-element "Custom URL" encoding with a urlTemplate field so a printed
QR can point at a third-party scanner/app or a different host. The {id}
placeholder reuses the existing qrContent substitution.
The inspector seeds the field from the default URL form and warns that a
custom target won't scan back into Spoolman's own scanner. The no-spool
editor-canvas preview now renders the actual per-encoding template so the
QR module density matches what will print.
Requested in #68 and #741.
A group with a single unused spool now renders it inline as a normal
SpoolRow instead of behind an 'X unused' collapsing header, which added
a click and a row for no vertical-space benefit. The header only appears
when 2+ spools are unused.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
When the list has zero results and no filters are active the library is
genuinely empty (a fresh install), so nudge the user toward adding their
first spool instead of the generic "nothing matches the filters" line.
The CTA button opens the same add-spool modal as the top-right button.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
find_by_color loaded every filament as a full ORM object (with its vendor
join) into memory and computed delta-E in Python, then the endpoint re-queried
the matched ids anyway. The full-catalog ORM load dominated the cost: a color
search ran at a flat ~100ms regardless of how few results it returned, ~5-20x
slower than an equivalent regular filtered search, and each concurrent request
cloned the whole catalog into memory.
Scan only id/color_hex/multi_color_hexes and return the matched ids; callers
load full rows for just the matches. The matching algorithm is unchanged.
Color search now scales with the match count like any other filter: ~5-16x
faster here and the concurrency memory spike (+21MB at 8 concurrent) is gone.
Drops the "Slow operation!" note from the color_hex query param since it no
longer applies.
Verified across SQLite/Postgres/MariaDB/CockroachDB (372 integration tests
pass on each).
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
Spool responses carry fields derived from the filament (remaining_length,
plus the initial_weight/price fall-backs). Editing a filament emitted only a
filament WS event, so subscribers to that filament's spools kept stale derived
values until each spool was next touched — visible in client_v2 as a length
inconsistent with the displayed density, but affecting every consumer.
filament.update now re-broadcasts the filament's spools (archived included)
as spool "updated" events after commit. Adds a WS integration test asserting
a density patch reaches /api/v1/spool with the new density and changed length.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
The library inspector read purely from the reactive cache, which is only
filled by the list page, search and live events. A selection reached by
bookmark/QR/shared link (?sel=spool:4093) for an entity not on the loaded
page rendered the empty "select…" state.
Add fetchSpool/fetchFilament/fetchVendor single-entity fetches to
spoolSource (upserting the entity plus embedded relations; a 404 resolves
to undefined via swallow404). Inspector.svelte runs a fetch-on-select
effect that resolves a missing selection by id, showing a loading state
while in flight and a distinct not-found state for a deleted entity.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
The Library's group list can only be ordered three ways by the backend
(group.title, group.total_remaining, group.last_used), yet the sort menu
offers ~20 fields. Picking any other sort while grouped silently fell back
to alphabetical group order and only reordered spools within each collapsed
group — an invisible, confusing no-op.
Enforce the invariant that a grouped view is only ever ordered by a
group-orderable sort (the axis's own name → group.title, remaining weight,
or last used). Picking a per-spool ranking now switches to the flat list
where the ranking is actually visible; regrouping while holding an
incompatible sort resets to the default group ordering. Axis-name sorts map
to group.title with a working direction toggle, and name/text sorts default
to ascending.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
Weblate writes an empty string for every untranslated key. Paraglide
compiles a present-but-empty value into a message function that returns
"", so the site rendered a blank instead of falling back to English. A
key that is absent from a locale, by contrast, is re-exported from the
base locale and falls back correctly.
Add scripts/strip-empty-locales.mjs to delete empty/whitespace-only
leaves from the non-base locales/*/common.json before paraglide compiles
them, turning "present-but-empty" into "absent". Idempotent, base en
untouched, key order and formatting preserved. Wired as predev/prebuild
so builds stay correct if empties reappear from a later Weblate sync;
deliberately not a lint/CI gate, since empties are a benign expected
input to be neutralised, not forbidden.
Cleans 125 existing empties across 10 locales.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
Replace the glossy 3D swatch (diagonal sheen + directional inset shade)
with a flat chip that keeps only two non-directional hairline rings for
edge definition on light and dark backgrounds.
Multi-color swatches now split into per-colour bands with softly feathered
boundaries instead of a full-slot blur, and the gradient angle follows the
filament's layout: coaxial splits side-by-side, longitudinal along the
strand, unknown stays diagonal. Direction is threaded through every Swatch
callsite, including the filament-grouped library header (already present in
the /spool/group response, just not mapped through before).
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
The Library's grouped view fetched every "show more" click as a fresh window
from offset 0, so the request grew with each click (8 → 28 → 48 …), wasting
bandwidth and backend work. buildScopedSpoolQuery now takes an explicit offset,
and GroupRow's "show more" fetches only the next slice (offset = loaded count,
limit = 20) and appends it — a constant-size request per click.
The base-load effect still refetches from offset 0, but for as many rows as are
already shown (spools.length read via untrack, so appending doesn't re-trigger
it), so a live-event refresh keeps the expanded window instead of collapsing to
the first page. "Show more" is disabled during a base load so its append can't
race the replace, and its request aborts on unmount like the base one.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
Datetime extra fields rendered through their own path (native datetime-local
input when editable, new Date(v).toLocaleString() when readonly) while builtin
datetimes like Last Used render via DateTimeField with a localized label.
Extract that label logic into formatDateTime/dateLocale (utils/datetime.ts);
DateTimeField reuses it, and ExtraFieldInput now uses DateTimeField for editing
and formatDateTime for readonly display so both format identically.
Also localize two datetime helpers in map.ts: shortDate (registered/first-used
labels) used the browser locale instead of the app locale, and relTime
(last-used labels) hard-coded English unit letters. Both move to datetime.ts as
formatShortDate/formatDurationShort, using the app locale and Intl.NumberFormat
unit style. Formatters are memoized per locale since they run over every spool.
Also correct two mislabeled fields in the spool inspector's filament section
(weight and spool weight i18n keys).
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4