Compare commits

...
Author SHA1 Message Date
Donkie f475da2176 auth: integration tests and a11y coverage for the phase 2 surfaces
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
2026-07-28 14:25:23 +02:00
Donkie 8c887a4515 client_v2: account, users and audit screens
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
2026-07-28 13:59:42 +02:00
Donkie ed58a6e3c4 auth: user administration over the API, not just the command line
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
2026-07-28 13:57:00 +02:00
Donkie 07be532f00 auth: API keys, machine credentials that cannot outrank their owner
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
2026-07-28 13:40:28 +02:00
Donkie b4d7955eaa auth: an audit trail for everything that changes who can do what
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
2026-07-28 13:39:24 +02:00
Donkie 0f3a3918c1 auth: focused integration test suite
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.
2026-07-28 13:10:55 +02:00
Donkie 3466a644ba client_v2: hide controls the server would refuse
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.
2026-07-28 13:03:04 +02:00
Donkie 07396abbcb client_v2: authentication plumbing, sign-in and claim screens
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.
2026-07-28 10:08:58 +02:00
Donkie c0a960cbe3 auth: command line administration
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.
2026-07-28 09:58:27 +02:00
Donkie 7cc84b32f5 auth: gate metrics, fix credentialed CORS, report status at startup
/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.
2026-07-28 09:54:12 +02:00
Donkie 5ef6401e2b auth: sign-in, first-run setup and account endpoints
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.
2026-07-28 09:52:03 +02:00
Donkie 4260a40bc7 auth: gate every v1 route
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.
2026-07-28 09:11:31 +02:00
Donkie 95f3acea6b auth: data layer and permission gates
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.
2026-07-28 09:01:34 +02:00
Donkie e4563e58c7 auth: core primitives
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.
2026-07-28 08:50:24 +02:00
Donkie a46cad036f auth: database tables and migration
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.
2026-07-28 08:46:28 +02:00
Donkie e0074e344b auth: environment flags and instance secret key
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.
2026-07-27 20:12:39 +02:00
Donkie e509378392 chore: require Python 3.14
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.
2026-07-27 20:09:43 +02:00
98 changed files with 11607 additions and 1393 deletions

No files matched your search

+51
View File
@@ -84,3 +84,54 @@ SPOOLMAN_PORT=7912
# SPOOLMAN_CORS_ORIGIN=source1.domain.com:p1, source2.domain.com:p2
# to allow from any
# SPOOLMAN_CORS_ORIGIN=*
#
# --- Authentication (opt-in) ---
#
# Spoolman has no authentication unless you switch it on here. With
# SPOOLMAN_AUTH_ENABLED unset, every endpoint stays open to anyone who can reach
# the port, exactly as before.
#
# Enforce authentication on the API and the web UI.
# The first visitor after enabling this claims the instance and becomes the owner,
# so enable it and claim the account before exposing the port.
# Default: FALSE
#SPOOLMAN_AUTH_ENABLED=TRUE
# Secret key for this instance.
# Leave unset and Spoolman generates one in the data directory (secret.key, mode
# 0600) on first start. Set it explicitly if you would rather manage it yourself or
# need it to be identical across a rebuild.
# Use SPOOLMAN_SECRET_KEY_FILE to read it from a file instead, e.g. a docker secret.
# Trailing whitespace in the file is ignored.
#SPOOLMAN_SECRET_KEY=
#SPOOLMAN_SECRET_KEY_FILE=/run/secrets/spoolman_secret_key
# Reverse proxies whose forwarding headers Spoolman may believe.
# Comma-separated IP addresses or CIDR networks. Until a proxy is listed here,
# X-Forwarded-For and X-Forwarded-Proto are ignored, which means rate limiting sees
# your proxy's address instead of the real client's.
# Note this is separate from uvicorn's own FORWARDED_ALLOW_IPS.
# Default: empty (trust nothing)
#SPOOLMAN_TRUSTED_PROXIES=172.16.0.0/12, 10.0.0.0/8
# Force the Secure flag on session cookies.
# AUTO derives it from the request scheme, which is almost always right. Set TRUE if
# you terminate TLS on a proxy that does not send X-Forwarded-Proto. Setting it TRUE
# on a plain-HTTP deployment makes the browser discard the cookie and login will
# fail silently.
# Default: AUTO
#SPOOLMAN_COOKIE_SECURE=AUTO
# Keep the /metrics endpoint reachable without credentials while auth is enabled.
# Only consulted when SPOOLMAN_AUTH_ENABLED is set; metrics are public otherwise.
# Default: FALSE
#SPOOLMAN_METRICS_PUBLIC=TRUE
# Shared token granting access to /metrics, sent as "Authorization: Bearer <token>"
# or "X-Api-Key: <token>". Use SPOOLMAN_METRICS_TOKEN_FILE to read it from a file.
# With auth enabled and neither this nor SPOOLMAN_METRICS_PUBLIC set, /metrics
# returns 401 to everyone.
#SPOOLMAN_METRICS_TOKEN=
#SPOOLMAN_METRICS_TOKEN_FILE=/run/secrets/spoolman_metrics_token
+34 -1
View File
@@ -277,6 +277,39 @@ jobs:
up-flags: "--abort-on-container-exit"
down-flags: "--volumes"
#
# Perform authentication tests
#
# Deliberately its own job rather than another entry in the matrix above. That matrix
# re-runs the whole regression suite per database; adding auth to it would run those
# same tests a fifth time to re-prove something none of them are about. Nothing in
# spoolman/auth is database-specific, so one SQLite pass over the auth suite is the
# whole cost.
#
test-auth:
needs: [build-amd64, build-tester]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download built images
uses: actions/download-artifact@v4.1.7
with:
pattern: spoolman*-image
path: /tmp
- name: Load built images
run: |
docker load --input /tmp/spoolman-image/spoolman.tar
docker load --input /tmp/spoolman-tester-image/spoolman-tester.tar
- name: Perform authentication tests
uses: hoverkraft-tech/compose-action@v2.0.1
with:
compose-file: "./tests_integration/docker-compose-sqlite-auth.yml"
up-flags: "--abort-on-container-exit"
down-flags: "--volumes"
#
# Perform frontend (browser) integration tests against the real image
#
test-frontend:
@@ -429,7 +462,7 @@ jobs:
#
publish-images:
if: ${{ github.event_name != 'pull_request' }}
needs: [test, test-frontend, build-amd64, build-arm64, build-armv7, style, build-client]
needs: [test, test-auth, test-frontend, build-amd64, build-arm64, build-armv7, style, build-client]
runs-on: ubuntu-latest
permissions:
packages: write
+197
View File
@@ -0,0 +1,197 @@
import { test, type Page } from '@playwright/test';
import { audit, settle } from './audit';
// Mobile-accessibility audit for the authentication surfaces.
//
// These live apart from a11y-mobile.spec.ts because they need something that file's
// backend does not have: authentication turned on. The sign-in screen never renders
// against a default instance, so auditing it there would silently measure the library
// page twice and report a pass.
//
// The file configures itself rather than needing a second Playwright config. It asks
// the server what it is, and:
//
// * skips entirely when authentication is disabled, which is the default and what CI
// runs, so `npm run audit:a11y` keeps working unchanged;
// * claims an unclaimed instance with the credentials below, so pointing the audit at
// a throwaway auth-enabled server needs no setup at all;
// * otherwise signs in with AUDIT_AUTH_USERNAME / AUDIT_AUTH_PASSWORD.
//
// One surface is always missing from any single run, unavoidably: claiming is one-way,
// so an instance shows either the claim screen or the sign-in screen, never both. A
// fresh instance audits sign-in (the test claims it first); pointing the audit at an
// already-claimed instance audits sign-in too. To audit the claim screen, run it once
// against a brand-new instance and read the report before the second run.
//
// Run it against an auth-enabled backend serving the built client with:
// npm run build
// SPOOLMAN_AUTH_ENABLED=TRUE uv run uvicorn spoolman.main:app --port 8000
// AUDIT_BASE_URL=http://localhost:8000 npm run audit:a11y
//
// Or against `vite dev`, with VITE_APIURL pointing at the backend as usual.
// Resolved the same way src/lib/api/config.ts resolves it, so the audit works against
// both layouts: a `vite dev` server on 5174 talking to a separate backend through an
// absolute VITE_APIURL, and the backend serving the built SPA itself, where the API is
// same-origin. Getting this wrong is silent — every request 404s and every test skips.
const API = process.env.VITE_APIURL?.replace(/\/+$/, '') ?? '/api/v1';
const USERNAME = process.env.AUDIT_AUTH_USERNAME ?? 'audit-owner';
const PASSWORD = process.env.AUDIT_AUTH_PASSWORD ?? 'audit-owner-password';
// A second account, created by the suite, whose password is one the owner set. It is
// what makes the forced-change screen reachable — the flag cannot be set on oneself.
const TEMP_USERNAME = 'audit-temp-user';
const TEMP_PASSWORD = 'audit-temp-password';
interface AuthConfig {
enabled: boolean;
setup_required: boolean;
}
async function authConfig(page: Page): Promise<AuthConfig | null> {
try {
const response = await page.request.get(`${API}/auth/config`);
if (!response.ok()) return null;
return (await response.json()) as AuthConfig;
} catch {
// No backend reachable at all. Skipping beats failing the whole audit run.
return null;
}
}
/** Read the CSRF cookie the server set, which every write must echo in a header. */
async function csrfHeaders(page: Page): Promise<Record<string, string>> {
const cookies = await page.context().cookies();
const csrf = cookies.find((c) => c.name === 'spoolman_csrf');
return csrf ? { 'X-CSRF-Token': csrf.value } : {};
}
/** Sign in through the API, leaving the session cookie on the page's context. */
async function signIn(page: Page, username: string, password: string): Promise<boolean> {
const response = await page.request.post(`${API}/auth/login`, {
data: { username, password, remember: false }
});
return response.ok();
}
/** Put the page in a signed-in state as an administrator, or return false. */
async function signInAsOwner(page: Page): Promise<boolean> {
const config = await authConfig(page);
if (config === null || !config.enabled) return false;
if (config.setup_required) {
const claimed = await page.request.post(`${API}/auth/setup`, {
data: { username: USERNAME, password: PASSWORD, display_name: 'Audit Owner' }
});
if (!claimed.ok()) return false;
return true;
}
return signIn(page, USERNAME, PASSWORD);
}
test.describe('auth surfaces', () => {
test('a11y: sign-in screen', async ({ page }, testInfo) => {
await page.goto('/');
// Claim the instance if it is unclaimed, rather than skipping: on a fresh server
// the sign-in screen does not exist until somebody owns it, and skipping here
// would mean the surface is never audited on exactly the throwaway instance this
// suite is meant to be pointed at.
test.skip(!(await signInAsOwner(page)), 'authentication is not enabled on this server');
await page.context().clearCookies();
await page.goto('/');
await settle(page);
await audit(page, testInfo, 'sign-in screen');
});
test('a11y: claim (first-run setup) screen', async ({ page }, testInfo) => {
await page.goto('/');
const config = await authConfig(page);
test.skip(config === null || !config.enabled, 'authentication is not enabled on this server');
test.skip(!config!.setup_required, 'instance is already claimed');
await settle(page);
await audit(page, testInfo, 'claim screen');
});
test('a11y: account page', async ({ page }, testInfo) => {
await page.goto('/');
test.skip(!(await signInAsOwner(page)), 'could not sign in');
await page.goto('/account');
await settle(page);
await audit(page, testInfo, 'account');
// The key-creation form is a separate surface: it is the only real form on the
// page and none of its controls exist until it is opened.
const newKey = page.getByRole('button', { name: /new api key/i }).first();
if ((await newKey.count()) > 0) {
await newKey.click();
await audit(page, testInfo, 'account — new API key form');
}
});
test('a11y: users page', async ({ page }, testInfo) => {
await page.goto('/');
test.skip(!(await signInAsOwner(page)), 'could not sign in');
await page.goto('/users');
await settle(page);
await audit(page, testInfo, 'users');
const newUser = page.getByRole('button', { name: /new user/i }).first();
if ((await newUser.count()) > 0) {
await newUser.click();
await audit(page, testInfo, 'users — new user form');
}
});
test('a11y: audit log', async ({ page }, testInfo) => {
await page.goto('/');
test.skip(!(await signInAsOwner(page)), 'could not sign in');
await page.goto('/audit');
await settle(page);
await audit(page, testInfo, 'audit log');
});
test('a11y: forced password change', async ({ page }, testInfo) => {
await page.goto('/');
test.skip(!(await signInAsOwner(page)), 'could not sign in');
// Create a throwaway account that must change its password. Re-running the audit
// against the same instance is fine: a 409 means it already exists, and the reset
// below puts it back into the state this surface needs either way.
const headers = await csrfHeaders(page);
await page.request.post(`${API}/auth/user`, {
headers,
data: {
username: TEMP_USERNAME,
password: TEMP_PASSWORD,
level: 'read',
is_admin: false,
must_change_password: true
}
});
const listed = await page.request.get(`${API}/auth/user`);
test.skip(!listed.ok(), 'could not list users');
const users = (await listed.json()) as { id: number; username: string }[];
const temp = users.find((u) => u.username === TEMP_USERNAME);
test.skip(temp === undefined, 'could not create the temporary account');
const reset = await page.request.post(`${API}/auth/user/${temp!.id}/password`, {
headers,
data: { password: TEMP_PASSWORD, must_change_password: true }
});
test.skip(!reset.ok(), 'could not reset the temporary account');
await page.context().clearCookies();
test.skip(!(await signIn(page, TEMP_USERNAME, TEMP_PASSWORD)), 'could not sign in as the temporary user');
await page.goto('/');
await settle(page);
await audit(page, testInfo, 'forced password change');
});
});
+8 -132
View File
@@ -1,137 +1,13 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import { SURFACES_DIR, TAP_MIN, type SurfaceResult, type TapTarget } from './report';
import { test } from '@playwright/test';
import { audit, settle } from './audit';
// Mobile-accessibility audit for client_v2.
// Mobile-accessibility audit for client_v2's main surfaces, under a Pixel 5 mobile
// emulation (see playwright.config.ts). The measurements themselves live in audit.ts;
// this file is the list of what gets measured.
//
// Two independent checks run against every surface, under a Pixel 5 mobile
// emulation (see playwright.config.ts):
//
// 1. axe-core — the full WCAG 2.0/2.1 A + AA ruleset PLUS the WCAG 2.2
// `target-size` rule (SC 2.5.8, 24x24 CSS px minimum). Real conformance
// failures.
//
// 2. A custom tap-target measurement — flags interactive controls smaller
// than TAP_MIN px in either dimension. An *advisory* thumb-comfort check
// (Apple HIG 44px / Material 48px), stricter than the WCAG minimum and
// catching targets axe considers technically compliant.
//
// Each surface's findings are written to SURFACES_DIR as JSON; the consolidated
// `a11y-report.md` is rendered by globalTeardown (which survives worker
// restarts). Raw findings are also attached to the Playwright HTML report.
let surfaceCount = 0;
// Let the DOM and any async lists settle. The app holds an open live-sync
// WebSocket, so `networkidle` never fires — we bound it and fall through.
async function settle(page: Page, ms = 500) {
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {});
await page.waitForTimeout(ms);
}
// Measure every interactive control in the live DOM and return those smaller
// than `min` px in either dimension. Runs in the browser.
function collectSmallTargets(min: number): TapTarget[] {
const SELECTOR = [
'a[href]',
'button',
'input:not([type="hidden"])',
'select',
'textarea',
'summary',
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="menuitem"]',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const out: TapTarget[] = [];
const seen = new Set<Element>();
for (const el of Array.from(document.querySelectorAll(SELECTOR))) {
if (seen.has(el)) continue;
seen.add(el);
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
if ((el as HTMLButtonElement).disabled) continue;
// WCAG exempts targets rendered inline within a run of text (e.g. a link
// inside a sentence). Skip inline <a> elements to avoid false positives.
if (el.tagName === 'A' && style.display.startsWith('inline')) continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue; // not rendered
if (rect.width >= min && rect.height >= min) continue;
const id = el.id ? `#${el.id}` : '';
const rawCls = (el as HTMLElement).className;
const cls =
typeof rawCls === 'string' && rawCls.trim()
? '.' + rawCls.trim().split(/\s+/).slice(0, 2).join('.')
: '';
const aria = el.getAttribute('aria-label');
out.push({
tag: el.tagName.toLowerCase(),
text: (aria || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40),
selector: `${el.tagName.toLowerCase()}${id}${cls}`,
width: Math.round(rect.width),
height: Math.round(rect.height)
});
}
return out;
}
// Run both checks against whatever is currently rendered and record the result.
async function audit(page: Page, testInfo: TestInfo, label: string) {
await settle(page);
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
const smallTargets = await page.evaluate(collectSmallTargets, TAP_MIN);
const surface: SurfaceResult = {
order: surfaceCount++,
label,
url: page.url(),
axe: axeResults.violations.map((v) => ({
id: v.id,
impact: v.impact,
help: v.help,
nodes: v.nodes.length,
targets: v.nodes.slice(0, 5).map((n) => n.target.join(' '))
})),
smallTargets
};
const file = path.join(SURFACES_DIR, `${label.replace(/[^a-z0-9]+/gi, '-')}.json`);
await writeFile(file, JSON.stringify(surface, null, 2), 'utf8');
await testInfo.attach(`${label} — axe violations`, {
body: JSON.stringify(axeResults.violations, null, 2),
contentType: 'application/json'
});
await testInfo.attach(`${label} — small tap targets`, {
body: JSON.stringify(smallTargets, null, 2),
contentType: 'application/json'
});
// Soft assertions: the run completes and reports every surface, but the test
// is still marked failed if anything is out of spec — useful as a CI gate.
const serious = surface.axe.filter((v) => v.impact === 'serious' || v.impact === 'critical');
expect.soft(serious, `${label}: serious/critical axe violations`).toEqual([]);
expect.soft(smallTargets, `${label}: tap targets under ${TAP_MIN}px`).toEqual([]);
}
// These run against the default, authentication-disabled configuration. The sign-in,
// account and administration surfaces need an authenticated backend and are audited by
// a11y-auth.spec.ts, which skips itself when authentication is off.
const STATIC_ROUTES: { path: string; label: string }[] = [
{ path: '/', label: 'library (home)' },
+131
View File
@@ -0,0 +1,131 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, type Page, type TestInfo } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import { SURFACES_DIR, TAP_MIN, type SurfaceResult, type TapTarget } from './report';
// The two measurements the mobile-accessibility audit takes, and the bookkeeping that
// turns them into a report. Extracted from a11y-mobile.spec.ts so the auth surfaces —
// which need a different backend configuration and therefore live in their own spec —
// are measured by exactly the same code rather than a copy of it.
//
// 1. axe-core, the full WCAG 2.0/2.1 A + AA ruleset plus WCAG 2.2 `target-size`
// (SC 2.5.8, 24x24 CSS px minimum). Real conformance failures.
//
// 2. A custom tap-target measurement flagging interactive controls smaller than
// TAP_MIN px in either dimension. Advisory thumb-comfort (Apple HIG 44px /
// Material 48px), stricter than WCAG and catching targets axe considers compliant.
//
// Each surface's findings are written to SURFACES_DIR as JSON; globalTeardown renders
// the consolidated a11y-report.md from them.
let surfaceCount = 0;
// Let the DOM and any async lists settle. The app holds an open live-sync
// WebSocket, so `networkidle` never fires — we bound it and fall through.
export async function settle(page: Page, ms = 500) {
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {});
await page.waitForTimeout(ms);
}
// Measure every interactive control in the live DOM and return those smaller
// than `min` px in either dimension. Runs in the browser.
function collectSmallTargets(min: number): TapTarget[] {
const SELECTOR = [
'a[href]',
'button',
'input:not([type="hidden"])',
'select',
'textarea',
'summary',
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="menuitem"]',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const out: TapTarget[] = [];
const seen = new Set<Element>();
for (const el of Array.from(document.querySelectorAll(SELECTOR))) {
if (seen.has(el)) continue;
seen.add(el);
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
if ((el as HTMLButtonElement).disabled) continue;
// WCAG exempts targets rendered inline within a run of text (e.g. a link
// inside a sentence). Skip inline <a> elements to avoid false positives.
if (el.tagName === 'A' && style.display.startsWith('inline')) continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue; // not rendered
if (rect.width >= min && rect.height >= min) continue;
const id = el.id ? `#${el.id}` : '';
const rawCls = (el as HTMLElement).className;
const cls =
typeof rawCls === 'string' && rawCls.trim()
? '.' + rawCls.trim().split(/\s+/).slice(0, 2).join('.')
: '';
const aria = el.getAttribute('aria-label');
out.push({
tag: el.tagName.toLowerCase(),
text: (aria || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40),
selector: `${el.tagName.toLowerCase()}${id}${cls}`,
width: Math.round(rect.width),
height: Math.round(rect.height)
});
}
return out;
}
// Run both checks against whatever is currently rendered and record the result.
export async function audit(page: Page, testInfo: TestInfo, label: string) {
await settle(page);
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
const smallTargets = await page.evaluate(collectSmallTargets, TAP_MIN);
const surface: SurfaceResult = {
order: surfaceCount++,
label,
url: page.url(),
axe: axeResults.violations.map((v) => ({
id: v.id,
impact: v.impact,
help: v.help,
nodes: v.nodes.length,
targets: v.nodes.slice(0, 5).map((n) => n.target.join(' '))
})),
smallTargets
};
const file = path.join(SURFACES_DIR, `${label.replace(/[^a-z0-9]+/gi, '-')}.json`);
await writeFile(file, JSON.stringify(surface, null, 2), 'utf8');
await testInfo.attach(`${label} — axe violations`, {
body: JSON.stringify(axeResults.violations, null, 2),
contentType: 'application/json'
});
await testInfo.attach(`${label} — small tap targets`, {
body: JSON.stringify(smallTargets, null, 2),
contentType: 'application/json'
});
// Soft assertions: the run completes and reports every surface, but the test
// is still marked failed if anything is out of spec — useful as a CI gate.
const serious = surface.axe.filter((v) => v.impact === 'serious' || v.impact === 'critical');
expect.soft(serious, `${label}: serious/critical axe violations`).toEqual([]);
expect.soft(smallTargets, `${label}: tap targets under ${TAP_MIN}px`).toEqual([]);
}
File diff suppressed because it is too large. Load diff
+98
View File
@@ -0,0 +1,98 @@
import { deleteJson, getJson, getList, patchJson, postJson } from './http';
import type { AuthUserInfo, Level } from './auth';
// The administrator-only endpoints: /auth/user and /auth/audit. Everything here is
// refused with 403 unless the caller is signed in as an administrator, so views built
// on these must gate themselves on `auth.isAdmin` — not to enforce anything, but to
// avoid rendering a page whose every request will fail.
export interface UserCreated {
user: AuthUserInfo;
/** Present only when the server generated the password. Shown once. */
password?: string;
}
export interface UserCreateInput {
username: string;
password?: string | null;
display_name?: string | null;
level: Level;
is_admin: boolean;
must_change_password: boolean;
}
export interface UserUpdateInput {
display_name?: string | null;
level?: Level;
is_admin?: boolean;
is_active?: boolean;
}
/** Users, plus the total the server reports — which may exceed what was returned. */
export async function listUsers(signal?: AbortSignal): Promise<AuthUserInfo[]> {
const page = await getList('/auth/user', {}, signal);
return page.items as AuthUserInfo[];
}
export function createUser(input: UserCreateInput): Promise<UserCreated> {
return postJson<UserCreated>('/auth/user', input);
}
export function updateUser(id: number, input: UserUpdateInput): Promise<AuthUserInfo> {
return patchJson<AuthUserInfo>(`/auth/user/${id}`, input);
}
export function resetUserPassword(
id: number,
password: string | null,
mustChangePassword: boolean
): Promise<UserCreated> {
return postJson<UserCreated>(`/auth/user/${id}/password`, {
password,
must_change_password: mustChangePassword
});
}
export function revokeUserSessions(id: number): Promise<{ message: string }> {
return postJson<{ message: string }>(`/auth/user/${id}/revoke-sessions`, {});
}
export function deleteUser(id: number): Promise<{ message: string }> {
return deleteJson<{ message: string }>(`/auth/user/${id}`);
}
export interface AuditEntry {
id: number;
date: string;
event: string;
actor_user_id: number | null;
actor_username: string | null;
actor_kind: string;
target: string | null;
ip: string | null;
user_agent: string | null;
detail: Record<string, unknown> | null;
}
export interface AuditPage {
entries: AuditEntry[];
total: number;
}
export async function listAudit(
params: { limit: number; offset: number; event?: string },
signal?: AbortSignal
): Promise<AuditPage> {
// getList rather than getJson: the endpoint reports the unpaginated total in
// X-Total-Count, which is what drives the pager.
const page = await getList(
'/auth/audit',
{ limit: params.limit, offset: params.offset, event: params.event || undefined },
signal
);
return { entries: page.items as AuditEntry[], total: page.total };
}
export function listAuditEvents(signal?: AbortSignal): Promise<string[]> {
return getJson<string[]>('/auth/audit/events', {}, signal);
}
+120
View File
@@ -0,0 +1,120 @@
import { deleteResource, getJson, postJson } from './http';
// The /api/v1/auth endpoints. Every one of these is reachable without being signed in
// (except changePassword), because they are how a session is obtained in the first
// place. Errors here are handled by the caller rather than by the global 401 handling
// in http.ts — a rejected sign-in is an answer, not a sign-out.
export type Level = 'read' | 'edit' | 'manage';
export interface AuthOidcConfig {
enabled: boolean;
name: string | null;
}
export interface AuthConfig {
enabled: boolean;
setup_required: boolean;
anonymous_read: boolean;
oidc: AuthOidcConfig;
mtls: boolean;
}
export interface AuthUserInfo {
id: number;
username: string;
display_name: string | null;
level: Level;
is_admin: boolean;
is_owner: boolean;
is_active: boolean;
must_change_password: boolean;
}
export interface AuthSessionInfo {
authenticated: boolean;
anonymous: boolean;
level: Level;
is_admin: boolean;
is_owner: boolean;
user: AuthUserInfo | null;
}
export function getAuthConfig(signal?: AbortSignal): Promise<AuthConfig> {
return getJson<AuthConfig>('/auth/config', {}, signal);
}
export function getAuthSession(signal?: AbortSignal): Promise<AuthSessionInfo> {
return getJson<AuthSessionInfo>('/auth/session', {}, signal);
}
export function login(username: string, password: string, remember: boolean): Promise<AuthSessionInfo> {
return postJson<AuthSessionInfo>('/auth/login', { username, password, remember });
}
export function claimInstance(
username: string,
password: string,
displayName?: string
): Promise<AuthSessionInfo> {
return postJson<AuthSessionInfo>('/auth/setup', {
username,
password,
display_name: displayName || null
});
}
export function logout(): Promise<{ message: string }> {
return postJson<{ message: string }>('/auth/logout', {});
}
export function changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
return postJson<{ message: string }>('/auth/password', {
current_password: currentPassword,
new_password: newPassword
});
}
// Phase 2: API keys. Every one of these needs a signed-in session — the server
// refuses to let a key manage keys — so unlike the endpoints above, a 401 here really
// does mean the session went away and the global handling in http.ts should run.
export interface ApiKeyInfo {
id: number;
name: string;
level: Level;
/** What the key can do right now, after capping against its owner's level. */
effective_level: Level;
prefix: string;
created: string;
expires: string | null;
last_used: string | null;
revoked: boolean;
expired: boolean;
}
export interface ApiKeyCreated {
/** The complete key. The server returns this once and never again. */
key: string;
info: ApiKeyInfo;
}
export function listApiKeys(signal?: AbortSignal): Promise<ApiKeyInfo[]> {
return getJson<ApiKeyInfo[]>('/auth/apikey', {}, signal);
}
export function createApiKey(name: string, level: Level, expiresDays: number | null): Promise<ApiKeyCreated> {
return postJson<ApiKeyCreated>('/auth/apikey', {
name,
level,
expires_days: expiresDays
});
}
export function revokeApiKey(id: number): Promise<ApiKeyInfo> {
return postJson<ApiKeyInfo>(`/auth/apikey/${id}/revoke`, {});
}
export function deleteApiKey(id: number): Promise<void> {
return deleteResource(`/auth/apikey/${id}`);
}
+81 -7
View File
@@ -42,6 +42,57 @@ export class HttpError extends Error {
}
}
const SESSION_EXPIRED = 401;
const FORBIDDEN = 403;
const CSRF_COOKIE = 'spoolman_csrf';
const CSRF_HEADER = 'X-CSRF-Token';
/** Paths that own their own error handling — a failed sign-in must not read as a sign-out. */
const AUTH_PATH_PREFIX = '/auth/';
type AuthHandlers = {
/** The server says we are not signed in. */
unauthorized: () => void;
/** The server refused for lack of permission. */
forbidden: (detail: string) => void;
};
let authHandlers: AuthHandlers | null = null;
/**
* Register what to do when the server rejects a request on authentication grounds.
*
* Inverted rather than imported directly: the auth store imports this module, so
* reaching back for the store here would be a cycle.
*/
export function setAuthHandlers(handlers: AuthHandlers): void {
authHandlers = handlers;
}
/** Read a cookie by name. Only used for the CSRF token, which is deliberately readable. */
function readCookie(name: string): string {
const prefix = name + '=';
for (const part of document.cookie.split('; ')) {
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
}
return '';
}
/**
* Headers for a request that carries a body.
*
* The session cookie is HttpOnly and travels on its own, but because it is sent
* automatically it needs a second factor the browser will not supply cross-site: the
* CSRF token, which lives in a readable cookie precisely so it can be echoed here.
*/
function writeHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'content-type': 'application/json' };
const token = readCookie(CSRF_COOKIE);
if (token) headers[CSRF_HEADER] = token;
return headers;
}
async function ensureOk(res: Response, method: string, path: string): Promise<Response> {
if (!res.ok) {
let detail = '';
@@ -50,6 +101,10 @@ async function ensureOk(res: Response, method: string, path: string): Promise<Re
} catch {
/* ignore */
}
if (!path.startsWith(AUTH_PATH_PREFIX)) {
if (res.status === SESSION_EXPIRED) authHandlers?.unauthorized();
else if (res.status === FORBIDDEN) authHandlers?.forbidden(detail);
}
throw new HttpError(`${method} ${path} → ${res.status}${detail ? `: ${detail}` : ''}`, res.status);
}
return res;
@@ -71,7 +126,11 @@ export async function getList(
params: QueryParams = {},
signal?: AbortSignal
): Promise<RawPage> {
const res = await ensureOk(await fetch(API_BASE + path + queryString(params), { signal }), 'GET', path);
const res = await ensureOk(
await fetch(API_BASE + path + queryString(params), { signal, credentials: 'include' }),
'GET',
path
);
const items = (await res.json()) as unknown[];
const header = res.headers.get('x-total-count');
const total = header != null && header !== '' ? Number(header) : items.length;
@@ -83,7 +142,11 @@ export async function getJson<T = unknown>(
params: QueryParams = {},
signal?: AbortSignal
): Promise<T> {
const res = await ensureOk(await fetch(API_BASE + path + queryString(params), { signal }), 'GET', path);
const res = await ensureOk(
await fetch(API_BASE + path + queryString(params), { signal, credentials: 'include' }),
'GET',
path
);
return (await res.json()) as T;
}
@@ -91,7 +154,8 @@ export async function patchJson<T = unknown>(path: string, body: unknown): Promi
const res = await ensureOk(
await fetch(API_BASE + path, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
headers: writeHeaders(),
credentials: 'include',
body: JSON.stringify(body)
}),
'PATCH',
@@ -104,7 +168,8 @@ export async function putJson<T = unknown>(path: string, body: unknown): Promise
const res = await ensureOk(
await fetch(API_BASE + path, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
headers: writeHeaders(),
credentials: 'include',
body: JSON.stringify(body)
}),
'PUT',
@@ -117,7 +182,8 @@ export async function postJson<T = unknown>(path: string, body: unknown): Promis
const res = await ensureOk(
await fetch(API_BASE + path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: writeHeaders(),
credentials: 'include',
body: JSON.stringify(body)
}),
'POST',
@@ -127,10 +193,18 @@ export async function postJson<T = unknown>(path: string, body: unknown): Promis
}
export async function deleteResource(path: string): Promise<void> {
await ensureOk(await fetch(API_BASE + path, { method: 'DELETE' }), 'DELETE', path);
await ensureOk(
await fetch(API_BASE + path, { method: 'DELETE', headers: writeHeaders(), credentials: 'include' }),
'DELETE',
path
);
}
export async function deleteJson<T = unknown>(path: string): Promise<T> {
const res = await ensureOk(await fetch(API_BASE + path, { method: 'DELETE' }), 'DELETE', path);
const res = await ensureOk(
await fetch(API_BASE + path, { method: 'DELETE', headers: writeHeaders(), credentials: 'include' }),
'DELETE',
path
);
return (await res.json()) as T;
}
+54 -3
View File
@@ -26,6 +26,8 @@ export interface SubscribeOpts {
export interface LiveConnection {
subscribe(resource: Resource, opts: SubscribeOpts, handler: LiveHandler): () => void;
/** Reopen sockets that an auth rejection stopped, after signing back in. */
rearm(): void;
}
interface Sub {
@@ -39,6 +41,8 @@ interface ResourceSocket {
reconnectTimer: ReturnType<typeof setTimeout> | null;
pingTimer: ReturnType<typeof setInterval> | null;
closed: boolean;
/** Set when the server rejected us on auth grounds; suppresses reconnects until rearm(). */
authBlocked: boolean;
/** Consecutive failed connects; drives exponential reconnect backoff. */
attempts: number;
}
@@ -52,6 +56,22 @@ interface ResourceSocket {
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
// Close codes the server sends after accepting the handshake. 4000 + the HTTP status
// they stand in for, matching the backend's convention.
const WS_UNAUTHENTICATED = 4401;
const WS_FORBIDDEN = 4403;
let authCloseHandler: (() => void) | null = null;
/**
* Register what to do when the server closes a socket on authentication grounds.
*
* Inverted rather than importing the auth store, which would be a cycle.
*/
export function setLiveAuthCloseHandler(handler: () => void): void {
authCloseHandler = handler;
}
class WebSocketLive implements LiveConnection {
private sockets = new Map<Resource, ResourceSocket>();
@@ -84,6 +104,7 @@ class WebSocketLive implements LiveConnection {
reconnectTimer: null,
pingTimer: null,
closed: false,
authBlocked: false,
attempts: 0
};
this.sockets.set(resource, sock);
@@ -135,10 +156,25 @@ class WebSocketLive implements LiveConnection {
}, 25000);
};
ws.onclose = () => {
ws.onclose = (ev) => {
if (sock.pingTimer) clearInterval(sock.pingTimer);
sock.pingTimer = null;
sock.ws = null;
// An auth rejection is permanent until something changes, unlike a dropped
// backend. Retrying it would hammer the server on a backoff loop that can
// never succeed, so stop and let the app react. Subscribers are kept, so
// rearm() can bring every view back after a sign-in without re-subscribing.
//
// This only works because the server accepts the handshake before closing.
// A close sent before the accept is turned into an HTTP 403 by uvicorn and
// the code never arrives — see spoolman/auth/dependencies.py.
if (ev.code === WS_UNAUTHENTICATED || ev.code === WS_FORBIDDEN) {
sock.authBlocked = true;
authCloseHandler?.();
return;
}
if (!sock.closed && sock.subs.size > 0) this.scheduleReconnect(resource, sock);
};
@@ -146,7 +182,7 @@ class WebSocketLive implements LiveConnection {
}
private scheduleReconnect(resource: Resource, sock: ResourceSocket) {
if (sock.reconnectTimer) return;
if (sock.reconnectTimer || sock.authBlocked) return;
const delay = this.backoffDelay(sock.attempts);
sock.attempts++;
sock.reconnectTimer = setTimeout(() => {
@@ -166,7 +202,7 @@ class WebSocketLive implements LiveConnection {
* waiting out its backoff, and reset the backoff so a fresh drop starts fast. */
private reconnectAllNow() {
for (const [resource, sock] of this.sockets) {
if (sock.closed || sock.subs.size === 0) continue;
if (sock.closed || sock.authBlocked || sock.subs.size === 0) continue;
const rs = sock.ws?.readyState;
if (rs === WebSocket.OPEN || rs === WebSocket.CONNECTING) continue;
sock.attempts = 0;
@@ -178,6 +214,21 @@ class WebSocketLive implements LiveConnection {
}
}
/**
* Retry sockets that were stopped by an auth rejection, after a successful sign-in.
*
* Distinct from teardown(), which drops the map entry: this keeps every existing
* subscriber, so views that were already listening resume without re-subscribing.
*/
rearm() {
for (const [resource, sock] of this.sockets) {
if (!sock.authBlocked) continue;
sock.authBlocked = false;
sock.attempts = 0;
if (sock.subs.size > 0) this.open(resource, sock);
}
}
private teardown(resource: Resource) {
const sock = this.sockets.get(resource);
if (!sock) return;
+5 -5
View File
@@ -1,6 +1,6 @@
import type { SearchMatch, SearchResults } from './types';
import type { Filament, Spool, Vendor } from '$lib/types';
import { API_BASE } from './config';
import { getJson } from './http';
import { mapFilament, mapSpool, mapVendor } from './map';
import { inventory } from '$lib/stores/inventory.svelte';
@@ -50,10 +50,10 @@ export async function searchAll(
const q = query.trim();
if (!q) return EMPTY;
const params = new URLSearchParams({ q, color_similarity_threshold: String(threshold) });
const res = await fetch(`${API_BASE}/search?${params.toString()}`, { signal });
if (!res.ok) throw new Error(`GET /search → ${res.status}`);
const data = (await res.json()) as Json;
// Goes through getJson rather than a bare fetch so it picks up credentials, the
// CSRF header and 401/403 handling like every other call. A hand-rolled fetch here
// would be an authentication hole that silently returns nothing once auth is on.
const data = await getJson<Json>('/search', { q, color_similarity_threshold: threshold }, signal);
return {
spools: (data.spools ?? []).map(mapSpoolHit),
@@ -0,0 +1,86 @@
<script lang="ts">
// A checkbox with a 44px tap target and an 18px box.
//
// A native `<input type="checkbox">` sized to 18px is what the app used, and it is
// what the mobile audit flags: the *input* is the tap target, so wrapping it in a
// 44px label makes the row comfortable while leaving the control itself at 18px.
// `appearance: none` lets the input be 44px and the visible box be drawn at 18px by
// a pseudo-element, which satisfies both at once.
//
// Still a real `<input type="checkbox">` underneath, so it keeps native keyboard
// behaviour, form participation and the checkbox role for free.
let {
checked = $bindable(false),
id,
disabled = false,
label
}: {
checked?: boolean;
id?: string;
disabled?: boolean;
label: string;
} = $props();
</script>
<label class="check" class:disabled>
<input type="checkbox" {id} bind:checked {disabled} />
<span>{label}</span>
</label>
<style>
.check {
display: flex;
align-items: center;
gap: 2px;
cursor: pointer;
color: var(--text);
font-size: 0.85rem;
}
.check.disabled {
cursor: default;
opacity: 0.55;
}
input {
appearance: none;
display: grid;
place-content: center;
width: 44px;
height: 44px;
flex: none;
margin: 0;
padding: 0;
border: 0;
background: none;
cursor: inherit;
}
input::before {
content: '';
width: 18px;
height: 18px;
border: 1px solid var(--border-input);
border-radius: 4px;
background: var(--input-bg);
transition:
background 0.12s ease,
border-color 0.12s ease;
}
input:checked::before {
border-color: var(--accent);
background-color: var(--accent);
/* A tick, inlined so the component stays self-contained and needs no asset. */
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M3 8.5l3.5 3.5L13 4.5'/></svg>");
background-repeat: no-repeat;
background-position: center;
background-size: 14px 14px;
}
input:focus-visible::before {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
@@ -3,13 +3,23 @@
value: string | number;
placeholder?: string;
mono?: boolean;
/** Read-only presentation, for users without permission to edit. */
disabled?: boolean;
oninput?: (value: string) => void;
}
let { value, placeholder = '—', mono = false, oninput }: Props = $props();
let { value, placeholder = '—', mono = false, disabled = false, oninput }: Props = $props();
</script>
<input class="edit" class:mono {value} {placeholder} oninput={(e) => oninput?.(e.currentTarget.value)} />
<input
class="edit"
class:mono
{value}
{placeholder}
{disabled}
readonly={disabled}
oninput={(e) => oninput?.(e.currentTarget.value)}
/>
<style>
.edit {
@@ -24,4 +34,12 @@
.edit:focus {
border-bottom-color: var(--accent);
}
/* Read-only: drop the dashed affordance so the field does not invite an edit
that the server would refuse. */
.edit:disabled {
border-bottom-color: transparent;
color: var(--text-2);
cursor: default;
}
</style>
+20 -4
View File
@@ -3,13 +3,29 @@
import type { Pathname } from '$app/types';
import { page } from '$app/stores';
import * as m from '$lib/paraglide/messages';
import { auth } from '$lib/stores/auth.svelte';
const tabs = [
// `show` decides whether a tab appears at all. Absent means always. The auth tabs
// are hidden rather than disabled: with auth off there is no account for /account
// to describe and no users for /users to list, so the pages would be empty rather
// than merely restricted. This is cosmetic — the server gates the endpoints.
interface Tab {
href: Pathname;
label: () => string;
show?: () => boolean;
}
const tabs: Tab[] = [
{ href: '/', label: m['nav.library'] },
{ href: '/locations', label: m['locations.locations'] },
{ href: '/labels', label: m['nav.labels'] },
{ href: '/settings', label: m['settings.header'] }
] satisfies { href: Pathname; label: () => string }[];
{ href: '/settings', label: m['settings.header'] },
{ href: '/account', label: m['nav.account'], show: () => auth.enabled && auth.authenticated },
{ href: '/users', label: m['nav.users'], show: () => auth.enabled && auth.isAdmin },
{ href: '/audit', label: m['nav.audit'], show: () => auth.enabled && auth.isAdmin }
];
let visible = $derived(tabs.filter((tab) => tab.show?.() ?? true));
// The deploy base path, without its trailing slash (resolve('/') === `${base}/`).
const basePath = resolve('/').replace(/\/$/, '');
@@ -22,7 +38,7 @@
</script>
<nav class="tabs">
{#each tabs as tab (tab.href)}
{#each visible as tab (tab.href)}
<a href={resolve(tab.href)} class="tab" class:active={isActive(tab.href)}>{tab.label()}</a>
{/each}
</nav>
+9 -1
View File
@@ -3,17 +3,21 @@
checked: boolean;
onchange?: (checked: boolean) => void;
ariaLabel: string | undefined;
/** Refuse interaction, for a toggle the server would reject anyway. */
disabled?: boolean;
}
let { checked, onchange, ariaLabel }: Props = $props();
let { checked, onchange, ariaLabel, disabled = false }: Props = $props();
</script>
<button
class="toggle"
class:on={checked}
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
{disabled}
onclick={() => onchange?.(!checked)}
>
<span class="knob"></span>
@@ -35,6 +39,10 @@
.toggle.on {
background: var(--accent);
}
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.knob {
position: absolute;
top: 2.5px;
+87 -11
View File
@@ -4,7 +4,9 @@
import SearchBox from './library/SearchBox.svelte';
import Button from './Button.svelte';
import * as m from '$lib/paraglide/messages';
import { auth } from '$lib/stores/auth.svelte';
import Plus from '@lucide/svelte/icons/plus';
import LogOut from '@lucide/svelte/icons/log-out';
import ScanLine from '@lucide/svelte/icons/scan-line';
import Search from '@lucide/svelte/icons/search';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
@@ -52,20 +54,44 @@
<button class="scan-btn" onclick={onscan} aria-label={m['scanner.title']()} title={m['scanner.title']()}>
<ScanLine size={18} />
</button>
<button
class="add-mobile"
onclick={onadd}
aria-label={m['topbar.addSpools']()}
title={m['topbar.addSpools']()}
>
<Plus size={18} />
</button>
{#if auth.canManage}
<button
class="add-mobile"
onclick={onadd}
aria-label={m['topbar.addSpools']()}
title={m['topbar.addSpools']()}
>
<Plus size={18} />
</button>
{/if}
<div class="search-desktop">
<SearchBox />
</div>
<div class="add-desktop">
<Button onclick={onadd}><Plus size={15} /> {m['topbar.addSpools']()}</Button>
</div>
{#if auth.canManage}
<div class="add-desktop">
<Button onclick={onadd}><Plus size={15} /> {m['topbar.addSpools']()}</Button>
</div>
{/if}
<!-- Only rendered when the server actually enforces auth, so the default
configuration keeps the bar exactly as it was. -->
{#if auth.enabled && auth.authenticated}
<div class="account">
<span class="who" title={m['auth.signedInAs']({ name: auth.displayName })}>
{auth.displayName}
</span>
<button
class="signout"
onclick={() => auth.signOut()}
aria-label={m['auth.signOut']()}
title={m['auth.signOut']()}
>
<LogOut size={16} />
</button>
</div>
{:else if auth.enabled && auth.anonymous}
<span class="badge">{m['auth.anonymousBadge']()}</span>
{/if}
<!-- Mobile: the expanded search overlays the row so the typed query is visible. -->
<div class="search-overlay" bind:this={overlayEl}>
@@ -219,4 +245,54 @@
overflow-x: auto;
}
}
.account {
display: flex;
align-items: center;
gap: 6px;
margin-left: 8px;
}
.who {
max-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-2);
font-size: 0.82rem;
}
.signout {
display: grid;
place-items: center;
/* 44px keeps the mobile a11y audit's tap-target check green. */
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
}
.signout:hover {
background: var(--surface-raised);
color: var(--text);
}
.badge {
margin-left: 8px;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.75rem;
white-space: nowrap;
}
@media (max-width: 860px) {
.who {
display: none;
}
}
</style>
@@ -0,0 +1,384 @@
<script lang="ts">
import Card from '$components/Card.svelte';
import Button from '$components/Button.svelte';
import SecretReveal from './SecretReveal.svelte';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Ban from '@lucide/svelte/icons/ban';
import {
createApiKey,
deleteApiKey,
listApiKeys,
revokeApiKey,
type ApiKeyCreated,
type ApiKeyInfo,
type Level
} from '$lib/api/auth';
import { auth } from '$lib/stores/auth.svelte';
import { isAbortError } from '$lib/api/http';
import { formatDateTime } from '$lib/utils/datetime';
import { grantableLevels, levelDescription, levelLabel } from '$lib/utils/levels';
import * as m from '$lib/paraglide/messages';
// The account page's key manager: list, create, revoke, delete.
//
// The one thing this screen must get right is that a new key's secret is shown
// once. The server hashes it and cannot produce it again, so the reveal panel stays
// up until the user explicitly dismisses it rather than closing on its own.
const EXPIRY_CHOICES: (number | null)[] = [30, 90, 365, null];
let keys = $state<ApiKeyInfo[]>([]);
let loading = $state(true);
let failed = $state(false);
let creating = $state(false);
let submitting = $state(false);
let name = $state('');
let level = $state<Level>('read');
let expiresDays = $state<number | null>(90);
let formError = $state('');
// The plaintext key, held only until dismissed. Never written anywhere else.
let revealed = $state<ApiKeyCreated | null>(null);
$effect(() => {
const controller = new AbortController();
load(controller.signal);
return () => controller.abort();
});
async function load(signal?: AbortSignal) {
loading = true;
try {
keys = await listApiKeys(signal);
failed = false;
} catch (e) {
if (isAbortError(e, signal)) return;
console.error('Failed to load API keys', e);
failed = true;
} finally {
loading = false;
}
}
function openForm() {
name = '';
// Default to the weakest level rather than the user's own. A key should be as
// small as the job needs, and the common case really is a read-only scraper.
level = 'read';
expiresDays = 90;
formError = '';
creating = true;
}
async function submit() {
if (submitting) return;
if (!name.trim()) {
formError = m['account.keyNameRequired']();
return;
}
submitting = true;
formError = '';
try {
revealed = await createApiKey(name.trim(), level, expiresDays);
creating = false;
await load();
} catch (e) {
console.error('Failed to create API key', e);
formError = m['account.keysFailed']();
} finally {
submitting = false;
}
}
async function revoke(key: ApiKeyInfo) {
if (!confirm(m['account.keyRevokeConfirm']({ name: key.name }))) return;
try {
await revokeApiKey(key.id);
await load();
} catch (e) {
console.error('Failed to revoke API key', e);
}
}
async function remove(key: ApiKeyInfo) {
if (!confirm(m['account.keyDeleteConfirm']({ name: key.name }))) return;
try {
await deleteApiKey(key.id);
await load();
} catch (e) {
console.error('Failed to delete API key', e);
}
}
function statusOf(key: ApiKeyInfo): string {
if (key.revoked) return m['account.keyRevoked']();
if (key.expired) return m['account.keyExpired']();
return m['account.keyActive']();
}
</script>
{#if revealed}
<SecretReveal
title={m['account.keyCreated']()}
value={revealed.key}
warning={m['account.keyCreatedWarning']()}
ondismiss={() => (revealed = null)}
/>
{/if}
<Card divided>
{#if loading}
<div class="empty">{m['auth.loading']()}…</div>
{:else if failed}
<div class="empty">{m['account.keysFailed']()}</div>
{:else if keys.length === 0}
<div class="empty">{m['account.keysEmpty']()}</div>
{:else}
{#each keys as key (key.id)}
<div class="key" class:inactive={key.revoked || key.expired}>
<div class="key-main">
<div class="key-name">
{key.name}
<span class="badge" class:dim={key.revoked || key.expired}>{statusOf(key)}</span>
</div>
<div class="key-meta mono">{key.prefix}</div>
<div class="key-meta">
{m['account.keyCreatedOn']({ date: formatDateTime(key.created) })}
{#if key.last_used}
· {m['account.keyLastUsed']({ date: formatDateTime(key.last_used) })}
{:else}
· {m['account.keyNeverUsed']()}
{/if}
{#if key.expires}
· {m['account.keyExpiresOn']({ date: formatDateTime(key.expires) })}
{/if}
</div>
<div class="key-meta">
{levelLabel(key.effective_level)}
{#if key.effective_level !== key.level}
<!-- The key outranks its owner, so the server is capping it. Saying
so beats silently showing a level it does not actually have. -->
<span class="capped"
>{m['account.keyCappedNotice']({ level: levelLabel(key.effective_level) })}</span
>
{/if}
</div>
</div>
<div class="key-actions">
{#if !key.revoked}
<button
type="button"
class="icon"
onclick={() => revoke(key)}
aria-label={m['account.keyRevoke']()}
title={m['account.keyRevoke']()}
>
<Ban size={15} />
</button>
{/if}
<button
type="button"
class="icon danger"
onclick={() => remove(key)}
aria-label={m['account.keyDelete']()}
title={m['account.keyDelete']()}
>
<Trash2 size={15} />
</button>
</div>
</div>
{/each}
{/if}
</Card>
{#if creating}
<Card>
<form
class="form"
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label for="key-name">{m['account.keyName']()}</label>
<input
id="key-name"
type="text"
bind:value={name}
placeholder={m['account.keyNamePlaceholder']()}
disabled={submitting}
maxlength="64"
/>
<label for="key-level">{m['account.keyLevel']()}</label>
<select id="key-level" bind:value={level} disabled={submitting}>
{#each grantableLevels(auth.level) as option (option)}
<option value={option}>{levelLabel(option)}</option>
{/each}
</select>
<p class="hint">{levelDescription(level)}</p>
<label for="key-expiry">{m['account.keyExpiry']()}</label>
<select id="key-expiry" bind:value={expiresDays} disabled={submitting}>
{#each EXPIRY_CHOICES as choice (choice ?? 'never')}
<option value={choice}>
{choice === null ? m['account.keyExpiryNever']() : m['account.keyExpiryDays']({ count: choice })}
</option>
{/each}
</select>
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
<div class="form-actions">
<Button type="submit" variant="primary" disabled={submitting}>
{m['account.keyCreate']()}
</Button>
<Button variant="ghost" onclick={() => (creating = false)}>{m['buttons.cancel']()}</Button>
</div>
</form>
</Card>
{:else}
<div class="add"><Button variant="outline" onclick={openForm}>{m['account.newKey']()}</Button></div>
{/if}
<style>
.empty {
padding: 16px;
font-size: 12.5px;
color: var(--text-dim);
}
.key {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
}
.key.inactive {
opacity: 0.6;
}
.key-main {
flex: 1;
min-width: 0;
}
.key-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.key-meta {
margin-top: 2px;
font-size: 11.5px;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.capped {
margin-left: 6px;
color: var(--warning, var(--text-muted));
}
.badge {
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--accent-wash);
color: var(--accent-soft);
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge.dim {
background: var(--surface-sunken);
color: var(--text-dim);
}
.key-actions {
display: flex;
gap: 2px;
}
.icon {
display: grid;
place-items: center;
/* 44px, matching PasswordInput: the auth surfaces are audited against the
advisory thumb-comfort minimum, not just WCAG's 24px. */
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
}
.icon:hover {
background: var(--surface-sunken);
color: var(--text);
}
.icon.danger:hover {
color: var(--danger);
}
.form {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
}
.form label {
margin-top: 6px;
font-size: 0.8rem;
color: var(--text-2);
}
.form input,
.form select {
height: 44px;
padding: 0 10px;
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
background: var(--input-bg);
color: var(--text);
font: inherit;
font-size: 12.5px;
outline: none;
}
.form input:focus,
.form select:focus {
border-color: var(--accent);
}
.hint {
margin: 2px 0 0;
font-size: 11.5px;
color: var(--text-dim);
}
.error {
margin: 4px 0 0;
color: var(--danger);
font-size: 0.85rem;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.add {
margin-top: 10px;
}
</style>
@@ -0,0 +1,44 @@
<script lang="ts">
import Logo from '$components/Logo.svelte';
import type { Snippet } from 'svelte';
// Shared frame for the sign-in and claim screens. These render in place of the
// normal shell, so they bring their own centring rather than inheriting the app
// layout's flex row.
let { children }: { children: Snippet } = $props();
</script>
<div class="screen">
<div class="card">
<div class="brand"><Logo /></div>
{@render children()}
</div>
</div>
<style>
.screen {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 24px 16px;
overflow-y: auto;
background: var(--bg);
}
.card {
width: 100%;
max-width: 380px;
padding: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.brand {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
import PasswordInput from './PasswordInput.svelte';
import Button from '$components/Button.svelte';
import { changePassword } from '$lib/api/auth';
import { auth } from '$lib/stores/auth.svelte';
import { HttpError } from '$lib/api/http';
import * as m from '$lib/paraglide/messages';
// Shared by the account page and the forced-change screen, which differ only in
// their surroundings: the same three fields, the same validation, the same call.
// Mirrors MIN_PASSWORD_LENGTH in spoolman/api/v1/models.py. Checked here only so
// the user hears about it before a round trip; the server is the authority.
const MIN_LENGTH = 10;
const BAD_REQUEST = 400;
let { onsuccess }: { onsuccess?: () => void } = $props();
let current = $state('');
let next = $state('');
let confirm = $state('');
let submitting = $state(false);
let error = $state('');
let done = $state(false);
async function submit() {
if (submitting) return;
error = '';
done = false;
if (next.length < MIN_LENGTH) {
error = m['auth.passwordTooShort']({ min: MIN_LENGTH });
return;
}
if (next !== confirm) {
error = m['auth.passwordMismatch']();
return;
}
submitting = true;
try {
await changePassword(current, next);
// Re-read the session so must_change_password clears and the forced screen
// gives way. Nothing else tells the store the flag is gone.
await auth.load();
current = next = confirm = '';
done = true;
onsuccess?.();
} catch (e) {
// 400 is the server's answer for a wrong current password, and it is worth
// distinguishing: it is the one failure the user can act on directly.
if (e instanceof HttpError && e.status === BAD_REQUEST) error = m['auth.passwordIncorrect']();
else error = m['auth.passwordChangeFailed']();
} finally {
submitting = false;
}
}
</script>
<form
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label for="pw-current">{m['auth.currentPassword']()}</label>
<PasswordInput id="pw-current" bind:value={current} autocomplete="current-password" disabled={submitting} />
<label for="pw-new">{m['auth.newPassword']()}</label>
<PasswordInput id="pw-new" bind:value={next} autocomplete="new-password" disabled={submitting} />
<label for="pw-confirm">{m['auth.confirmPassword']()}</label>
<PasswordInput id="pw-confirm" bind:value={confirm} autocomplete="new-password" disabled={submitting} />
{#if error}
<p class="error" role="alert">{error}</p>
{:else if done}
<p class="ok" role="status">{m['auth.passwordChanged']()}</p>
{/if}
<Button type="submit" variant="primary" disabled={submitting}>
{m['auth.changePassword']()}
</Button>
</form>
<style>
form {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
margin-top: 8px;
font-size: 0.8rem;
color: var(--text-2);
}
.error {
margin: 4px 0 0;
color: var(--danger);
font-size: 0.85rem;
}
.ok {
margin: 4px 0 0;
color: var(--text-2);
font-size: 0.85rem;
}
form :global(button[type='submit']) {
margin-top: 12px;
align-self: flex-start;
min-height: 44px;
}
</style>
@@ -0,0 +1,59 @@
<script lang="ts">
import AuthCard from './AuthCard.svelte';
import ChangePasswordForm from './ChangePasswordForm.svelte';
import { auth } from '$lib/stores/auth.svelte';
import * as m from '$lib/paraglide/messages';
// Shown in place of the app when the signed-in user's password was set by somebody
// else. Phase 1 could set must_change_password from the CLI and clear it through
// POST /auth/password, but nothing ever made the user go through it, so the flag
// was advisory. This is what makes it mean something.
//
// There is deliberately no way past this screen other than changing the password or
// signing out. A "remind me later" would defeat the purpose: the administrator who
// set the temporary password knows it.
</script>
<AuthCard>
<h1>{m['auth.mustChangeTitle']()}</h1>
<p class="sub">{m['auth.mustChangeIntro']()}</p>
<ChangePasswordForm />
<button class="signout" type="button" onclick={() => auth.signOut()}>
{m['auth.signOut']()}
</button>
</AuthCard>
<style>
h1 {
margin: 0 0 4px;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
}
.sub {
margin: 0 0 12px;
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
}
.signout {
display: block;
width: 100%;
min-height: 44px;
margin-top: 12px;
border: 0;
background: none;
color: var(--text-muted);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.signout:hover {
color: var(--text);
}
</style>
@@ -0,0 +1,137 @@
<script lang="ts">
import AuthCard from './AuthCard.svelte';
import PasswordInput from './PasswordInput.svelte';
import Button from '$components/Button.svelte';
import Checkbox from '$components/Checkbox.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { HttpError } from '$lib/api/http';
import * as m from '$lib/paraglide/messages';
// A real <form> with a submit button, unlike the rest of the app, which is
// change-on-blur auto-save. That is what gives Enter-to-submit and lets password
// managers recognise and offer to save the credentials.
const UNAUTHORIZED = 401;
const TOO_MANY_REQUESTS = 429;
let username = $state('');
let password = $state('');
let remember = $state(false);
let submitting = $state(false);
let error = $state('');
async function submit() {
if (submitting) return;
error = '';
if (!username.trim()) {
error = m['auth.usernameRequired']();
return;
}
submitting = true;
try {
await auth.signIn(username, password, remember);
password = '';
} catch (e) {
if (e instanceof HttpError && e.status === UNAUTHORIZED) error = m['auth.invalidCredentials']();
else if (e instanceof HttpError && e.status === TOO_MANY_REQUESTS) error = m['auth.tooManyAttempts']();
else error = m['auth.signInFailed']();
password = '';
} finally {
submitting = false;
}
}
</script>
<AuthCard>
<h1>{m['auth.loginTitle']()}</h1>
<p class="sub">{m['auth.loginSubtitle']()}</p>
<form
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label for="login-username">{m['auth.username']()}</label>
<input
id="login-username"
type="text"
bind:value={username}
autocomplete="username"
autocapitalize="off"
spellcheck="false"
disabled={submitting}
/>
<label for="login-password">{m['auth.password']()}</label>
<PasswordInput id="login-password" bind:value={password} disabled={submitting} />
<Checkbox bind:checked={remember} disabled={submitting} label={m['auth.rememberMe']()} />
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<Button type="submit" variant="primary" disabled={submitting}>
{submitting ? m['auth.signingIn']() + '…' : m['auth.signIn']()}
</Button>
</form>
</AuthCard>
<style>
h1 {
margin: 0 0 4px;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
}
.sub {
margin: 0 0 20px;
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
}
form {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-size: 0.8rem;
color: var(--text-2);
}
label {
margin-top: 8px;
}
input[type='text'] {
height: 44px;
padding: 0 10px;
background: var(--input-bg);
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
outline: none;
}
input[type='text']:focus {
border-color: var(--accent);
}
.error {
margin: 4px 0 0;
color: var(--danger);
font-size: 0.85rem;
}
form :global(button[type='submit']) {
margin-top: 12px;
justify-content: center;
min-height: 44px;
}
</style>
@@ -0,0 +1,111 @@
<script lang="ts">
import Eye from '@lucide/svelte/icons/eye';
import EyeOff from '@lucide/svelte/icons/eye-off';
import * as m from '$lib/paraglide/messages';
// The app has no password field anywhere else — everything else is change-on-blur
// auto-save, so this is the first real form control. The reveal toggle is a plain
// button rather than an icon-only affordance so it carries a label for screen
// readers, and it is sized to the 44px minimum the mobile a11y audit enforces.
let {
value = $bindable(''),
id,
autocomplete = 'current-password',
placeholder = '',
disabled = false,
invalid = false
}: {
value?: string;
id: string;
autocomplete?: 'current-password' | 'new-password';
placeholder?: string;
disabled?: boolean;
invalid?: boolean;
} = $props();
let revealed = $state(false);
</script>
<div class="wrap" class:invalid>
{#if revealed}
<input
{id}
type="text"
bind:value
{autocomplete}
{placeholder}
{disabled}
aria-invalid={invalid}
spellcheck="false"
autocapitalize="off"
/>
{:else}
<input {id} type="password" bind:value {autocomplete} {placeholder} {disabled} aria-invalid={invalid} />
{/if}
<button
type="button"
class="reveal"
onclick={() => (revealed = !revealed)}
aria-label={revealed ? m['auth.hidePassword']() : m['auth.showPassword']()}
aria-pressed={revealed}
{disabled}
>
{#if revealed}
<EyeOff size={16} />
{:else}
<Eye size={16} />
{/if}
</button>
</div>
<style>
.wrap {
display: flex;
align-items: stretch;
background: var(--input-bg);
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
overflow: hidden;
}
.wrap:focus-within {
border-color: var(--accent);
}
.wrap.invalid {
border-color: var(--danger);
}
input {
flex: 1;
min-width: 0;
padding: 0 10px;
height: 44px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
outline: none;
}
.reveal {
display: grid;
place-items: center;
width: 44px;
flex: 0 0 44px;
border: 0;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.reveal:hover:not(:disabled) {
color: var(--text);
}
.reveal:disabled {
cursor: default;
opacity: 0.5;
}
</style>
@@ -0,0 +1,117 @@
<script lang="ts">
import Card from '$components/Card.svelte';
import Button from '$components/Button.svelte';
import Copy from '@lucide/svelte/icons/copy';
import { toasts } from '$lib/stores/toasts.svelte';
import * as m from '$lib/paraglide/messages';
// Shows a value the server will never send again — a new API key, or a generated
// password. Two rules follow from that and are the reason this is a component
// rather than a toast:
//
// It never auto-dismisses. A toast that times out while the user is fetching their
// password manager loses the only copy of the secret.
//
// It is never logged or stored. The value lives in the caller's state until
// dismissed and nowhere else.
let {
title,
value,
warning,
ondismiss
}: {
title: string;
value: string;
warning: string;
ondismiss: () => void;
} = $props();
async function copy() {
try {
await navigator.clipboard.writeText(value);
toasts.success(m['account.keyCopied']());
} catch {
// The clipboard API is unavailable outside a secure context, which is exactly
// the plain-HTTP homelab this feature serves. Say so rather than doing
// nothing; the value is selectable, so manual copying still works.
toasts.error(m['account.keyCopyFailed']());
}
}
</script>
<Card>
<div class="reveal">
<div class="reveal-title">{title}</div>
<p class="warn">{warning}</p>
<div class="secret">
<code>{value}</code>
<button
type="button"
class="icon"
onclick={copy}
aria-label={m['account.keyCopy']()}
title={m['account.keyCopy']()}
>
<Copy size={15} />
</button>
</div>
<Button variant="outline" onclick={ondismiss}>{m['account.keyDone']()}</Button>
</div>
</Card>
<style>
.reveal {
padding: 14px 16px;
}
.reveal-title {
font-size: 13px;
font-weight: 600;
}
.warn {
margin: 4px 0 10px;
font-size: 12px;
line-height: 1.5;
color: var(--warning, var(--text-2));
}
.secret {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 8px 10px;
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
background: var(--input-bg);
}
.secret code {
flex: 1;
min-width: 0;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
overflow-wrap: anywhere;
user-select: all;
}
.icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: none;
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
}
.icon:hover {
background: var(--surface-sunken);
color: var(--text);
}
</style>
@@ -0,0 +1,191 @@
<script lang="ts">
import AuthCard from './AuthCard.svelte';
import PasswordInput from './PasswordInput.svelte';
import Button from '$components/Button.svelte';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import { auth } from '$lib/stores/auth.svelte';
import { HttpError } from '$lib/api/http';
import * as m from '$lib/paraglide/messages';
// Shown while the instance is unclaimed. Whoever submits this becomes the owner, so
// the warning is prominent rather than tucked away — an instance left in this state
// on a reachable network is claimable by anyone who finds it.
// Must match MIN_PASSWORD_LENGTH in spoolman/api/v1/models.py, which is what
// actually enforces this; checking here only avoids a pointless round trip.
const MIN_PASSWORD = 10;
const CONFLICT = 409;
let username = $state('');
let displayName = $state('');
let password = $state('');
let confirm = $state('');
let submitting = $state(false);
let error = $state('');
async function submit() {
if (submitting) return;
error = '';
if (!username.trim()) {
error = m['auth.usernameRequired']();
return;
}
if (password.length < MIN_PASSWORD) {
error = m['auth.passwordTooShort']({ min: MIN_PASSWORD });
return;
}
if (password !== confirm) {
error = m['auth.passwordMismatch']();
return;
}
submitting = true;
try {
await auth.claim(username, password, displayName.trim() || undefined);
password = '';
confirm = '';
} catch (e) {
error =
e instanceof HttpError && e.status === CONFLICT
? m['auth.setupAlreadyClaimed']()
: m['auth.setupFailed']();
} finally {
submitting = false;
}
}
</script>
<AuthCard>
<h1>{m['auth.setupTitle']()}</h1>
<p class="sub">{m['auth.setupIntro']()}</p>
<p class="warn">
<TriangleAlert size={16} />
<span>{m['auth.setupWarning']()}</span>
</p>
<form
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label for="setup-username">{m['auth.username']()}</label>
<input
id="setup-username"
type="text"
bind:value={username}
autocomplete="username"
autocapitalize="off"
spellcheck="false"
disabled={submitting}
/>
<label for="setup-display">{m['auth.displayNameOptional']()}</label>
<input
id="setup-display"
type="text"
bind:value={displayName}
autocomplete="name"
disabled={submitting}
/>
<label for="setup-password">{m['auth.password']()}</label>
<PasswordInput
id="setup-password"
bind:value={password}
autocomplete="new-password"
disabled={submitting}
/>
<label for="setup-confirm">{m['auth.confirmPassword']()}</label>
<PasswordInput
id="setup-confirm"
bind:value={confirm}
autocomplete="new-password"
disabled={submitting}
invalid={confirm.length > 0 && confirm !== password}
/>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<Button type="submit" variant="primary" disabled={submitting}>
{submitting ? m['auth.signingIn']() + '…' : m['auth.setupClaim']()}
</Button>
</form>
</AuthCard>
<style>
h1 {
margin: 0 0 4px;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
}
.sub {
margin: 0 0 14px;
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
}
.warn {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 0 0 16px;
padding: 10px;
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.8rem;
line-height: 1.4;
}
.warn :global(svg) {
flex: 0 0 auto;
margin-top: 1px;
color: var(--danger);
}
form {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
margin-top: 8px;
font-size: 0.8rem;
color: var(--text-2);
}
input[type='text'] {
height: 44px;
padding: 0 10px;
background: var(--input-bg);
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
outline: none;
}
input[type='text']:focus {
border-color: var(--accent);
}
.error {
margin: 4px 0 0;
color: var(--danger);
font-size: 0.85rem;
}
form :global(button[type='submit']) {
margin-top: 12px;
justify-content: center;
min-height: 44px;
}
</style>
@@ -28,6 +28,7 @@
import { makeSaver, makeExtraSaver } from '$lib/utils/saver';
import { trackSave } from '$lib/utils/autosave';
import * as m from '$lib/paraglide/messages';
import { auth } from '$lib/stores/auth.svelte';
let { filament }: { filament: Filament } = $props();
@@ -189,10 +190,11 @@
<SectionLabel>{m['inspector.specs']()}</SectionLabel>
<FieldGrid>
<Field label={m['filament.fields.name']()}>
<EditableField value={filament.name} oninput={(v) => set({ name: v })} />
<EditableField disabled={!auth.canEdit} value={filament.name} oninput={(v) => set({ name: v })} />
</Field>
<Field label={m['filament.fields.material']()}>
<Combobox
disabled={!auth.canEdit}
value={filament.material}
options={materialNames}
underline
@@ -201,15 +203,20 @@
</Field>
<Field label={m['filament.fields.colorHex']()}>
{#key filament.id}
<ColorEditor
colors={filament.colors}
direction={filament.multiColorDirection}
onchange={(v) => set({ colors: v.colors, multiColorDirection: v.direction })}
/>
<!-- ColorEditor has no disabled prop of its own; inert takes the whole
subtree out of interaction without having to add one. -->
<div class="gate" inert={!auth.canEdit}>
<ColorEditor
colors={filament.colors}
direction={filament.multiColorDirection}
onchange={(v) => set({ colors: v.colors, multiColorDirection: v.direction })}
/>
</div>
{/key}
</Field>
<Field label={m['filament.fields.diameter']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="200px"
unit="mm"
@@ -221,6 +228,7 @@
</Field>
<Field label={m['filament.fields.density']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="200px"
unit="g/cm³"
@@ -232,6 +240,7 @@
</Field>
<Field label={m['filament.fields.weight']()} help={m['filament.fieldsHelp.weight']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="200px"
unit="g"
@@ -243,6 +252,7 @@
</Field>
<Field label={m['filament.fields.spoolWeight']()} help={m['filament.fieldsHelp.spoolWeight']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="200px"
unit="g"
@@ -256,6 +266,7 @@
</Field>
<Field label={m['filament.fields.settingsExtruderTemp']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="200px"
unit="°C"
@@ -267,6 +278,7 @@
</Field>
<Field label={m['filament.fields.settingsBedTemp']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="200px"
unit="°C"
@@ -278,6 +290,7 @@
</Field>
<Field label={m['filament.fields.price']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="200px"
unit={settings.currency}
@@ -289,6 +302,7 @@
</Field>
<Field label={m['filament.fields.articleNumber']()} help={m['filament.fieldsHelp.articleNumber']()}>
<EditableField
disabled={!auth.canEdit}
value={filament.articleNumber ?? ''}
mono
oninput={(v) => set({ articleNumber: v })}
@@ -296,11 +310,20 @@
</Field>
<Field label={m['filament.fields.registered']()}>{filament.registeredLabel}</Field>
<Field label={m['filament.fields.comment']()}>
<EditableField value={filament.comment} oninput={(v) => set({ comment: v })} />
<EditableField
disabled={!auth.canEdit}
value={filament.comment}
oninput={(v) => set({ comment: v })}
/>
</Field>
</FieldGrid>
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={extraSaver.change} />
<ExtraFieldsSection
readonly={!auth.canEdit}
entity="filament"
extra={filament.extra}
onchange={extraSaver.change}
/>
</div>
</div>
</div>
@@ -429,4 +452,9 @@
flex-basis: 100%;
}
}
/* Wrapper exists only to carry `inert`; it must not affect layout. */
.gate {
display: contents;
}
</style>
@@ -26,6 +26,7 @@
import { page } from '$app/state';
import { resolve } from '$app/paths';
import * as m from '$lib/paraglide/messages';
import { auth } from '$lib/stores/auth.svelte';
let { spool }: { spool: Spool } = $props();
@@ -265,7 +266,7 @@
bind:value={adjustVal}
unit={adjustInfo.unit}
step={0.01}
disabled={adjustBusy}
disabled={adjustBusy || !auth.canEdit}
width="130px"
/>
<Button onclick={applyAdjust} disabled={adjustBusy}
@@ -287,6 +288,7 @@
<FieldGrid>
<Field label={m['spool.fields.location']()} help={m['spool.fieldsHelp.location']()}>
<Combobox
disabled={!auth.canEdit}
value={spool.location}
placeholder={m['library.noLocation']()}
options={locationOptions}
@@ -295,10 +297,11 @@
/>
</Field>
<Field label={m['spool.fields.lotNr']()} help={m['spool.fieldsHelp.lotNr']()}>
<EditableField value={spool.lot} mono oninput={(v) => set({ lot: v })} />
<EditableField disabled={!auth.canEdit} value={spool.lot} mono oninput={(v) => set({ lot: v })} />
</Field>
<Field label={m['spool.fields.price']()} help={m['spool.fieldsHelp.price']()}>
<NumberInput
disabled={!auth.canEdit}
dense
width="240px"
unit={settings.currencySymbol}
@@ -319,11 +322,20 @@
<DateTimeField value={spool.lastUsed} oninput={(iso) => set({ lastUsed: iso })} />
</Field>
<Field label={m['spool.fields.comment']()}>
<EditableField value={spool.comment} oninput={(v) => set({ comment: v })} />
<EditableField
disabled={!auth.canEdit}
value={spool.comment}
oninput={(v) => set({ comment: v })}
/>
</Field>
</FieldGrid>
<ExtraFieldsSection entity="spool" extra={spool.extra} onchange={extraSaver.change} />
<ExtraFieldsSection
readonly={!auth.canEdit}
entity="spool"
extra={spool.extra}
onchange={extraSaver.change}
/>
</div>
<div class="col">
@@ -364,6 +376,8 @@
{/if}
</FieldGrid>
<!-- Already read-only regardless of permission: this shows the parent filament's
fields from inside the spool inspector, which are edited on the filament. -->
<ExtraFieldsSection entity="filament" extra={filament.extra} onchange={() => {}} readonly />
</div>
</div>
@@ -14,6 +14,7 @@
import { makeSaver, makeExtraSaver } from '$lib/utils/saver';
import { trackSave } from '$lib/utils/autosave';
import * as m from '$lib/paraglide/messages';
import { auth } from '$lib/stores/auth.svelte';
let { vendor }: { vendor: Vendor } = $props();
@@ -81,10 +82,11 @@
<SectionLabel>{m['filament.fields.vendor']()}</SectionLabel>
<FieldGrid labelWidth="140px">
<Field label={m['vendor.fields.name']()}>
<EditableField value={vendor.name} oninput={(v) => set({ name: v })} />
<EditableField disabled={!auth.canEdit} value={vendor.name} oninput={(v) => set({ name: v })} />
</Field>
<Field label={m['vendor.fields.emptySpoolWeight']()} help={m['vendor.fieldsHelp.emptySpoolWeight']()}>
<NumberInput
disabled={!auth.canEdit}
dense
unit="g"
step={10}
@@ -96,11 +98,20 @@
</Field>
<Field label={m['vendor.fields.registered']()}>{vendor.registeredLabel}</Field>
<Field label={m['vendor.fields.comment']()}>
<EditableField value={vendor.comment} oninput={(v) => set({ comment: v })} />
<EditableField
disabled={!auth.canEdit}
value={vendor.comment}
oninput={(v) => set({ comment: v })}
/>
</Field>
</FieldGrid>
<ExtraFieldsSection entity="vendor" extra={vendor.extra} onchange={extraSaver.change} />
<ExtraFieldsSection
readonly={!auth.canEdit}
entity="vendor"
extra={vendor.extra}
onchange={extraSaver.change}
/>
</div>
<div class="col">
<SectionLabel>{m['filament.filament']()}</SectionLabel>
+175
View File
@@ -0,0 +1,175 @@
import {
claimInstance,
getAuthConfig,
getAuthSession,
login,
logout,
type AuthUserInfo,
type Level
} from '$lib/api/auth';
import { setAuthHandlers } from '$lib/api/http';
import { setLiveAuthCloseHandler } from '$lib/api/live';
import { toasts } from './toasts.svelte';
import * as m from '$lib/paraglide/messages';
// Who is signed in, and what they may do.
//
// The important property: when the server reports authentication is disabled, every
// capability getter answers true. That is what keeps the default configuration
// pixel-identical to an instance built before auth existed — no call site needs to know
// whether auth is on, and nothing has to be conditionally rendered for the common case.
//
// Gating here is cosmetic. The server is the authority; this only avoids showing people
// buttons that would fail.
const RANKS: Record<Level, number> = { read: 0, edit: 1, manage: 2 };
class Auth {
/** Whether the server enforces authentication at all. */
enabled = $state(false);
/** Whether the first load has finished. Nothing should render until this is true. */
ready = $state(false);
/** Whether the instance is unclaimed and awaiting an owner. */
setupRequired = $state(false);
/** Whether the server grants unauthenticated read access. */
anonymousRead = $state(false);
authenticated = $state(false);
anonymous = $state(false);
level = $state<Level>('manage');
isAdmin = $state(true);
isOwner = $state(true);
user = $state<AuthUserInfo | null>(null);
// Unlike the other stores, load() can be re-entered — by a sign-in, a sign-out, or a
// 401 arriving mid-flight — so concurrent calls share one request.
#inflight: Promise<void> | null = null;
async load(): Promise<void> {
this.#inflight ??= this.#load().finally(() => {
this.#inflight = null;
});
return this.#inflight;
}
async #load(): Promise<void> {
try {
const config = await getAuthConfig();
this.enabled = config.enabled;
this.setupRequired = config.setup_required;
this.anonymousRead = config.anonymous_read;
if (!config.enabled) {
this.#applyDisabled();
return;
}
this.#apply(await getAuthSession());
} catch (e) {
// Reaching neither endpoint means we cannot know. Assume the restrictive
// answer rather than rendering an interface the server will refuse.
console.error('Failed to load authentication state', e);
this.#applySignedOut();
} finally {
this.ready = true;
}
}
#applyDisabled(): void {
this.authenticated = true;
this.anonymous = false;
this.level = 'manage';
this.isAdmin = true;
this.isOwner = true;
this.user = null;
}
#applySignedOut(): void {
this.authenticated = false;
this.anonymous = false;
this.level = 'read';
this.isAdmin = false;
this.isOwner = false;
this.user = null;
}
#apply(session: {
authenticated: boolean;
anonymous: boolean;
level: Level;
is_admin: boolean;
is_owner: boolean;
user: AuthUserInfo | null;
}): void {
this.authenticated = session.authenticated;
this.anonymous = session.anonymous;
this.level = session.level;
this.isAdmin = session.is_admin;
this.isOwner = session.is_owner;
this.user = session.user;
}
async signIn(username: string, password: string, remember: boolean): Promise<void> {
this.#apply(await login(username, password, remember));
this.setupRequired = false;
}
async claim(username: string, password: string, displayName?: string): Promise<void> {
this.#apply(await claimInstance(username, password, displayName));
this.setupRequired = false;
}
async signOut(): Promise<void> {
try {
await logout();
} catch (e) {
// The session may already be gone server-side, which is not a failure worth
// blocking on — the local state still has to be cleared either way.
console.error('Sign-out request failed', e);
}
this.markSignedOut();
}
/** Drop local session state, e.g. after the server answered 401. */
markSignedOut(): void {
if (!this.enabled) return;
this.#applySignedOut();
}
get canRead(): boolean {
return !this.enabled || this.authenticated || this.anonymous;
}
get canEdit(): boolean {
return !this.enabled || (this.authenticated && RANKS[this.level] >= RANKS.edit);
}
get canManage(): boolean {
return !this.enabled || (this.authenticated && RANKS[this.level] >= RANKS.manage);
}
/** True when the instance is unclaimed and the setup screen should be shown. */
get needsSetup(): boolean {
return this.enabled && this.setupRequired;
}
/** True when a signed-in user must change their password before continuing. */
get mustChangePassword(): boolean {
return this.user?.must_change_password ?? false;
}
/** How to address the signed-in user in the interface. */
get displayName(): string {
return this.user?.display_name || this.user?.username || '';
}
}
export const auth = new Auth();
// Wire the API layer's rejection handling back to this store. Registered here rather
// than imported there, because http.ts and live.ts must not depend on a store.
setAuthHandlers({
unauthorized: () => auth.markSignedOut(),
forbidden: () => toasts.error(m['auth.forbidden']())
});
setLiveAuthCloseHandler(() => auth.markSignedOut());
+42
View File
@@ -0,0 +1,42 @@
import type { Level } from '$lib/api/auth';
import * as m from '$lib/paraglide/messages';
// Permission levels, for the interface. The ranking mirrors spoolman/auth/levels.py,
// and is duplicated here rather than fetched because a client that has to ask the
// server what "edit" outranks cannot render a form until it has.
export const LEVELS: Level[] = ['read', 'edit', 'manage'];
const RANKS: Record<Level, number> = { read: 0, edit: 1, manage: 2 };
export function levelLabel(level: Level): string {
switch (level) {
case 'read':
return m['level.read']();
case 'edit':
return m['level.edit']();
case 'manage':
return m['level.manage']();
}
}
export function levelDescription(level: Level): string {
switch (level) {
case 'read':
return m['level.readDesc']();
case 'edit':
return m['level.editDesc']();
case 'manage':
return m['level.manageDesc']();
}
}
/** True when `actual` is at least as permissive as `required`. */
export function levelCovers(actual: Level, required: Level): boolean {
return RANKS[actual] >= RANKS[required];
}
/** The levels a holder of `own` may grant — never above themselves, as the server enforces. */
export function grantableLevels(own: Level): Level[] {
return LEVELS.filter((level) => levelCovers(own, level));
}
+60 -11
View File
@@ -5,12 +5,18 @@
import AddSpoolModal from '$components/AddSpoolModal.svelte';
import QrScannerModal from '$components/QrScannerModal.svelte';
import Toaster from '$components/Toaster.svelte';
import LoginScreen from '$components/auth/LoginScreen.svelte';
import SetupScreen from '$components/auth/SetupScreen.svelte';
import ForcePasswordChange from '$components/auth/ForcePasswordChange.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { serverInfo } from '$lib/stores/serverInfo.svelte';
import { theme } from '$lib/stores/theme.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { startLiveSync } from '$lib/api/liveSync';
import { live } from '$lib/api/live';
import { getLocale, getTextDirection } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
@@ -31,31 +37,64 @@
document.documentElement.dir = getTextDirection();
});
// Find out whether anyone needs to sign in before rendering anything. With auth
// disabled this resolves immediately and reports full capability, so the default
// configuration behaves exactly as it did before auth existed.
$effect(() => {
auth.load();
});
// Load server settings and start central live-sync (keeps the reactive cache,
// and thus every view that reads it, up to date with WebSocket events).
//
// Split from the effect above and gated on being able to read: starting live-sync
// while signed out would open websockets the server immediately closes. Because
// canRead is reactive, signing in re-runs this and everything starts for real; the
// returned teardown runs on sign-out.
$effect(() => {
if (!auth.ready || !auth.canRead || auth.mustChangePassword) return;
settings.load();
serverInfo.load();
// Sockets stopped by an auth rejection stay stopped until told otherwise.
live.rearm();
return startLiveSync();
});
</script>
<div class="app">
<TopBar onadd={() => ui.openAddModal()} onscan={() => ui.openScanner()} />
{#if !auth.ready}
<!-- Nothing renders until we know: showing the app and then yanking it away
would flash the interface at someone who cannot use it. -->
<div class="boot">{m['auth.loading']()}…</div>
{:else if auth.needsSetup}
<div class="app"><SetupScreen /></div>
{:else if !auth.canRead}
<div class="app"><LoginScreen /></div>
{:else if auth.mustChangePassword}
<!-- Ahead of the app, not inside it. A temporary password set by an administrator
is one they know, so browsing on it should not be an option. -->
<div class="app"><ForcePasswordChange /></div>
{:else}
<div class="app">
<TopBar onadd={() => ui.openAddModal()} onscan={() => ui.openScanner()} />
<main>{@render children()}</main>
<Footer />
</div>
<main>{@render children()}</main>
<Footer />
</div>
<AddSpoolModal
open={ui.addModalOpen}
presetFilamentId={ui.addModalFilamentId}
onclose={() => ui.closeAddModal()}
/>
{#if auth.canManage}
<AddSpoolModal
open={ui.addModalOpen}
presetFilamentId={ui.addModalFilamentId}
onclose={() => ui.closeAddModal()}
/>
{/if}
<QrScannerModal open={ui.scannerOpen} onclose={() => ui.closeScanner()} />
<QrScannerModal open={ui.scannerOpen} onclose={() => ui.closeScanner()} />
{/if}
<!-- Outside the branches: sign-in errors need somewhere to surface too. -->
<Toaster />
<style>
@@ -73,4 +112,14 @@
flex: 1;
min-height: 0;
}
.boot {
display: grid;
place-items: center;
height: 100vh;
height: 100dvh;
background: var(--bg);
color: var(--text-muted);
font-size: 0.9rem;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import Card from '$components/Card.svelte';
import SettingRow from '$components/settings/SettingRow.svelte';
import ChangePasswordForm from '$components/auth/ChangePasswordForm.svelte';
import ApiKeyList from '$components/auth/ApiKeyList.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { levelLabel } from '$lib/utils/levels';
import * as m from '$lib/paraglide/messages';
// The signed-in user's own account. Unlike /users this needs no admin rights, and
// unlike the rest of the app it is only reachable at all when auth is enabled —
// with auth off there is no account to be the subject of any of it.
function roleLabel(): string {
if (auth.isOwner) return m['account.roleOwner']();
if (auth.isAdmin) return m['account.roleAdmin']();
return m['account.roleUser']();
}
</script>
<svelte:head>
<title>{m['documentTitle.account']()}</title>
</svelte:head>
<div class="page scroll-y">
<div class="wrap">
<div class="title">{m['account.header']()}</div>
{#if !auth.enabled || !auth.authenticated}
<!-- With auth off there is no account to describe, and no password or key
endpoint that would answer. Reachable only by typing the URL. -->
<p class="notice">{m['auth.forbidden']()}</p>
{:else}
<div class="sec-label">{m['account.profile']()}</div>
<Card divided>
<SettingRow title={m['auth.username']()}>
<span class="value mono">{auth.user?.username ?? ''}</span>
</SettingRow>
{#if auth.user?.display_name}
<SettingRow title={m['auth.displayName']()}>
<span class="value">{auth.user.display_name}</span>
</SettingRow>
{/if}
<SettingRow title={m['account.level']()}>
<span class="value">{levelLabel(auth.level)}</span>
</SettingRow>
<SettingRow title={m['account.role']()}>
<span class="value">{roleLabel()}</span>
</SettingRow>
</Card>
<div class="sec-label">{m['account.passwordSection']()}</div>
<p class="desc">{m['account.passwordDesc']()}</p>
<Card>
<div class="pad"><ChangePasswordForm /></div>
</Card>
<div class="sec-label">{m['account.keysSection']()}</div>
<p class="desc">{m['account.keysDesc']()}</p>
<ApiKeyList />
{/if}
</div>
</div>
<style>
.page {
flex: 1;
min-height: 0;
padding: 22px 24px 48px;
}
.wrap {
max-width: 680px;
margin: 0 auto;
}
.title {
font-weight: 700;
font-size: 16px;
}
.sec-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
padding: 22px 0 8px;
}
.desc {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-dim);
}
.value {
font-size: 12.5px;
color: var(--text-2);
}
.mono {
font-family: var(--font-mono, ui-monospace, monospace);
}
.pad {
padding: 14px 16px;
}
.notice {
margin: 12px 0 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
color: var(--text-2);
font-size: 0.82rem;
}
</style>
+330
View File
@@ -0,0 +1,330 @@
<script lang="ts">
import Card from '$components/Card.svelte';
import Pagination from '$components/Pagination.svelte';
import { listAudit, listAuditEvents, type AuditEntry } from '$lib/api/admin';
import { getSettings, parseSetting, setSetting } from '$lib/api/settings';
import { isAbortError } from '$lib/api/http';
import { formatDateTime } from '$lib/utils/datetime';
import { trackSave } from '$lib/utils/autosave';
import { auth } from '$lib/stores/auth.svelte';
import * as m from '$lib/paraglide/messages';
// Administrator-only, read-only. There is no way to write or delete an entry from
// here by design — the server offers none. Entries appear as a side effect of what
// they record and leave when the retention window passes over them.
let pageSize = $state(50);
let entries = $state<AuditEntry[]>([]);
let total = $state(0);
let page = $state(0);
let event = $state('');
let events = $state<string[]>([]);
let loading = $state(true);
let failed = $state(false);
let retention = $state(90);
$effect(() => {
if (!auth.enabled || !auth.isAdmin) return;
// Reading these here is what subscribes the effect to them, so changing any one
// of them refetches.
const limit = pageSize;
const offset = page * pageSize;
const filter = event;
const controller = new AbortController();
load(limit, offset, filter, controller.signal);
return () => controller.abort();
});
$effect(() => {
if (!auth.enabled || !auth.isAdmin) return;
const controller = new AbortController();
loadMeta(controller.signal);
return () => controller.abort();
});
async function load(limit: number, offset: number, filter: string, signal: AbortSignal) {
loading = true;
try {
const result = await listAudit({ limit, offset, event: filter }, signal);
entries = result.entries;
total = result.total;
failed = false;
} catch (e) {
if (isAbortError(e, signal)) return;
console.error('Failed to load the audit log', e);
failed = true;
} finally {
loading = false;
}
}
async function loadMeta(signal: AbortSignal) {
try {
events = await listAuditEvents(signal);
const settings = await getSettings(signal);
retention = parseSetting(settings.auth_audit_retention_days, 90);
} catch (e) {
if (isAbortError(e, signal)) return;
console.error('Failed to load audit metadata', e);
}
}
function saveRetention(value: string) {
const days = Math.max(0, Math.floor(Number(value)));
if (!Number.isFinite(days)) return;
retention = days;
trackSave(setSetting('auth_audit_retention_days', days));
}
/** A readable name for an event, falling back to the raw identifier. */
function eventLabel(name: string): string {
const key = `audit.events.${name}`;
const messages = m as unknown as Record<string, (() => string) | undefined>;
return messages[key]?.() ?? name;
}
function actorLabel(entry: AuditEntry): string {
if (entry.actor_username) return entry.actor_username;
if (entry.actor_user_id !== null) return m['audit.deletedActor']();
return m['audit.anonymousActor']();
}
/** Render the detail object as `key: value` pairs, since its shape varies by event. */
function detailText(entry: AuditEntry): string {
if (!entry.detail) return '';
return Object.entries(entry.detail)
.map(([key, value]) => `${key}: ${value}`)
.join(' · ');
}
</script>
<svelte:head>
<title>{m['documentTitle.audit']()}</title>
</svelte:head>
<div class="page scroll-y">
<div class="wrap">
<div class="title">{m['audit.header']()}</div>
<p class="desc">{m['audit.desc']()}</p>
{#if !auth.enabled || !auth.isAdmin}
<!-- Reachable by typing the URL. Say so rather than firing requests the
server will refuse. -->
<p class="notice">{m['auth.forbidden']()}</p>
{:else}
<div class="bar">
<label class="field">
<span class="field-label">{m['audit.event']()}</span>
<select
bind:value={event}
onchange={() => (page = 0)}
aria-label={m['audit.event']()}
class="filter"
>
<option value="">{m['audit.allEvents']()}</option>
{#each events as name (name)}
<option value={name}>{eventLabel(name)}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">{m['audit.retention']()}</span>
<span class="retention">
<input
class="num"
type="number"
min="0"
value={retention}
aria-label={m['audit.retention']()}
onchange={(e) => saveRetention(e.currentTarget.value)}
/>
<span class="unit">{m['audit.retentionDays']()}</span>
</span>
</label>
</div>
<p class="desc small">{m['audit.retentionDesc']()}</p>
<Card divided>
{#if loading}
<div class="empty">{m['auth.loading']()}…</div>
{:else if failed}
<div class="empty">{m['audit.loadFailed']()}</div>
{:else if entries.length === 0}
<div class="empty">{m['audit.empty']()}</div>
{:else}
{#each entries as entry (entry.id)}
<div class="entry">
<div class="when">{formatDateTime(entry.date)}</div>
<div class="what">
<div class="event">
{eventLabel(entry.event)}
{#if entry.target}<span class="target">{entry.target}</span>{/if}
</div>
<div class="meta">
{actorLabel(entry)}
{#if entry.ip}· {entry.ip}{/if}
</div>
{#if entry.detail}
<div class="meta detail">{detailText(entry)}</div>
{/if}
</div>
</div>
{/each}
{/if}
</Card>
{#if total > 0}
<div class="pager">
<Pagination
page={page + 1}
{pageSize}
{total}
onpage={(p) => (page = p - 1)}
onpagesize={(size) => {
pageSize = size;
page = 0;
}}
/>
</div>
{/if}
{/if}
</div>
</div>
<style>
.page {
flex: 1;
min-height: 0;
padding: 22px 24px 48px;
}
.wrap {
max-width: 900px;
margin: 0 auto;
}
.title {
font-weight: 700;
font-size: 16px;
}
.desc {
margin: 3px 0 14px;
font-size: 12px;
color: var(--text-dim);
}
.desc.small {
margin: 6px 0 14px;
font-size: 11.5px;
}
.bar {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
}
.field {
display: flex;
align-items: center;
gap: 8px;
}
.field-label {
font-size: 11.5px;
color: var(--text-dim);
}
.retention {
display: inline-flex;
align-items: center;
gap: 6px;
}
.unit {
font-size: 11.5px;
color: var(--text-dim);
}
.filter,
.num {
height: 44px;
padding: 0 8px;
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
background: var(--input-bg);
color: var(--text);
font: inherit;
font-size: 12.5px;
outline: none;
}
.filter {
min-width: 180px;
}
.num {
width: 76px;
text-align: right;
}
.filter:focus,
.num:focus {
border-color: var(--accent);
}
.empty {
padding: 16px;
font-size: 12.5px;
color: var(--text-dim);
}
.entry {
display: flex;
gap: 14px;
padding: 10px 14px;
}
.when {
flex: 0 0 150px;
font-size: 11.5px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.what {
flex: 1;
min-width: 0;
}
.event {
font-size: 12.5px;
}
.target {
margin-left: 6px;
color: var(--text-2);
font-weight: 500;
}
.meta {
margin-top: 2px;
font-size: 11.5px;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.meta.detail {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px;
}
.pager {
margin-top: 12px;
}
.notice {
margin: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
color: var(--text-2);
font-size: 0.82rem;
}
@media (max-width: 640px) {
.entry {
flex-direction: column;
gap: 2px;
}
.when {
flex: none;
}
}
</style>
+116 -88
View File
@@ -7,6 +7,7 @@
import { theme, type ThemePref } from '$lib/stores/theme.svelte';
import { locales, getLocale, setLocale, isLocale } from '$lib/paraglide/runtime.js';
import * as m from '$lib/paraglide/messages';
import { auth } from '$lib/stores/auth.svelte';
import { languages } from '$lib/i18n/languages';
import { trackSave } from '$lib/utils/autosave';
@@ -40,99 +41,116 @@
<div class="wrap">
<div class="title">{m['settings.header']()}</div>
<div class="sec-label">{m['settings.appearance.tab']()}</div>
<Card divided>
<SettingRow title={m['settings.appearance.theme.label']()} desc={m['settings.appearance.theme.desc']()}>
<div class="seg" role="group" aria-label={m['settings.appearance.theme.label']()}>
{#each themeOptions as opt (opt.value)}
<button
type="button"
class="seg-btn"
class:active={theme.pref === opt.value}
aria-pressed={theme.pref === opt.value}
onclick={() => theme.setPref(opt.value)}
>
{opt.label()}
</button>
{/each}
</div>
</SettingRow>
<SettingRow title={m['settings.language.label']()} desc={m['settings.language.desc']()}>
<select
class="lang"
aria-label={m['settings.language.label']()}
value={getLocale()}
onchange={(e) => {
if (isLocale(e.currentTarget.value)) setLocale(e.currentTarget.value);
}}
{#if !auth.canManage}
<p class="notice">{m['auth.readOnlyNotice']()}</p>
{/if}
<!-- Every control below writes through POST /setting/{key}, which the server
gates at manage. `inert` removes the whole subtree from interaction and from
the accessibility tree with one attribute, which beats threading a `disabled`
prop through a dozen components that were never built to take one. This is
cosmetic; the server is the authority. -->
<div class="sections" inert={!auth.canManage}>
<div class="sec-label">{m['settings.appearance.tab']()}</div>
<Card divided>
<SettingRow
title={m['settings.appearance.theme.label']()}
desc={m['settings.appearance.theme.desc']()}
>
{#each localeData as locale (locale.code)}
<option value={locale.code}>{locale.langData.name}</option>
{/each}
</select>
</SettingRow>
</Card>
<div class="seg" role="group" aria-label={m['settings.appearance.theme.label']()}>
{#each themeOptions as opt (opt.value)}
<button
type="button"
class="seg-btn"
class:active={theme.pref === opt.value}
aria-pressed={theme.pref === opt.value}
onclick={() => theme.setPref(opt.value)}
>
{opt.label()}
</button>
{/each}
</div>
</SettingRow>
<SettingRow title={m['settings.language.label']()} desc={m['settings.language.desc']()}>
<select
class="lang"
aria-label={m['settings.language.label']()}
value={getLocale()}
onchange={(e) => {
if (isLocale(e.currentTarget.value)) setLocale(e.currentTarget.value);
}}
>
{#each localeData as locale (locale.code)}
<option value={locale.code}>{locale.langData.name}</option>
{/each}
</select>
</SettingRow>
</Card>
<div class="sec-label">{m['settings.general.tab']()}</div>
<Card divided>
<SettingRow title={m['settings.general.currency.label']()} desc={m['settings.general.currency.desc']()}>
<input
class="code mono"
aria-label={m['settings.general.currency.label']()}
value={settings.currency}
maxlength="3"
onchange={(e) => saveCurrency(e.currentTarget.value)}
/>
</SettingRow>
<SettingRow
title={m['settings.general.roundPrices.label']()}
desc={m['settings.general.roundPrices.tooltip']()}
>
<Toggle
checked={settings.roundPrices}
onchange={(v) => trackSave(settings.setRoundPrices(v))}
ariaLabel={m['settings.general.roundPrices.label']()}
/>
</SettingRow>
<SettingRow
title={m['settings.general.baseUrl.label']()}
desc={m['settings.general.baseUrl.tooltip']()}
>
<input
class="url"
aria-label={m['settings.general.baseUrl.label']()}
value={settings.baseUrl}
placeholder="https://spoolman.example.com"
onchange={(e) => saveBaseUrl(e.currentTarget.value)}
/>
</SettingRow>
</Card>
<div class="sec-label">{m['settings.general.tab']()}</div>
<Card divided>
<SettingRow
title={m['settings.general.currency.label']()}
desc={m['settings.general.currency.desc']()}
>
<input
class="code mono"
aria-label={m['settings.general.currency.label']()}
value={settings.currency}
maxlength="3"
onchange={(e) => saveCurrency(e.currentTarget.value)}
/>
</SettingRow>
<SettingRow
title={m['settings.general.roundPrices.label']()}
desc={m['settings.general.roundPrices.tooltip']()}
>
<Toggle
checked={settings.roundPrices}
onchange={(v) => trackSave(settings.setRoundPrices(v))}
ariaLabel={m['settings.general.roundPrices.label']()}
/>
</SettingRow>
<SettingRow
title={m['settings.general.baseUrl.label']()}
desc={m['settings.general.baseUrl.tooltip']()}
>
<input
class="url"
aria-label={m['settings.general.baseUrl.label']()}
value={settings.baseUrl}
placeholder="https://spoolman.example.com"
onchange={(e) => saveBaseUrl(e.currentTarget.value)}
/>
</SettingRow>
</Card>
<div class="sec-label">{m['settings.library.tab']()}</div>
<Card divided>
<SettingRow
title={m['settings.library.lowThreshold.label']()}
desc={m['settings.library.lowThreshold.desc']()}
>
<input
class="num mono"
type="number"
aria-label={m['settings.library.lowThreshold.label']()}
value={settings.lowThreshold}
oninput={(e) => settings.setLowThreshold(Number(e.currentTarget.value) || 0)}
/>
<span class="unit">g</span>
</SettingRow>
</Card>
<div class="sec-label">{m['settings.library.tab']()}</div>
<Card divided>
<SettingRow
title={m['settings.library.lowThreshold.label']()}
desc={m['settings.library.lowThreshold.desc']()}
>
<input
class="num mono"
type="number"
aria-label={m['settings.library.lowThreshold.label']()}
value={settings.lowThreshold}
oninput={(e) => settings.setLowThreshold(Number(e.currentTarget.value) || 0)}
/>
<span class="unit">g</span>
</SettingRow>
</Card>
<div class="sec-label">{m['settings.extraFields.tab']()}</div>
<div class="subtitle sub2">
<p>{m['settings.extraFields.description.intro']()}</p>
<p>{m['settings.extraFields.description.constraints']()}</p>
<p>{m['settings.extraFields.description.keyUsage']()}</p>
<p>{m['settings.extraFields.description.tableViews']()}</p>
<div class="sec-label">{m['settings.extraFields.tab']()}</div>
<div class="subtitle sub2">
<p>{m['settings.extraFields.description.intro']()}</p>
<p>{m['settings.extraFields.description.constraints']()}</p>
<p>{m['settings.extraFields.description.keyUsage']()}</p>
<p>{m['settings.extraFields.description.tableViews']()}</p>
</div>
<ExtraFieldsManager />
</div>
<ExtraFieldsManager />
</div>
</div>
@@ -222,4 +240,14 @@
background: var(--accent);
color: #fff;
}
.notice {
margin: 0 0 12px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
color: var(--text-2);
font-size: 0.82rem;
}
</style>
+549
View File
@@ -0,0 +1,549 @@
<script lang="ts">
import Card from '$components/Card.svelte';
import Button from '$components/Button.svelte';
import Toggle from '$components/Toggle.svelte';
import Checkbox from '$components/Checkbox.svelte';
import PasswordInput from '$components/auth/PasswordInput.svelte';
import SecretReveal from '$components/auth/SecretReveal.svelte';
import KeyRound from '@lucide/svelte/icons/key-round';
import LogOut from '@lucide/svelte/icons/log-out';
import Trash2 from '@lucide/svelte/icons/trash-2';
import {
createUser,
deleteUser,
listUsers,
resetUserPassword,
revokeUserSessions,
updateUser
} from '$lib/api/admin';
import type { AuthUserInfo, Level } from '$lib/api/auth';
import { auth } from '$lib/stores/auth.svelte';
import { toasts } from '$lib/stores/toasts.svelte';
import { HttpError, isAbortError } from '$lib/api/http';
import { LEVELS, levelLabel } from '$lib/utils/levels';
import * as m from '$lib/paraglide/messages';
// Administrator-only. The server refuses every request here without admin rights,
// so the page gates itself to avoid rendering an interface that cannot work.
//
// Every control that the server would refuse for a *specific* account — the owner's
// level, an administrator's own active flag — is disabled with the reason shown,
// rather than left enabled to fail. The rules are duplicated from
// spoolman/api/v1/users.py::_protected; the server remains the authority.
let users = $state<AuthUserInfo[]>([]);
let loading = $state(true);
let failed = $state(false);
let busy = $state<number | null>(null);
let creating = $state(false);
let submitting = $state(false);
let newUsername = $state('');
let newPassword = $state('');
let newDisplayName = $state('');
let newLevel = $state<Level>('read');
let newIsAdmin = $state(false);
let newMustChange = $state(true);
let formError = $state('');
// A generated password, held until dismissed. The server returns it once.
let secret = $state<{ title: string; value: string } | null>(null);
$effect(() => {
// Nothing to fetch without admin rights; the guard in the markup explains why.
if (!auth.enabled || !auth.isAdmin) return;
const controller = new AbortController();
load(controller.signal);
return () => controller.abort();
});
async function load(signal?: AbortSignal) {
loading = true;
try {
users = await listUsers(signal);
failed = false;
} catch (e) {
if (isAbortError(e, signal)) return;
console.error('Failed to load users', e);
failed = true;
} finally {
loading = false;
}
}
/** Why a destructive change to this account is refused, or '' when it is allowed. */
function protectedReason(user: AuthUserInfo): string {
if (user.is_owner) return m['users.protectedOwner']();
if (user.id === auth.user?.id) return m['users.protectedSelf']();
return '';
}
async function patch(user: AuthUserInfo, change: Parameters<typeof updateUser>[1]) {
busy = user.id;
try {
await updateUser(user.id, change);
await load();
toasts.success(m['users.saved']());
} catch (e) {
console.error('Failed to update user', e);
toasts.error(e instanceof HttpError ? e.message : m['users.loadFailed']());
// Reload regardless: the row on screen may now disagree with the server.
await load();
} finally {
busy = null;
}
}
async function submitCreate() {
if (submitting) return;
if (!newUsername.trim()) {
formError = m['auth.usernameRequired']();
return;
}
submitting = true;
formError = '';
try {
const created = await createUser({
username: newUsername.trim(),
password: newPassword ? newPassword : null,
display_name: newDisplayName.trim() || null,
level: newLevel,
is_admin: newIsAdmin,
must_change_password: newMustChange
});
creating = false;
newUsername = newPassword = newDisplayName = '';
if (created.password) {
secret = { title: m['users.generatedPassword'](), value: created.password };
}
await load();
} catch (e) {
formError = e instanceof HttpError && e.status === 409 ? e.message : m['users.loadFailed']();
} finally {
submitting = false;
}
}
async function resetPassword(user: AuthUserInfo) {
if (!confirm(m['users.resetWarning']())) return;
busy = user.id;
try {
const result = await resetUserPassword(user.id, null, true);
if (result.password) {
secret = { title: m['users.generatedPassword'](), value: result.password };
}
await load();
} catch (e) {
console.error('Failed to reset password', e);
toasts.error(m['users.loadFailed']());
} finally {
busy = null;
}
}
async function signOutEverywhere(user: AuthUserInfo) {
busy = user.id;
try {
await revokeUserSessions(user.id);
toasts.success(m['users.sessionsRevoked']());
} catch (e) {
console.error('Failed to revoke sessions', e);
toasts.error(m['users.loadFailed']());
} finally {
busy = null;
}
}
async function remove(user: AuthUserInfo) {
if (!confirm(m['users.deleteConfirm']({ name: user.username }))) return;
busy = user.id;
try {
await deleteUser(user.id);
toasts.success(m['users.deleted']());
await load();
} catch (e) {
console.error('Failed to delete user', e);
toasts.error(e instanceof HttpError ? e.message : m['users.loadFailed']());
} finally {
busy = null;
}
}
</script>
<svelte:head>
<title>{m['documentTitle.users']()}</title>
</svelte:head>
<div class="page scroll-y">
<div class="wrap">
<div class="title">{m['users.header']()}</div>
<p class="desc">{m['users.desc']()}</p>
{#if !auth.enabled || !auth.isAdmin}
<!-- Reachable by typing the URL. Say so rather than firing a page of
requests the server will refuse. -->
<p class="notice">{m['auth.forbidden']()}</p>
{:else}
{#if secret}
<SecretReveal
title={secret.title}
value={secret.value}
warning={m['users.generatedPasswordWarning']()}
ondismiss={() => (secret = null)}
/>
{/if}
<Card divided>
{#if loading}
<div class="empty">{m['auth.loading']()}…</div>
{:else if failed}
<div class="empty">{m['users.loadFailed']()}</div>
{:else}
{#each users as user (user.id)}
{@const reason = protectedReason(user)}
<div class="user" class:disabled={!user.is_active}>
<div class="user-main">
<div class="user-name">
{user.username}
{#if user.is_owner}<span class="badge">{m['account.roleOwner']()}</span>{/if}
{#if user.id === auth.user?.id}<span class="badge dim">{m['users.you']()}</span>{/if}
</div>
{#if user.display_name}<div class="user-meta">{user.display_name}</div>{/if}
<div class="user-meta">
{#if !user.is_active}
{m['users.statusDisabled']()} ·
{/if}
{#if user.must_change_password}
{m['users.statusMustChange']()}
{/if}
</div>
{#if reason}<div class="user-meta hint">{reason}</div>{/if}
</div>
<div class="controls">
<label class="ctl">
<span class="ctl-label">{m['users.level']()}</span>
<select
value={user.level}
disabled={!!reason || busy === user.id}
aria-label={m['users.level']()}
onchange={(e) => patch(user, { level: e.currentTarget.value as Level })}
>
{#each LEVELS as option (option)}
<option value={option}>{levelLabel(option)}</option>
{/each}
</select>
</label>
<div class="ctl">
<span class="ctl-label">{m['users.admin']()}</span>
<Toggle
checked={user.is_admin}
disabled={(!!reason && user.is_admin) || busy === user.id}
ariaLabel={m['users.admin']()}
onchange={(v) => patch(user, { is_admin: v })}
/>
</div>
<div class="ctl">
<span class="ctl-label">{m['users.active']()}</span>
<Toggle
checked={user.is_active}
disabled={(!!reason && user.is_active) || busy === user.id}
ariaLabel={m['users.active']()}
onchange={(v) => patch(user, { is_active: v })}
/>
</div>
<div class="acts">
<button
type="button"
class="icon"
disabled={busy === user.id}
onclick={() => resetPassword(user)}
aria-label={m['users.resetPassword']()}
title={m['users.resetPassword']()}
>
<KeyRound size={15} />
</button>
<button
type="button"
class="icon"
disabled={busy === user.id}
onclick={() => signOutEverywhere(user)}
aria-label={m['users.signOutEverywhere']()}
title={m['users.signOutEverywhere']()}
>
<LogOut size={15} />
</button>
<button
type="button"
class="icon danger"
disabled={!!reason || busy === user.id}
onclick={() => remove(user)}
aria-label={m['users.delete']()}
title={reason || m['users.delete']()}
>
<Trash2 size={15} />
</button>
</div>
</div>
</div>
{/each}
{/if}
</Card>
{#if creating}
<Card>
<form
class="form"
onsubmit={(e) => {
e.preventDefault();
submitCreate();
}}
>
<label for="nu-username">{m['users.username']()}</label>
<input
id="nu-username"
type="text"
bind:value={newUsername}
autocapitalize="off"
spellcheck="false"
disabled={submitting}
maxlength="64"
/>
<label for="nu-display">{m['auth.displayNameOptional']()}</label>
<input
id="nu-display"
type="text"
bind:value={newDisplayName}
disabled={submitting}
maxlength="128"
/>
<label for="nu-password">{m['users.passwordOptional']()}</label>
<PasswordInput
id="nu-password"
bind:value={newPassword}
autocomplete="new-password"
disabled={submitting}
/>
<label for="nu-level">{m['users.level']()}</label>
<select id="nu-level" bind:value={newLevel} disabled={submitting}>
{#each LEVELS as option (option)}
<option value={option}>{levelLabel(option)}</option>
{/each}
</select>
<Checkbox bind:checked={newIsAdmin} disabled={submitting} label={m['users.admin']()} />
<Checkbox
bind:checked={newMustChange}
disabled={submitting}
label={m['users.mustChangePassword']()}
/>
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
<div class="form-actions">
<Button type="submit" variant="primary" disabled={submitting}>{m['users.create']()}</Button>
<Button variant="ghost" onclick={() => (creating = false)}>{m['buttons.cancel']()}</Button>
</div>
</form>
</Card>
{:else}
<div class="add">
<Button
variant="outline"
onclick={() => {
formError = '';
creating = true;
}}>{m['users.newUser']()}</Button
>
</div>
{/if}
{/if}
</div>
</div>
<style>
.page {
flex: 1;
min-height: 0;
padding: 22px 24px 48px;
}
.wrap {
max-width: 820px;
margin: 0 auto;
}
.title {
font-weight: 700;
font-size: 16px;
}
.desc {
margin: 3px 0 14px;
font-size: 12px;
color: var(--text-dim);
}
.empty {
padding: 16px;
font-size: 12.5px;
color: var(--text-dim);
}
.user {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 14px;
}
.user.disabled {
opacity: 0.6;
}
.user-main {
flex: 1;
min-width: 180px;
}
.user-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.user-meta {
margin-top: 2px;
font-size: 11.5px;
color: var(--text-dim);
}
.user-meta.hint {
font-style: italic;
}
.badge {
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--accent-wash);
color: var(--accent-soft);
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge.dim {
background: var(--surface-sunken);
color: var(--text-dim);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
}
.ctl {
display: flex;
align-items: center;
gap: 6px;
}
.ctl-label {
font-size: 11.5px;
color: var(--text-dim);
}
select {
height: 44px;
padding: 0 8px;
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
background: var(--input-bg);
color: var(--text);
font: inherit;
font-size: 12.5px;
outline: none;
}
select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.acts {
display: flex;
gap: 2px;
}
.icon {
display: grid;
place-items: center;
/* 44px, matching PasswordInput and the rest of the auth surfaces. */
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
}
.icon:hover:not(:disabled) {
background: var(--surface-sunken);
color: var(--text);
}
.icon.danger:hover:not(:disabled) {
color: var(--danger);
}
.icon:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.form {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
}
.form label {
margin-top: 6px;
font-size: 0.8rem;
color: var(--text-2);
}
.form input[type='text'],
.form select {
height: 44px;
padding: 0 10px;
border: 1px solid var(--border-input);
border-radius: var(--radius-sm);
background: var(--input-bg);
color: var(--text);
font: inherit;
font-size: 12.5px;
outline: none;
}
.form input[type='text']:focus,
.form select:focus {
border-color: var(--accent);
}
.error {
margin: 4px 0 0;
color: var(--danger);
font-size: 0.85rem;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.add {
margin-top: 10px;
}
.notice {
margin: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
color: var(--text-2);
font-size: 0.82rem;
}
</style>
@@ -0,0 +1,158 @@
"""Auth tables.
Revision ID: 6e496ae15523
Revises: 415a8f855e14
Create Date: 2026-07-27 20:13:56.370544
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "6e496ae15523"
down_revision = "415a8f855e14"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Perform the upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"auth_user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(length=64), nullable=False, comment="Stored lowercased."),
sa.Column("display_name", sa.String(length=128), nullable=True),
sa.Column(
"email", sa.String(length=256), nullable=True, comment="Used for OIDC matching only, never delivery."
),
sa.Column("password_hash", sa.Text(), nullable=True, comment="Null for OIDC- or certificate-only accounts."),
sa.Column(
"level", sa.String(length=16), nullable=False, comment="read, edit or manage. See spoolman.auth.levels."
),
sa.Column("is_admin", sa.Boolean(), nullable=False),
sa.Column("is_owner", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("must_change_password", sa.Boolean(), nullable=False),
sa.Column("totp_secret", sa.Text(), nullable=True, comment="Fernet-encrypted. Phase 4."),
sa.Column("totp_enabled", sa.Boolean(), nullable=False),
sa.Column("oidc_issuer", sa.String(length=256), nullable=True, comment="Phase 3."),
sa.Column("oidc_subject", sa.String(length=256), nullable=True, comment="Phase 3."),
sa.Column("failed_logins", sa.Integer(), nullable=False),
sa.Column("locked_until", sa.DateTime(), nullable=True),
sa.Column("registered", sa.DateTime(), nullable=False),
sa.Column("last_login", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_user_id"), "auth_user", ["id"], unique=False)
op.create_index(op.f("ix_auth_user_username"), "auth_user", ["username"], unique=True)
op.create_table(
"auth_api_key",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("level", sa.String(length=16), nullable=False),
sa.Column("prefix", sa.String(length=16), nullable=False, comment="Indexes the row at lookup."),
sa.Column("key_hash", sa.Text(), nullable=False),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("expires", sa.DateTime(), nullable=True),
sa.Column("last_used", sa.DateTime(), nullable=True),
sa.Column("revoked", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["user_id"],
["auth_user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_api_key_id"), "auth_api_key", ["id"], unique=False)
op.create_index(op.f("ix_auth_api_key_prefix"), "auth_api_key", ["prefix"], unique=True)
op.create_index(op.f("ix_auth_api_key_user_id"), "auth_api_key", ["user_id"], unique=False)
op.create_table(
"auth_audit_log",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("date", sa.DateTime(), nullable=False),
sa.Column("event", sa.String(length=64), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("actor_kind", sa.String(length=16), nullable=False),
sa.Column("target", sa.String(length=128), nullable=True),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.Column("user_agent", sa.String(length=256), nullable=True),
sa.Column("detail", sa.Text(), nullable=True, comment="JSON object."),
sa.ForeignKeyConstraint(
["actor_user_id"],
["auth_user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_audit_log_date"), "auth_audit_log", ["date"], unique=False)
op.create_index(op.f("ix_auth_audit_log_id"), "auth_audit_log", ["id"], unique=False)
op.create_table(
"auth_certificate",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column(
"fingerprint_sha256",
sa.String(length=95),
nullable=False,
comment="Colon-separated uppercase hex, 95 chars.",
),
sa.Column("subject_dn", sa.String(length=512), nullable=False),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("last_used", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["auth_user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_auth_certificate_fingerprint_sha256"), "auth_certificate", ["fingerprint_sha256"], unique=True
)
op.create_index(op.f("ix_auth_certificate_id"), "auth_certificate", ["id"], unique=False)
op.create_index(op.f("ix_auth_certificate_user_id"), "auth_certificate", ["user_id"], unique=False)
op.create_table(
"auth_session",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False, comment="SHA-256 hex of the token."),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("expires", sa.DateTime(), nullable=False),
sa.Column("last_seen", sa.DateTime(), nullable=False),
sa.Column("remember", sa.Boolean(), nullable=False),
sa.Column("user_agent", sa.String(length=256), nullable=True),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["auth_user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_auth_session_id"), "auth_session", ["id"], unique=False)
op.create_index(op.f("ix_auth_session_token_hash"), "auth_session", ["token_hash"], unique=True)
op.create_index(op.f("ix_auth_session_user_id"), "auth_session", ["user_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Perform the downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_auth_session_user_id"), table_name="auth_session")
op.drop_index(op.f("ix_auth_session_token_hash"), table_name="auth_session")
op.drop_index(op.f("ix_auth_session_id"), table_name="auth_session")
op.drop_table("auth_session")
op.drop_index(op.f("ix_auth_certificate_user_id"), table_name="auth_certificate")
op.drop_index(op.f("ix_auth_certificate_id"), table_name="auth_certificate")
op.drop_index(op.f("ix_auth_certificate_fingerprint_sha256"), table_name="auth_certificate")
op.drop_table("auth_certificate")
op.drop_index(op.f("ix_auth_audit_log_id"), table_name="auth_audit_log")
op.drop_index(op.f("ix_auth_audit_log_date"), table_name="auth_audit_log")
op.drop_table("auth_audit_log")
op.drop_index(op.f("ix_auth_api_key_user_id"), table_name="auth_api_key")
op.drop_index(op.f("ix_auth_api_key_prefix"), table_name="auth_api_key")
op.drop_index(op.f("ix_auth_api_key_id"), table_name="auth_api_key")
op.drop_table("auth_api_key")
op.drop_index(op.f("ix_auth_user_username"), table_name="auth_user")
op.drop_index(op.f("ix_auth_user_id"), table_name="auth_user")
op.drop_table("auth_user")
# ### end Alembic commands ###
+15 -3
View File
@@ -25,7 +25,7 @@ dependencies = [
"hishel~=0.1",
"aiomysql[rsa]~=0.3",
]
requires-python = ">=3.10"
requires-python = ">=3.14"
[dependency-groups]
dev = [
@@ -69,7 +69,7 @@ replace = 'version = "{new_version}"'
[tool.ruff]
line-length = 120
target-version = "py310"
target-version = "py314"
extend-exclude = ["client_v2"]
[tool.ruff.lint]
@@ -92,6 +92,16 @@ ignore = [
"S104",
"S603",
"SIM108",
# TC001/2/3 want type-only imports moved into `if TYPE_CHECKING:` blocks. That 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 these breaks dependency injection.
# Ruff marks all of them as unsafe fixes for exactly this reason. They only began
# firing when target-version moved to py314 (PEP 649 deferred annotations).
"TC001",
"TC002",
"TC003",
"TD002",
"TD003",
"TRY003",
@@ -99,5 +109,7 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"tests*/*" = ["ANN201", "ASYNC210", "S101", "PLR2004", "D103", "TID252"]
# S105/S106 flag string literals that look like passwords. Authentication tests have to
# name the credentials they sign in with, so the rule only produces noise there.
"tests*/*" = ["ANN201", "ASYNC210", "S101", "S105", "S106", "PLR2004", "D103", "TID252"]
"migrations/versions/*" = ["N999"]
+218
View File
@@ -0,0 +1,218 @@
"""API key endpoints.
A key is a machine credential: something a printer, a script or a scrape job presents on
every request, with no way to sign in and no browser to redirect. It belongs to a user
and can never outrank them.
Every route here is gated by ``require_user``, so a key cannot manage keys -- not even
its own. See that gate's docstring for why.
"""
# ruff: noqa: D103
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from spoolman.auth.audit import AuditEvent, record
from spoolman.auth.dependencies import require_user
from spoolman.auth.levels import covers, parse_level
from spoolman.auth.principal import Principal
from spoolman.database import auth_api_key as auth_api_key_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database.database import get_db_session
from spoolman.exceptions import ItemNotFoundError
from . import models
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/auth/apikey",
tags=["auth"],
)
BAD_REQUEST = 400
FORBIDDEN = 403
NOT_FOUND = 404
CREATED = 201
# Keys a single account may hold at once. Not a security boundary; a stop on the one
# unbounded write an ordinary user can perform through this API.
MAX_KEYS_PER_USER = 50
def _message(status_code: int, message: str) -> JSONResponse:
"""Build an error response in the shape the rest of this API uses."""
return JSONResponse(status_code=status_code, content={"message": message})
@router.get(
"",
name="List own API keys",
description=(
"List the calling user's API keys. Secrets are never included: the complete key "
"is returned only once, by the request that created it. Revoked and expired keys "
"are listed too, so that a key which stopped working can be told apart from one "
"that was never made."
),
response_model=list[models.AuthApiKeyInfo],
)
async def find(
db: Annotated[AsyncSession, Depends(get_db_session)],
principal: Annotated[Principal, Depends(require_user())],
) -> Response:
if principal.user_id is None:
# Only reachable with authentication disabled, where there is no account to own
# a key. Answering with an empty list rather than an error keeps the account
# page renderable on an unauthenticated instance.
return JSONResponse(content=[], headers={"x-total-count": "0"})
user = await auth_user_db.get_by_id(db, principal.user_id)
keys = await auth_api_key_db.find_for_user(db=db, user_id=principal.user_id)
return JSONResponse(
content=jsonable_encoder(
[models.AuthApiKeyInfo.from_db(key, user.level) for key in keys],
exclude_none=True,
),
headers={"x-total-count": str(len(keys))},
)
@router.post(
"",
name="Create an API key",
description=(
"Issue a new API key. The complete key is in the response and is never "
"retrievable again. Present it in an `X-API-Key` header, or as a bearer token."
),
response_model=models.AuthApiKeyCreated,
responses={400: {"model": models.Message}, 403: {"model": models.Message}},
status_code=201,
)
async def create(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_user())],
body: models.ApiKeyCreateRequest,
) -> Response:
if principal.user_id is None:
return _message(FORBIDDEN, "API keys require an account, and authentication is not enabled.")
try:
level = parse_level(body.level)
except ValueError as exc:
return _message(BAD_REQUEST, str(exc))
# Refused rather than silently capped. A key quietly issued weaker than asked for is
# a support ticket six months later when a script starts failing; an error now is
# read immediately.
if not covers(principal.level, level):
return _message(FORBIDDEN, f"You cannot issue a key at the '{level}' level, which is above your own.")
if await auth_api_key_db.count_for_user(db, principal.user_id) >= MAX_KEYS_PER_USER:
return _message(
BAD_REQUEST,
f"You already have {MAX_KEYS_PER_USER} API keys. Delete one before creating another.",
)
user = await auth_user_db.get_by_id(db, principal.user_id)
key, plaintext = await auth_api_key_db.create(
db=db,
user_id=principal.user_id,
name=body.name,
level=level,
expires_days=body.expires_days,
)
await record(
request,
AuditEvent.APIKEY_CREATED,
principal=principal,
target=key.name,
detail={"prefix": key.prefix, "level": str(level), "expires_days": body.expires_days},
)
created = models.AuthApiKeyCreated(
key=plaintext,
info=models.AuthApiKeyInfo.from_db(key, user.level),
)
return JSONResponse(status_code=CREATED, content=jsonable_encoder(created, exclude_none=True))
@router.post(
"/{key_id}/revoke",
name="Revoke an API key",
description=(
"Stop a key from authenticating while keeping it listed. Prefer this to deleting "
"when you want the record of the key to survive."
),
response_model=models.AuthApiKeyInfo,
responses={403: {"model": models.Message}, 404: {"model": models.Message}},
)
async def revoke(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_user())],
key_id: int,
) -> Response:
try:
key = await auth_api_key_db.get_by_id(db, key_id)
except ItemNotFoundError as exc:
return _message(NOT_FOUND, str(exc))
if key.user_id != principal.user_id:
# 404 rather than 403. Answering "forbidden" would confirm that a key with this
# ID exists and belongs to somebody else, which is more than a caller who does
# not own it needs to know.
return _message(NOT_FOUND, f"No API key with ID {key_id} found.")
await auth_api_key_db.revoke(db=db, key=key)
await record(
request,
AuditEvent.APIKEY_REVOKED,
principal=principal,
target=key.name,
detail={"prefix": key.prefix},
)
user = await auth_user_db.get_by_id(db, key.user_id)
return JSONResponse(content=jsonable_encoder(models.AuthApiKeyInfo.from_db(key, user.level), exclude_none=True))
@router.delete(
"/{key_id}",
name="Delete an API key",
description="Remove a key entirely. The key stops working immediately.",
response_model=models.Message,
responses={403: {"model": models.Message}, 404: {"model": models.Message}},
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_user())],
key_id: int,
) -> Response:
try:
key = await auth_api_key_db.get_by_id(db, key_id)
except ItemNotFoundError as exc:
return _message(NOT_FOUND, str(exc))
if key.user_id != principal.user_id:
return _message(NOT_FOUND, f"No API key with ID {key_id} found.")
name, prefix = key.name, key.prefix
await auth_api_key_db.delete_key(db=db, key=key)
await record(
request,
AuditEvent.APIKEY_DELETED,
principal=principal,
target=name,
detail={"prefix": prefix},
)
return JSONResponse(content={"message": "API key deleted."})
+114
View File
@@ -0,0 +1,114 @@
"""Audit log endpoint.
Read-only, and administrators only. The log records addresses, user agents and which
accounts exist, so exposing it at the ``read`` level would hand an anonymous reader a
map of the instance's users and a record of every sign-in attempt against them.
There is deliberately no way to write an entry through the API, and no way to delete
one. Entries appear as a side effect of the operations that produce them, and leave only
when the retention window in :data:`spoolman.auth.audit.RETENTION_SETTING` passes over
them. A log that its subjects can edit is not evidence of anything.
"""
# ruff: noqa: D103
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from spoolman.auth.audit import AuditEvent
from spoolman.auth.dependencies import require_admin
from spoolman.auth.principal import Principal
from spoolman.database import auth_audit as auth_audit_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database.database import get_db_session
from spoolman.exceptions import ItemNotFoundError
from . import models
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/auth/audit",
tags=["auth"],
)
# Cap on a single page. The table is the one in the schema that grows without an upper
# bound, so an unbounded list endpoint over it is an easy way to exhaust memory.
MAX_LIMIT = 500
DEFAULT_LIMIT = 100
@router.get(
"",
name="Read the audit log",
description=(
"List audit entries, newest first. Administrators only. Entries cannot be "
"created or deleted through the API; they appear as a side effect of the "
"operations they record and expire with the retention setting."
),
response_model=list[models.AuthAuditEntry],
)
async def find(
db: Annotated[AsyncSession, Depends(get_db_session)],
_principal: Annotated[Principal, Depends(require_admin())],
limit: Annotated[int, Query(ge=1, le=MAX_LIMIT, description="Maximum number of entries to return.")] = (
DEFAULT_LIMIT
),
offset: Annotated[int, Query(ge=0, description="How many entries to skip.")] = 0,
event: Annotated[str | None, Query(description="Only return entries for this event.")] = None,
actor_user_id: Annotated[int | None, Query(description="Only return entries by this user.")] = None,
) -> Response:
entries, total = await auth_audit_db.find(
db=db,
limit=limit,
offset=offset,
event=event,
actor_user_id=actor_user_id,
)
# Resolve the usernames in one pass rather than per row. A page of a hundred entries
# is typically a handful of distinct actors, and the alternative is a hundred
# queries against a database that may be a single SQLite file.
usernames: dict[int, str] = {}
for actor_id in {entry.actor_user_id for entry in entries if entry.actor_user_id is not None}:
try:
usernames[actor_id] = (await auth_user_db.get_by_id(db, actor_id)).username
except ItemNotFoundError:
# The account was deleted between the log write and now. The entry stays,
# unattributed, which is the point of keeping history past its subject.
logger.debug("Audit entry references user %d, which no longer exists.", actor_id)
return JSONResponse(
content=jsonable_encoder(
[
models.AuthAuditEntry.from_db(
entry,
usernames.get(entry.actor_user_id) if entry.actor_user_id is not None else None,
)
for entry in entries
],
exclude_none=True,
),
headers={"x-total-count": str(total)},
)
@router.get(
"/events",
name="List audit event types",
description=(
"List every event name this server can record, so a client can offer a filter "
"without hardcoding the vocabulary."
),
response_model=list[str],
)
async def events(
_principal: Annotated[Principal, Depends(require_admin())],
) -> Response:
return JSONResponse(content=[str(event) for event in AuditEvent])
+416
View File
@@ -0,0 +1,416 @@
"""Sign-in, sign-out and account endpoints.
Every endpoint here returns a JSON body, never a bare 204. The client's fetch helpers
parse a body unconditionally, so an empty response would surface as a parse failure
rather than a success.
These routes are reachable without credentials by necessity -- they are how a credential
is obtained -- so each does its own checking rather than relying on a permission gate.
"""
# ruff: noqa: D103
import logging
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman import env
from spoolman.auth import cookies
from spoolman.auth.audit import AuditEvent, record
from spoolman.auth.dependencies import anonymous_read_enabled, require_user, resolve_principal
from spoolman.auth.hashing import (
dummy_verify_async,
hash_password_async,
needs_rehash,
new_token,
verify_password_async,
)
from spoolman.auth.levels import Level
from spoolman.auth.principal import Principal, PrincipalKind
from spoolman.auth.ratelimit import client_ip, ip_window
from spoolman.database import auth_session as auth_session_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database import models as db_models
from spoolman.database.database import get_db_session
from . import models
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/auth",
tags=["auth"],
)
# One message for every failed sign-in. Distinguishing "no such user" from "wrong
# password" would let anyone enumerate accounts; the equal-time dummy verify below
# closes the same leak in the timing channel.
INVALID_CREDENTIALS = "Incorrect username or password."
BAD_REQUEST = 400
UNAUTHORIZED = 401
FORBIDDEN = 403
CONFLICT = 409
TOO_MANY_REQUESTS = 429
CREATED = 201
USER_AGENT_HEADER = "user-agent"
def _message(status_code: int, message: str, headers: dict[str, str] | None = None) -> JSONResponse:
"""Build an error response in the shape the rest of this API uses."""
return JSONResponse(status_code=status_code, content={"message": message}, headers=headers)
def _session_info(principal: Principal | None, user: models.AuthUserInfo | None = None) -> models.AuthSessionInfo:
"""Describe the caller's standing."""
if principal is None:
return models.AuthSessionInfo(
authenticated=False,
anonymous=False,
level=str(Level.READ),
is_admin=False,
is_owner=False,
)
return models.AuthSessionInfo(
authenticated=principal.kind in {PrincipalKind.USER, PrincipalKind.DISABLED},
anonymous=principal.kind is PrincipalKind.ANONYMOUS,
level=str(principal.level),
is_admin=principal.is_admin,
is_owner=principal.is_owner,
user=user,
)
def _failure_reason(user: db_models.AuthUser | None) -> str:
"""Say why a sign-in failed before a password was even checked, for the audit log."""
if user is None:
return "no_such_user"
if not user.is_active:
return "account_disabled"
return "no_password_set"
def _throttled(address: str) -> JSONResponse | None:
"""Check the per-address sign-in throttle, returning a 429 response if it has tripped.
Checked before any password hashing, so a flood of attempts cannot be turned into
CPU exhaustion against the single server process. Per-account throttling is separate
and lives on the user row; see :func:`spoolman.database.auth_user.lockout_for`.
"""
if not address:
return None
decision = ip_window.check(address)
if decision.allowed:
return None
return _message(
TOO_MANY_REQUESTS,
"Too many sign-in attempts. Try again shortly.",
{"Retry-After": str(decision.retry_after)},
)
async def _start_session(
*,
db: AsyncSession,
request: Request,
user_id: int,
remember: bool,
) -> tuple[str, str]:
"""Create a session row and return the tokens the cookies should carry."""
_, token = await auth_session_db.create(
db=db,
user_id=user_id,
remember=remember,
user_agent=request.headers.get(USER_AGENT_HEADER),
ip=client_ip(request) or None,
)
return token, new_token()
@router.get(
"/config",
name="Get authentication configuration",
description=(
"Report whether authentication is enabled and how to sign in. Always reachable "
"without credentials, since a client needs this before it can present any."
),
response_model=models.AuthConfig,
)
async def get_config(db: Annotated[AsyncSession, Depends(get_db_session)]) -> models.AuthConfig:
if not env.is_auth_enabled():
return models.AuthConfig(enabled=False, setup_required=False, anonymous_read=False)
return models.AuthConfig(
enabled=True,
setup_required=await auth_user_db.count(db) == 0,
anonymous_read=await anonymous_read_enabled(),
)
@router.post(
"/setup",
name="Claim this instance",
description=(
"Create the owner account. Only possible while no account exists; the owner "
"administers everyone else. The caller is signed in as the new owner."
),
response_model=models.AuthSessionInfo,
responses={403: {"model": models.Message}, 409: {"model": models.Message}},
status_code=201,
)
async def setup(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
body: models.SetupRequest,
) -> Response:
if not env.is_auth_enabled():
return _message(FORBIDDEN, "Authentication is not enabled on this instance.")
if await auth_user_db.count(db) > 0:
return _message(CONFLICT, "This instance has already been claimed.")
password_hash = await hash_password_async(body.password)
try:
user = await auth_user_db.create(
db=db,
username=body.username,
password_hash=password_hash,
level=Level.MANAGE,
is_admin=True,
is_owner=True,
display_name=body.display_name,
)
except IntegrityError:
# Two callers raced for an unclaimed instance; the unique index on username
# decided it. The loser is told the same thing as a late arrival.
await db.rollback()
return _message(CONFLICT, "This instance has already been claimed.")
# Loud on purpose: whoever reached the instance first now owns it, and the operator
# should be able to see from the log whether that was them.
logger.warning(
"Instance ownership claimed by %r from %s.",
user.username,
client_ip(request) or "an unknown address",
)
await record(request, AuditEvent.INSTANCE_CLAIMED, actor_user_id=user.id, target=user.username)
token, csrf = await _start_session(db=db, request=request, user_id=user.id, remember=False)
info = _session_info(
Principal(
kind=PrincipalKind.USER,
level=Level.MANAGE,
user_id=user.id,
username=user.username,
is_admin=True,
is_owner=True,
),
models.AuthUserInfo.from_db(user),
)
response = JSONResponse(status_code=CREATED, content=info.model_dump())
cookies.set_session_cookies(response, request, token=token, csrf=csrf, remember=False)
return response
@router.post(
"/login",
name="Sign in",
description="Exchange a username and password for a session cookie.",
response_model=models.AuthSessionInfo,
responses={
401: {"model": models.Message},
403: {"model": models.Message},
429: {"model": models.Message},
},
)
async def login(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
body: models.LoginRequest,
) -> Response:
if not env.is_auth_enabled():
return _message(FORBIDDEN, "Authentication is not enabled on this instance.")
username = auth_user_db.normalize_username(body.username)
address = client_ip(request)
throttled = _throttled(address)
if throttled is not None:
return throttled
user = await auth_user_db.get_by_username(db, username)
if user is None or not user.is_active or user.password_hash is None:
# Spend a verification's worth of time anyway, so that a missing, disabled or
# password-less account is indistinguishable from a wrong password.
await dummy_verify_async()
if address:
ip_window.hit(address)
# The audit entry does distinguish these cases, unlike the response. 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.
await record(
request,
AuditEvent.LOGIN_FAILURE,
actor_user_id=user.id if user is not None else None,
target=username,
detail={"reason": _failure_reason(user)},
)
return _message(UNAUTHORIZED, INVALID_CREDENTIALS)
if auth_user_db.is_locked(user) and user.locked_until is not None:
# Deliberately not spending a hash here: the answer is already decided, and
# burning CPU on locked accounts is a self-inflicted denial of service.
retry_after = max(int((user.locked_until - datetime.utcnow()).total_seconds()), 1)
await record(
request,
AuditEvent.LOGIN_LOCKED,
actor_user_id=user.id,
target=username,
detail={"retry_after": retry_after},
)
return _message(
TOO_MANY_REQUESTS,
"Too many sign-in attempts. Try again shortly.",
{"Retry-After": str(retry_after)},
)
if not await verify_password_async(body.password, user.password_hash):
await auth_user_db.record_login_failure(db=db, user=user)
if address:
ip_window.hit(address)
await record(
request,
AuditEvent.LOGIN_FAILURE,
actor_user_id=user.id,
target=username,
detail={"reason": "bad_password", "failed_logins": user.failed_logins},
)
return _message(UNAUTHORIZED, INVALID_CREDENTIALS)
await auth_user_db.record_login_success(db=db, user=user)
await record(request, AuditEvent.LOGIN_SUCCESS, actor_user_id=user.id, target=user.username)
# Upgrade the stored hash if the cost parameters have been raised since it was made.
if needs_rehash(user.password_hash):
await auth_user_db.rehash_password(
db=db,
user=user,
password_hash=await hash_password_async(body.password),
)
token, csrf = await _start_session(db=db, request=request, user_id=user.id, remember=body.remember)
info = _session_info(
Principal(
kind=PrincipalKind.USER,
level=Level(user.level),
user_id=user.id,
username=user.username,
is_admin=user.is_admin,
is_owner=user.is_owner,
),
models.AuthUserInfo.from_db(user),
)
response = JSONResponse(content=info.model_dump())
cookies.set_session_cookies(response, request, token=token, csrf=csrf, remember=body.remember)
return response
@router.post(
"/logout",
name="Sign out",
description="End the current session. Succeeds even if there was no session to end.",
response_model=models.Message,
)
async def logout(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
) -> Response:
# Idempotent, and reachable without a gate: signing out has to work even when the
# session has already expired server-side, which is exactly when a client most wants
# its cookies cleared.
token = request.cookies.get(cookies.SESSION_COOKIE, "")
if token:
# Resolved before the revoke, while the token still identifies somebody. A
# sign-out that cannot say who signed out is not worth recording.
principal = await resolve_principal(request)
await auth_session_db.revoke_by_token(db, token)
if principal is not None and principal.user_id is not None:
await record(request, AuditEvent.LOGOUT, principal=principal, target=principal.username)
response = JSONResponse(content={"message": "Signed out."})
cookies.clear_session_cookies(response, request)
return response
@router.get(
"/session",
name="Get the current session",
description=(
"Describe the caller's current standing. Answers for signed-out callers too, "
"so a client can distinguish having no session from being unable to reach the server."
),
response_model=models.AuthSessionInfo,
)
async def get_session(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
) -> models.AuthSessionInfo:
if not env.is_auth_enabled():
return models.AuthSessionInfo(
authenticated=True,
anonymous=False,
level=str(Level.MANAGE),
is_admin=True,
is_owner=True,
)
principal = await resolve_principal(request)
user_info = None
if principal is not None and principal.user_id is not None:
user_info = models.AuthUserInfo.from_db(await auth_user_db.get_by_id(db, principal.user_id))
return _session_info(principal, user_info)
@router.post(
"/password",
name="Change own password",
description="Replace one's own password. Requires the current password.",
response_model=models.Message,
responses={400: {"model": models.Message}, 401: {"model": models.Message}},
)
async def change_password(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_user())],
body: models.PasswordChangeRequest,
) -> Response:
if principal.user_id is None:
return _message(UNAUTHORIZED, "Authentication required.")
user = await auth_user_db.get_by_id(db, principal.user_id)
if user.password_hash is None or not await verify_password_async(body.current_password, user.password_hash):
await record(
request,
AuditEvent.LOGIN_FAILURE,
principal=principal,
target=user.username,
detail={"reason": "bad_password", "during": "password_change"},
)
return _message(BAD_REQUEST, "The current password is incorrect.")
await auth_user_db.set_password(
db=db,
user=user,
password_hash=await hash_password_async(body.new_password),
)
# Other sessions are deliberately left alone. Someone changing their password
# routinely does not expect their phone to be signed out; an administrator who
# suspects a compromise has "sign out everywhere" for that, and a reset performed
# for them revokes everything.
await record(request, AuditEvent.PASSWORD_CHANGED, principal=principal, target=user.username)
return JSONResponse(content={"message": "Password changed."})
+5
View File
@@ -8,6 +8,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth.dependencies import require_level
from spoolman.auth.levels import Level
from spoolman.database import filament, spool, vendor
from spoolman.database.database import get_db_session
from spoolman.database.models import Base
@@ -29,6 +31,7 @@ class ExportFormat(Enum):
"/spools",
name="Export spools",
description="Export the list of spools in various formats. Filament and vendor data is included.",
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def export_spools(
*,
@@ -47,6 +50,7 @@ async def export_spools(
"/filaments",
name="Export filaments",
description="Export the list of filaments in various formats. Vendor data is included.",
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def export_filaments(
*,
@@ -61,6 +65,7 @@ async def export_filaments(
"/vendors",
name="Export vendors",
description="Export the list of vendors in various formats.",
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def export_vendors(
*,
+6 -1
View File
@@ -3,9 +3,11 @@
import logging
from typing import Annotated
from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse
from spoolman.auth.dependencies import require_level
from spoolman.auth.levels import Level
from spoolman.externaldb import (
ExternalFilament,
ExternalMaterial,
@@ -28,6 +30,7 @@ logger = logging.getLogger(__name__)
name="Get all external filaments",
response_model_exclude_none=True,
response_model=list[ExternalFilament],
dependencies=[Depends(require_level(Level.READ))],
)
async def filaments() -> FileResponse:
"""Get all external filaments."""
@@ -38,6 +41,7 @@ async def filaments() -> FileResponse:
"/filament/search",
name="Search external filaments",
response_model_exclude_none=True,
dependencies=[Depends(require_level(Level.READ))],
)
async def search_external_filaments(
query: Annotated[
@@ -65,6 +69,7 @@ async def search_external_filaments(
name="Get all external materials",
response_model_exclude_none=True,
response_model=list[ExternalMaterial],
dependencies=[Depends(require_level(Level.READ))],
)
async def materials() -> FileResponse:
"""Get all external materials."""
+6
View File
@@ -8,6 +8,8 @@ from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message
from spoolman.auth.dependencies import require_level
from spoolman.auth.levels import Level
from spoolman.database.database import get_db_session
from spoolman.database.extra_field_query import find_extra_field_values
from spoolman.exceptions import ItemNotFoundError
@@ -35,6 +37,7 @@ logger = logging.getLogger(__name__)
name="Get extra fields",
description="Get all extra fields for a specific entity type.",
response_model_exclude_none=True,
dependencies=[Depends(require_level(Level.READ))],
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -54,6 +57,7 @@ async def get(
response_model_exclude_none=True,
response_model=list[str],
responses={404: {"model": Message}},
dependencies=[Depends(require_level(Level.READ))],
)
async def get_values(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -76,6 +80,7 @@ async def get_values(
response_model_exclude_none=True,
response_model=list[ExtraField],
responses={400: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def update(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -105,6 +110,7 @@ async def update(
response_model_exclude_none=True,
response_model=list[ExtraField],
responses={404: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
+11 -4
View File
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Filament, FilamentEvent, Message, MultiColorDirection
from spoolman.auth.dependencies import require_level, ws_authenticated
from spoolman.auth.levels import Level
from spoolman.database import filament
from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
@@ -158,7 +160,7 @@ class FilamentParameters(BaseModel):
return v
@model_validator(mode="after") # type: ignore[]
def validate(self) -> "FilamentParameters":
def validate(self) -> FilamentParameters:
"""Validate the model."""
if self.color_hex and self.multi_color_hexes:
raise ValueError("Cannot specify both color_hex and multi_color_hexes.")
@@ -178,7 +180,7 @@ class FilamentUpdateParameters(FilamentParameters):
@field_validator("density", "diameter")
@classmethod
def prevent_none(cls: type["FilamentUpdateParameters"], v: float | None) -> float | None:
def prevent_none(cls: type[FilamentUpdateParameters], v: float | None) -> float | None:
"""Prevent density and diameter from being None."""
if v is None:
raise ValueError("Value must not be None.")
@@ -198,6 +200,7 @@ class FilamentUpdateParameters(FilamentParameters):
200: {"model": list[Filament]},
299: {"model": FilamentEvent, "description": "Websocket message"},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find(
*,
@@ -382,10 +385,10 @@ async def find(
"",
name="Listen to filament changes",
)
@ws_authenticated(Level.READ)
async def notify_any(
websocket: WebSocket,
) -> None:
await websocket.accept()
websocket_manager.connect(("filament",), websocket)
try:
while True:
@@ -405,6 +408,7 @@ async def notify_any(
),
response_model_exclude_none=True,
responses={404: {"model": Message}, 299: {"model": FilamentEvent, "description": "Websocket message"}},
dependencies=[Depends(require_level(Level.READ))],
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -418,11 +422,11 @@ async def get(
"/{filament_id}",
name="Listen to filament changes",
)
@ws_authenticated(Level.READ)
async def notify(
websocket: WebSocket,
filament_id: int,
) -> None:
await websocket.accept()
websocket_manager.connect(("filament", str(filament_id)), websocket)
try:
while True:
@@ -440,6 +444,7 @@ async def notify(
response_model_exclude_none=True,
response_model=Filament,
responses={400: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def create( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -489,6 +494,7 @@ async def create( # noqa: ANN201
400: {"model": Message},
404: {"model": Message},
},
dependencies=[Depends(require_level(Level.EDIT))],
)
async def update( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -522,6 +528,7 @@ async def update( # noqa: ANN201
403: {"model": Message},
404: {"model": Message},
},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def delete( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
+287 -6
View File
@@ -1,11 +1,14 @@
"""Pydantic data models for typing the FastAPI request/responses."""
from datetime import datetime, timezone
import json
from datetime import UTC, datetime
from enum import Enum
from typing import Annotated, Literal
from pydantic import BaseModel, Field, PlainSerializer
from spoolman.auth import apikey
from spoolman.auth.levels import parse_level
from spoolman.database import models
from spoolman.math import length_from_weight
from spoolman.settings import SettingDefinition, SettingType
@@ -14,7 +17,7 @@ from spoolman.settings import SettingDefinition, SettingType
def datetime_to_str(dt: datetime) -> str:
"""Convert a datetime object to a string."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.replace(tzinfo=UTC)
return dt.isoformat().replace("+00:00", "Z")
@@ -51,7 +54,7 @@ class SettingKV(BaseModel):
setting: SettingResponse = Field(description="Setting value.")
@staticmethod
def from_db(definition: SettingDefinition, set_value: str | None) -> "SettingKV":
def from_db(definition: SettingDefinition, set_value: str | None) -> SettingKV:
"""Create a new Pydantic vendor object from a database vendor object."""
return SettingKV(
key=definition.key,
@@ -92,7 +95,7 @@ class Vendor(BaseModel):
)
@staticmethod
def from_db(item: models.Vendor) -> "Vendor":
def from_db(item: models.Vendor) -> Vendor:
"""Create a new Pydantic vendor object from a database vendor object."""
return Vendor(
id=item.id,
@@ -208,7 +211,7 @@ class Filament(BaseModel):
)
@staticmethod
def from_db(item: models.Filament) -> "Filament":
def from_db(item: models.Filament) -> Filament:
"""Create a new Pydantic filament object from a database filament object."""
return Filament(
id=item.id,
@@ -317,7 +320,7 @@ class Spool(BaseModel):
)
@staticmethod
def from_db(item: models.Spool) -> "Spool":
def from_db(item: models.Spool) -> Spool:
"""Create a new Pydantic spool object from a database spool object."""
filament = Filament.from_db(item.filament)
@@ -533,3 +536,281 @@ class SettingEvent(Event):
payload: SettingKV = Field(description="Updated setting.")
resource: Literal["setting"] = Field(description="Resource type.")
#
# Authentication.
#
# These describe the /auth endpoints. They are additive: nothing above changes shape,
# and none of this is reachable in a meaningful way unless SPOOLMAN_AUTH_ENABLED is set.
#
# Short enough to stay usable for a homelab instance, long enough that the lockout in
# spoolman/database/auth_user.py is the binding constraint rather than the search space.
MIN_PASSWORD_LENGTH = 10
MAX_PASSWORD_LENGTH = 1024
MAX_USERNAME_LENGTH = 64
MAX_DISPLAY_NAME_LENGTH = 128
class AuthOidcConfig(BaseModel):
"""OIDC provider advertisement. Phase 3; always absent for now."""
enabled: bool = Field(default=False, description="Whether an OIDC provider is configured.")
name: str | None = Field(default=None, description="Display name for the sign-in button.")
class AuthConfig(BaseModel):
"""What a client needs to know before anyone has signed in."""
enabled: bool = Field(description="Whether authentication is enforced on this instance.")
setup_required: bool = Field(
description="Whether this instance is unclaimed. When true, anyone may claim ownership.",
)
anonymous_read: bool = Field(description="Whether unauthenticated clients may read.")
oidc: AuthOidcConfig = Field(default_factory=AuthOidcConfig, description="OIDC provider info.")
mtls: bool = Field(default=False, description="Whether client certificates are accepted. Phase 5.")
class AuthUserInfo(BaseModel):
"""A user account, as shown to itself or an administrator."""
id: int = Field(description="Unique internal ID of this user.")
username: str = Field(description="Username, stored lowercased.")
display_name: str | None = Field(default=None, description="Friendly name, if set.")
level: str = Field(description="Permission level: read, edit or manage.", examples=["edit"])
is_admin: bool = Field(description="Whether this user administers other users.")
is_owner: bool = Field(description="Whether this user owns the instance.")
is_active: bool = Field(default=True, description="Whether the account may sign in at all.")
must_change_password: bool = Field(description="Whether a password change is required.")
@staticmethod
def from_db(user: models.AuthUser) -> AuthUserInfo:
"""Create a Pydantic user object from a database user object."""
return AuthUserInfo(
id=user.id,
username=user.username,
display_name=user.display_name,
level=user.level,
is_admin=user.is_admin,
is_owner=user.is_owner,
is_active=user.is_active,
must_change_password=user.must_change_password,
)
class AuthSessionInfo(BaseModel):
"""The caller's current standing.
Always answers rather than rejecting, so a signed-out client can tell "no session"
apart from "server unreachable" without treating a 401 as an error.
"""
authenticated: bool = Field(description="Whether a user is signed in.")
anonymous: bool = Field(description="Whether access comes from the anonymous-read setting.")
level: str = Field(description="Effective permission level. 'manage' when auth is disabled.")
is_admin: bool = Field(description="Whether the caller administers other users.")
is_owner: bool = Field(description="Whether the caller owns the instance.")
user: AuthUserInfo | None = Field(default=None, description="The signed-in user, if any.")
class LoginRequest(BaseModel):
"""Credentials for a sign-in attempt."""
username: str = Field(min_length=1, max_length=MAX_USERNAME_LENGTH, description="Username.")
password: str = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH, description="Password.")
remember: bool = Field(default=False, description="Whether the session should outlive the browser session.")
class SetupRequest(BaseModel):
"""The owner account to create when claiming an unclaimed instance."""
username: str = Field(min_length=1, max_length=MAX_USERNAME_LENGTH, description="Username for the owner.")
password: str = Field(
min_length=MIN_PASSWORD_LENGTH,
max_length=MAX_PASSWORD_LENGTH,
description="Password for the owner.",
)
display_name: str | None = Field(default=None, max_length=MAX_DISPLAY_NAME_LENGTH, description="Friendly name.")
class PasswordChangeRequest(BaseModel):
"""A request to replace one's own password."""
current_password: str = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH, description="The current password.")
new_password: str = Field(
min_length=MIN_PASSWORD_LENGTH,
max_length=MAX_PASSWORD_LENGTH,
description="The new password.",
)
#
# Authentication, phase 2: API keys, user administration and the audit log.
#
MAX_KEY_NAME_LENGTH = 64
MAX_AUDIT_EVENT_LENGTH = 64
# The longest an API key may be issued for. Mirrors
# spoolman.database.auth_api_key.MAX_EXPIRY_DAYS; validated here so a bad value is a 422
# from the schema rather than a database error.
MAX_KEY_EXPIRY_DAYS = 3650
class AuthApiKeyInfo(BaseModel):
"""An API key, as shown to its owner.
Never carries the secret. The complete key exists in a readable form exactly once,
in the response to the request that created it.
"""
id: int = Field(description="Unique internal ID of this key.")
name: str = Field(description="The label its owner gave it.")
level: str = Field(description="Permission level the key was issued with.", examples=["edit"])
effective_level: str = Field(
description="What the key can actually do right now, after capping against its owner's level.",
examples=["read"],
)
prefix: str = Field(description="The public half of the key, enough to recognise it in a log.")
created: SpoolmanDateTime = Field(description="When the key was issued.")
expires: SpoolmanDateTime | None = Field(default=None, description="When the key stops working, if ever.")
last_used: SpoolmanDateTime | None = Field(
default=None,
description="When the key was last accepted. Updated at most once every few minutes.",
)
revoked: bool = Field(description="Whether the key has been withdrawn.")
expired: bool = Field(description="Whether the key's expiry has passed.")
@staticmethod
def from_db(key: models.AuthApiKey, owner_level: str) -> AuthApiKeyInfo:
"""Create a Pydantic API key object from a database API key object."""
return AuthApiKeyInfo(
id=key.id,
name=key.name,
level=key.level,
effective_level=str(apikey.effective_level(parse_level(key.level), parse_level(owner_level))),
prefix=key.prefix,
created=key.created,
expires=key.expires,
last_used=key.last_used,
revoked=key.revoked,
expired=key.expires is not None and key.expires <= datetime.utcnow(),
)
class AuthApiKeyCreated(BaseModel):
"""A newly issued API key, including the secret."""
key: str = Field(description="The complete key. Shown once and never again; store it now.")
info: AuthApiKeyInfo = Field(description="The key's metadata, as it will appear in listings.")
class ApiKeyCreateRequest(BaseModel):
"""A request to issue an API key."""
name: str = Field(min_length=1, max_length=MAX_KEY_NAME_LENGTH, description="A label, to tell keys apart.")
level: str = Field(
description="Permission level to issue at. May not exceed the creator's own level.",
examples=["edit"],
)
expires_days: int | None = Field(
default=None,
ge=1,
le=MAX_KEY_EXPIRY_DAYS,
description="How many days the key should live for. Omit for a key that never expires.",
)
class AuthUserCreateRequest(BaseModel):
"""An account for an administrator to create."""
username: str = Field(min_length=1, max_length=MAX_USERNAME_LENGTH, description="Username, stored lowercased.")
password: str | None = Field(
default=None,
min_length=MIN_PASSWORD_LENGTH,
max_length=MAX_PASSWORD_LENGTH,
description="Initial password. Omit to have one generated and returned once.",
)
display_name: str | None = Field(default=None, max_length=MAX_DISPLAY_NAME_LENGTH, description="Friendly name.")
level: str = Field(default="read", description="Permission level to grant.", examples=["edit"])
is_admin: bool = Field(default=False, description="Whether the account administers other users.")
must_change_password: bool = Field(
default=True,
description="Whether the user must choose a new password at next sign-in.",
)
class AuthUserUpdateRequest(BaseModel):
"""Changes to an existing account. Omitted fields are left alone."""
display_name: str | None = Field(default=None, max_length=MAX_DISPLAY_NAME_LENGTH, description="Friendly name.")
level: str | None = Field(default=None, description="Permission level to grant.", examples=["manage"])
is_admin: bool | None = Field(default=None, description="Whether the account administers other users.")
is_active: bool | None = Field(default=None, description="Whether the account may sign in at all.")
class AuthPasswordResetRequest(BaseModel):
"""An administrator's replacement of somebody else's password."""
password: str | None = Field(
default=None,
min_length=MIN_PASSWORD_LENGTH,
max_length=MAX_PASSWORD_LENGTH,
description="The new password. Omit to have one generated and returned once.",
)
must_change_password: bool = Field(
default=True,
description="Whether the user must choose a new password at next sign-in.",
)
class AuthUserCreated(BaseModel):
"""A newly created account, with its password if one was generated."""
user: AuthUserInfo = Field(description="The account.")
password: str | None = Field(
default=None,
description="The generated password, present only when the caller did not supply one. Shown once.",
)
class AuthAuditEntry(BaseModel):
"""One line of the audit trail."""
id: int = Field(description="Unique internal ID of this entry.")
date: SpoolmanDateTime = Field(description="When it happened, in UTC.")
event: str = Field(description="What happened.", examples=["login.success"])
actor_user_id: int | None = Field(default=None, description="Which account acted, if it still exists.")
actor_username: str | None = Field(default=None, description="That account's username, resolved for display.")
actor_kind: str = Field(description="How the actor was authenticated.", examples=["user"])
target: str | None = Field(default=None, description="What was acted on.")
ip: str | None = Field(default=None, description="Where the request came from.")
user_agent: str | None = Field(default=None, description="What the request identified itself as.")
detail: dict | None = Field(default=None, description="Anything else worth keeping.")
@staticmethod
def from_db(entry: models.AuthAuditLog, actor_username: str | None = None) -> AuthAuditEntry:
"""Create a Pydantic audit entry from a database audit entry."""
detail = None
if entry.detail:
try:
parsed = json.loads(entry.detail)
except ValueError:
# A row written by a future version, or corrupted. Showing the raw text
# beats dropping the entry: the point of an audit log is that entries do
# not silently vanish.
parsed = {"raw": entry.detail}
detail = parsed if isinstance(parsed, dict) else {"value": parsed}
return AuthAuditEntry(
id=entry.id,
date=entry.date,
event=entry.event,
actor_user_id=entry.actor_user_id,
actor_username=actor_username,
actor_kind=entry.actor_kind,
target=entry.target,
ip=entry.ip,
user_agent=entry.user_agent,
detail=detail,
)
+7
View File
@@ -7,6 +7,8 @@ from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, RootModel
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth.dependencies import require_level
from spoolman.auth.levels import Level
from spoolman.database import filament, spool
from spoolman.database.database import get_db_session
@@ -39,6 +41,7 @@ router = APIRouter(
},
},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find_materials(
*,
@@ -65,6 +68,7 @@ async def find_materials(
},
},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find_article_numbers(
*,
@@ -91,6 +95,7 @@ async def find_article_numbers(
},
},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find_lot_numbers(
*,
@@ -118,6 +123,7 @@ async def find_lot_numbers(
},
},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find_locations(
*,
@@ -136,6 +142,7 @@ class RenameLocationBody(BaseModel):
description="Rename a spool location. All spools in this location will be moved to the new location.",
response_model_exclude_none=True,
response_model=RootModel[str],
dependencies=[Depends(require_level(Level.EDIT))],
)
async def rename_location(
location: str,
+81 -5
View File
@@ -5,18 +5,36 @@
import asyncio
import logging
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from starlette.requests import Request
from starlette.responses import Response
from spoolman import env
from spoolman.auth.coverage import assert_routes_covered
from spoolman.auth.dependencies import require_level, ws_authenticated
from spoolman.auth.levels import Level
from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.exceptions import AuthenticationRequiredError, ItemNotFoundError, PermissionDeniedError
from spoolman.externaldb import get_external_db_name
from spoolman.ws import websocket_manager
from . import export, externaldb, field, filament, models, other, search, setting, spool, vendor
from . import (
apikey,
audit,
auth,
export,
externaldb,
field,
filament,
models,
other,
search,
setting,
spool,
users,
vendor,
)
logger = logging.getLogger(__name__)
@@ -44,8 +62,33 @@ async def itemnotfounderror_exception_handler(_request: Request, exc: ItemNotFou
)
# The body shape matches models.Message, the same as every other error this API returns,
# rather than FastAPI's default {"detail": ...}. Handlers have to be registered on this
# app and not the outer one: Starlette resolves them from the mounted app that owns the
# route, which is why the handler above lives here too.
@app.exception_handler(AuthenticationRequiredError)
async def authenticationrequirederror_exception_handler(
_request: Request,
exc: AuthenticationRequiredError,
) -> Response:
logger.debug(exc)
return JSONResponse(
status_code=401,
content={"message": exc.args[0]},
)
@app.exception_handler(PermissionDeniedError)
async def permissiondeniederror_exception_handler(_request: Request, exc: PermissionDeniedError) -> Response:
logger.debug(exc)
return JSONResponse(
status_code=403,
content={"message": exc.args[0]},
)
# Add a general info endpoint
@app.get("/info")
@app.get("/info", dependencies=[Depends(require_level(Level.READ))])
async def info() -> models.Info:
"""Return general info about the API."""
return models.Info(
@@ -75,6 +118,7 @@ async def health() -> models.HealthCheck:
description="Trigger a database backup. Only applicable for SQLite databases.",
response_model=models.BackupResponse,
responses={500: {"model": models.Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def backup(): # noqa: ANN201
"""Trigger a database backup."""
@@ -91,10 +135,10 @@ async def backup(): # noqa: ANN201
"/",
name="Listen to any changes",
)
@ws_authenticated(Level.READ)
async def notify(
websocket: WebSocket,
) -> None:
await websocket.accept()
websocket_manager.connect((), websocket)
try:
while True:
@@ -106,6 +150,10 @@ async def notify(
# Add routers
app.include_router(auth.router)
app.include_router(apikey.router)
app.include_router(users.router)
app.include_router(audit.router)
app.include_router(filament.router)
app.include_router(spool.router)
app.include_router(vendor.router)
@@ -115,3 +163,31 @@ app.include_router(other.router)
app.include_router(externaldb.router)
app.include_router(export.router)
app.include_router(search.router)
# Routes that must stay reachable without credentials.
#
# /health is polled by container healthchecks and by the frontend test harness, and is
# the one endpoint that has to answer before anyone can possibly be signed in. The auth
# endpoints bootstrap a session, so requiring one would be circular; each performs its
# own checking. Everything else is gated.
PUBLIC_ROUTES = frozenset(
{
("GET", "/health"),
# Signing in is how a credential is obtained, so requiring one would be
# circular. Each of these checks for itself: /setup refuses once an account
# exists, /login throttles and verifies, /logout and /session are read-only
# about the caller and answer honestly when there is no session.
("GET", "/auth/config"),
("POST", "/auth/setup"),
("POST", "/auth/login"),
("POST", "/auth/logout"),
("GET", "/auth/session"),
},
)
# Proving coverage here, at import, is the point: a route added without a gate stops the
# process immediately rather than quietly serving unauthenticated traffic. This runs
# before uvicorn binds a port and before migrations, and equally when the module is
# imported by the docs generator or a test.
ROUTE_LEVELS = assert_routes_covered(app, PUBLIC_ROUTES)
+3
View File
@@ -15,6 +15,8 @@ from spoolman.api.v1.models import (
Spool,
Vendor,
)
from spoolman.auth.dependencies import require_level
from spoolman.auth.levels import Level
from spoolman.database import search
from spoolman.database.database import get_db_session
@@ -43,6 +45,7 @@ router = APIRouter(
"reports which field matched."
),
response_model_exclude_none=True,
dependencies=[Depends(require_level(Level.READ))],
)
async def search_endpoint(
*,
+7 -2
View File
@@ -9,6 +9,8 @@ from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, SettingEvent, SettingResponse
from spoolman.auth.dependencies import require_level, ws_authenticated
from spoolman.auth.levels import Level
from spoolman.database import setting
from spoolman.database.database import get_db_session
from spoolman.exceptions import ItemNotFoundError
@@ -29,10 +31,10 @@ logger = logging.getLogger(__name__)
"",
name="Listen to setting changes",
)
@ws_authenticated(Level.READ)
async def notify_any(
websocket: WebSocket,
) -> None:
await websocket.accept()
websocket_manager.connect(("setting",), websocket)
try:
while True:
@@ -54,6 +56,7 @@ async def notify_any(
response_model_exclude_none=True,
response_model=SettingResponse,
responses={404: {"model": Message}, 299: {"model": SettingEvent, "description": "Websocket message"}},
dependencies=[Depends(require_level(Level.READ))],
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -85,6 +88,7 @@ async def get(
description=("Get all settings, set or not. If the setting has not been set, 'value' will be the default value."),
response_model_exclude_none=True,
response_model=dict[str, SettingResponse],
dependencies=[Depends(require_level(Level.READ))],
)
async def find(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -121,6 +125,7 @@ async def find(
"/{key}",
name="Listen to setting changes",
)
@ws_authenticated(Level.READ)
async def notify(
websocket: WebSocket,
key: str,
@@ -131,7 +136,6 @@ async def notify(
await websocket.close(code=4040, reason=str(e))
return
await websocket.accept()
websocket_manager.connect(("setting", str(key)), websocket)
try:
while True:
@@ -153,6 +157,7 @@ async def notify(
response_model_exclude_none=True,
response_model=SettingResponse,
responses={404: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def update(
db: Annotated[AsyncSession, Depends(get_db_session)],
+13 -3
View File
@@ -13,6 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.datastructures import QueryParams
from spoolman.api.v1.models import Filament, Message, Spool, SpoolEvent, SpoolGroup, Vendor
from spoolman.auth.dependencies import require_level, ws_authenticated
from spoolman.auth.levels import Level
from spoolman.database import spool
from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
@@ -117,7 +119,7 @@ class SpoolUpdateParameters(SpoolParameters):
@field_validator("filament_id")
@classmethod
def prevent_none(cls: type["SpoolUpdateParameters"], v: int | None) -> int | None:
def prevent_none(cls: type[SpoolUpdateParameters], v: int | None) -> int | None:
"""Prevent filament_id from being None."""
if v is None:
raise ValueError("Value must not be None.")
@@ -146,6 +148,7 @@ class SpoolMeasureParameters(BaseModel):
200: {"model": list[Spool]},
299: {"model": SpoolEvent, "description": "Websocket message"},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find(
*,
@@ -361,10 +364,10 @@ async def find(
"",
name="Listen to spool changes",
)
@ws_authenticated(Level.READ)
async def notify_any(
websocket: WebSocket,
) -> None:
await websocket.accept()
websocket_manager.connect(("spool",), websocket)
try:
while True:
@@ -390,6 +393,7 @@ async def notify_any(
200: {"model": list[SpoolGroup]},
400: {"model": Message},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find_groups(
*,
@@ -551,6 +555,7 @@ async def find_groups(
),
response_model_exclude_none=True,
responses={404: {"model": Message}, 299: {"model": SpoolEvent, "description": "Websocket message"}},
dependencies=[Depends(require_level(Level.READ))],
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -564,11 +569,11 @@ async def get(
"/{spool_id}",
name="Listen to spool changes",
)
@ws_authenticated(Level.READ)
async def notify(
websocket: WebSocket,
spool_id: int,
) -> None:
await websocket.accept()
websocket_manager.connect(("spool", str(spool_id)), websocket)
try:
while True:
@@ -592,6 +597,7 @@ async def notify(
responses={
400: {"model": Message},
},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def create( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -651,6 +657,7 @@ async def create( # noqa: ANN201
400: {"model": Message},
404: {"model": Message},
},
dependencies=[Depends(require_level(Level.EDIT))],
)
async def update( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -693,6 +700,7 @@ async def update( # noqa: ANN201
name="Delete spool",
description="Delete a spool.",
responses={404: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -714,6 +722,7 @@ async def delete(
400: {"model": Message},
404: {"model": Message},
},
dependencies=[Depends(require_level(Level.EDIT))],
)
async def use( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -750,6 +759,7 @@ async def use( # noqa: ANN201
400: {"model": Message},
404: {"model": Message},
},
dependencies=[Depends(require_level(Level.EDIT))],
)
async def measure( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
+430
View File
@@ -0,0 +1,430 @@
"""User administration endpoints.
Everything here needs ``require_admin``, which in turn needs a signed-in session -- an
API key never carries administrative rights, so a leaked printer credential cannot be
used to create a second account and entrench itself.
Two accounts are protected from the operations below, and both protections exist to stop
an instance being administered into a state nobody can recover from without the command
line:
* The owner cannot be demoted, disabled or deleted by anyone, themselves included. It is
the account that can always fix the others.
* An administrator cannot demote, disable or delete themselves. Someone else with the
rights has to do it, which guarantees a second pair of hands still exists afterwards.
Neither protection is a security boundary. Both are recoverable with
``python -m spoolman.cli``, which is the escape hatch when there is no working account
left at all.
"""
# ruff: noqa: D103
import logging
import secrets
from typing import Annotated
from fastapi import APIRouter, Depends, Query, Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from spoolman.auth.audit import AuditEvent, record
from spoolman.auth.dependencies import require_admin
from spoolman.auth.hashing import hash_password_async
from spoolman.auth.levels import parse_level
from spoolman.auth.principal import Principal
from spoolman.database import auth_api_key as auth_api_key_db
from spoolman.database import auth_audit as auth_audit_db
from spoolman.database import auth_session as auth_session_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database import models as db_models
from spoolman.database.database import get_db_session
from spoolman.exceptions import ItemNotFoundError
from . import models
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/auth/user",
tags=["auth"],
)
BAD_REQUEST = 400
FORBIDDEN = 403
NOT_FOUND = 404
CONFLICT = 409
CREATED = 201
# Length of a generated password, in bytes of entropy before base64. 16 bytes is 128
# bits, which is far past anything the account lockout would ever let be guessed; the
# length is chosen to still be transcribable rather than for the strength.
GENERATED_PASSWORD_BYTES = 16
def _message(status_code: int, message: str) -> JSONResponse:
"""Build an error response in the shape the rest of this API uses."""
return JSONResponse(status_code=status_code, content={"message": message})
def _generate_password() -> str:
"""Make up a password for an account whose creator did not supply one."""
return secrets.token_urlsafe(GENERATED_PASSWORD_BYTES)
def _protected(principal: Principal, user: db_models.AuthUser) -> str | None:
"""Check whether an account is shielded from a destructive change.
Args:
principal: Who is asking.
user: The account being changed.
Returns:
Optional[str]: A refusal message, or None if the change may proceed.
"""
if user.is_owner:
return "The instance owner cannot be demoted, disabled or deleted."
if user.id == principal.user_id:
return "You cannot demote, disable or delete your own account. Ask another administrator."
return None
class _Refused(Exception): # noqa: N818
"""A change the protections above will not allow.
An exception rather than a returned value so that :func:`_collect_changes` can stay
a straight-line description of the four fields instead of threading a refusal back
through every branch. Caught immediately by the one handler that raises it, and
never escapes this module.
"""
def __init__(self, status_code: int, message: str) -> None:
"""Record the refusal."""
super().__init__(message)
self.status_code = status_code
self.message = message
def _collect_changes(
principal: Principal,
user: db_models.AuthUser,
body: models.AuthUserUpdateRequest,
) -> dict[str, object]:
"""Work out what an update actually changes, refusing protected changes.
Fields that are omitted, or that already hold the requested value, produce no
change. That matters for the protections: re-sending an unchanged account from a
form must not be refused just because the form included the owner's own level.
Args:
principal: Who is asking.
user: The account being changed.
body: The requested changes.
Raises:
_Refused: If a requested change is not allowed.
Returns:
dict: The fields to change and their new values.
"""
changes: dict[str, object] = {}
def guard() -> None:
refusal = _protected(principal, user)
if refusal is not None:
raise _Refused(FORBIDDEN, refusal)
if body.level is not None and body.level != user.level:
try:
level = parse_level(body.level)
except ValueError as exc:
raise _Refused(BAD_REQUEST, str(exc)) from exc
guard()
changes["level"] = str(level)
# Granting administrator rights is unrestricted; removing them is what the
# protections are about, so they are only consulted when something is taken away.
if body.is_admin is not None and body.is_admin != user.is_admin:
if not body.is_admin:
guard()
changes["is_admin"] = body.is_admin
if body.is_active is not None and body.is_active != user.is_active:
if not body.is_active:
guard()
changes["is_active"] = body.is_active
if body.display_name is not None and body.display_name != user.display_name:
changes["display_name"] = body.display_name
return changes
@router.get(
"",
name="List users",
description="List the accounts on this instance. Administrators only.",
response_model=list[models.AuthUserInfo],
)
async def find(
db: Annotated[AsyncSession, Depends(get_db_session)],
_principal: Annotated[Principal, Depends(require_admin())],
limit: Annotated[int | None, Query(description="Maximum number of accounts to return.")] = None,
offset: Annotated[int, Query(description="How many accounts to skip.")] = 0,
) -> Response:
users, total = await auth_user_db.find(db=db, limit=limit, offset=offset)
return JSONResponse(
content=jsonable_encoder(
[models.AuthUserInfo.from_db(user) for user in users],
exclude_none=True,
),
headers={"x-total-count": str(total)},
)
@router.post(
"",
name="Create a user",
description=(
"Create an account. If no password is supplied one is generated and returned "
"once, which is the only time it exists in a readable form."
),
response_model=models.AuthUserCreated,
responses={400: {"model": models.Message}, 409: {"model": models.Message}},
status_code=201,
)
async def create(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_admin())],
body: models.AuthUserCreateRequest,
) -> Response:
try:
level = parse_level(body.level)
except ValueError as exc:
return _message(BAD_REQUEST, str(exc))
username = auth_user_db.normalize_username(body.username)
if not username:
return _message(BAD_REQUEST, "A username is required.")
if await auth_user_db.get_by_username(db, username) is not None:
return _message(CONFLICT, f"A user named '{username}' already exists.")
generated = body.password is None
password = body.password or _generate_password()
try:
user = await auth_user_db.create(
db=db,
username=username,
password_hash=await hash_password_async(password),
level=level,
is_admin=body.is_admin,
display_name=body.display_name,
must_change_password=body.must_change_password,
)
except IntegrityError:
# Two administrators created the same username at once; the unique index decided.
await db.rollback()
return _message(CONFLICT, f"A user named '{username}' already exists.")
await record(
request,
AuditEvent.USER_CREATED,
principal=principal,
target=user.username,
detail={"level": str(level), "is_admin": body.is_admin},
)
created = models.AuthUserCreated(
user=models.AuthUserInfo.from_db(user),
password=password if generated else None,
)
return JSONResponse(status_code=CREATED, content=jsonable_encoder(created, exclude_none=True))
@router.get(
"/{user_id}",
name="Get a user",
description="Get one account. Administrators only.",
response_model=models.AuthUserInfo,
responses={404: {"model": models.Message}},
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
_principal: Annotated[Principal, Depends(require_admin())],
user_id: int,
) -> Response:
try:
user = await auth_user_db.get_by_id(db, user_id)
except ItemNotFoundError as exc:
return _message(NOT_FOUND, str(exc))
return JSONResponse(content=jsonable_encoder(models.AuthUserInfo.from_db(user), exclude_none=True))
@router.patch(
"/{user_id}",
name="Update a user",
description=(
"Change an account's level, administrator flag, active flag or display name. "
"Omitted fields are left alone. Disabling an account also ends its sessions."
),
response_model=models.AuthUserInfo,
responses={400: {"model": models.Message}, 403: {"model": models.Message}, 404: {"model": models.Message}},
)
async def update(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_admin())],
user_id: int,
body: models.AuthUserUpdateRequest,
) -> Response:
try:
user = await auth_user_db.get_by_id(db, user_id)
except ItemNotFoundError as exc:
return _message(NOT_FOUND, str(exc))
try:
changes = _collect_changes(principal, user, body)
except _Refused as refusal:
return _message(refusal.status_code, refusal.message)
if not changes:
return JSONResponse(content=jsonable_encoder(models.AuthUserInfo.from_db(user), exclude_none=True))
await auth_user_db.update(db=db, user=user, changes=changes)
if changes.get("is_active") is False:
# A disabled account whose cookie still works is not disabled. resolve_principal
# already refuses an inactive user, but ending the sessions makes the state on
# disk agree with the state in the browser.
await auth_session_db.revoke_all_for_user(db, user.id)
await record(
request,
AuditEvent.USER_UPDATED,
principal=principal,
target=user.username,
detail={key: value for key, value in changes.items() if key != "display_name"},
)
return JSONResponse(content=jsonable_encoder(models.AuthUserInfo.from_db(user), exclude_none=True))
@router.post(
"/{user_id}/password",
name="Reset a user's password",
description=(
"Set somebody else's password and end all their sessions. If no password is "
"supplied one is generated and returned once. The current password is not "
"required -- this is the path for a user who has forgotten theirs."
),
response_model=models.AuthUserCreated,
responses={404: {"model": models.Message}},
)
async def reset_password(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_admin())],
user_id: int,
body: models.AuthPasswordResetRequest,
) -> Response:
try:
user = await auth_user_db.get_by_id(db, user_id)
except ItemNotFoundError as exc:
return _message(NOT_FOUND, str(exc))
generated = body.password is None
password = body.password or _generate_password()
await auth_user_db.set_password(
db=db,
user=user,
password_hash=await hash_password_async(password),
must_change=body.must_change_password,
)
# Whoever knew the old password may be exactly who this reset is defending against,
# and their cookie would otherwise outlive the credential it was obtained with.
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
await record(
request,
AuditEvent.PASSWORD_RESET,
principal=principal,
target=user.username,
detail={"sessions_revoked": revoked, "must_change_password": body.must_change_password},
)
result = models.AuthUserCreated(
user=models.AuthUserInfo.from_db(user),
password=password if generated else None,
)
return JSONResponse(content=jsonable_encoder(result, exclude_none=True))
@router.post(
"/{user_id}/revoke-sessions",
name="Sign a user out everywhere",
description="End every login session belonging to an account, without changing their password.",
response_model=models.Message,
responses={404: {"model": models.Message}},
)
async def revoke_sessions(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_admin())],
user_id: int,
) -> Response:
try:
user = await auth_user_db.get_by_id(db, user_id)
except ItemNotFoundError as exc:
return _message(NOT_FOUND, str(exc))
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
await record(
request,
AuditEvent.SESSIONS_REVOKED,
principal=principal,
target=user.username,
detail={"sessions_revoked": revoked},
)
return JSONResponse(content={"message": f"Ended {revoked} session(s)."})
@router.delete(
"/{user_id}",
name="Delete a user",
description=(
"Remove an account, its sessions and its API keys. Its audit log entries are "
"kept but stop pointing at the account."
),
response_model=models.Message,
responses={403: {"model": models.Message}, 404: {"model": models.Message}},
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
request: Request,
principal: Annotated[Principal, Depends(require_admin())],
user_id: int,
) -> Response:
try:
user = await auth_user_db.get_by_id(db, user_id)
except ItemNotFoundError as exc:
return _message(NOT_FOUND, str(exc))
refusal = _protected(principal, user)
if refusal is not None:
return _message(FORBIDDEN, refusal)
username = user.username
await auth_session_db.revoke_all_for_user(db, user.id)
await auth_api_key_db.delete_all_for_user(db, user.id)
await auth_audit_db.clear_actor(db, user.id)
await auth_user_db.delete_user(db=db, user=user)
await record(request, AuditEvent.USER_DELETED, principal=principal, target=username)
return JSONResponse(content={"message": f"Deleted user '{username}'."})
+10 -3
View File
@@ -10,6 +10,8 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Vendor, VendorEvent
from spoolman.auth.dependencies import require_level, ws_authenticated
from spoolman.auth.levels import Level
from spoolman.database import vendor
from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
@@ -57,7 +59,7 @@ class VendorUpdateParameters(VendorParameters):
@field_validator("name")
@classmethod
def prevent_none(cls: type["VendorUpdateParameters"], v: str | None) -> str | None:
def prevent_none(cls: type[VendorUpdateParameters], v: str | None) -> str | None:
"""Prevent name from being None."""
if v is None:
raise ValueError("Value must not be None.")
@@ -77,6 +79,7 @@ class VendorUpdateParameters(VendorParameters):
200: {"model": list[Vendor]},
299: {"model": VendorEvent, "description": "Websocket message"},
},
dependencies=[Depends(require_level(Level.READ))],
)
async def find(
request: Request,
@@ -160,10 +163,10 @@ async def find(
"",
name="Listen to vendor changes",
)
@ws_authenticated(Level.READ)
async def notify_any(
websocket: WebSocket,
) -> None:
await websocket.accept()
websocket_manager.connect(("vendor",), websocket)
try:
while True:
@@ -183,6 +186,7 @@ async def notify_any(
),
response_model_exclude_none=True,
responses={404: {"model": Message}, 299: {"model": VendorEvent, "description": "Websocket message"}},
dependencies=[Depends(require_level(Level.READ))],
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -196,11 +200,11 @@ async def get(
"/{vendor_id}",
name="Listen to vendor changes",
)
@ws_authenticated(Level.READ)
async def notify(
websocket: WebSocket,
vendor_id: int,
) -> None:
await websocket.accept()
websocket_manager.connect(("vendor", str(vendor_id)), websocket)
try:
while True:
@@ -218,6 +222,7 @@ async def notify(
response_model_exclude_none=True,
response_model=Vendor,
responses={400: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def create( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -255,6 +260,7 @@ async def create( # noqa: ANN201
400: {"model": Message},
404: {"model": Message},
},
dependencies=[Depends(require_level(Level.EDIT))],
)
async def update( # noqa: ANN201
db: Annotated[AsyncSession, Depends(get_db_session)],
@@ -286,6 +292,7 @@ async def update( # noqa: ANN201
"Delete a vendor. The vendor attribute of any filaments who refer to the deleted vendor will be cleared."
),
responses={404: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
+15
View File
@@ -0,0 +1,15 @@
"""Authentication and authorization.
Everything in this package is inert unless ``SPOOLMAN_AUTH_ENABLED`` is set. With it
unset, permission gates short-circuit to an unrestricted principal before touching the
database, so an instance behaves exactly as it did before authentication existed.
The permission model has two orthogonal axes:
* **Level** -- an ordered ``read < edit < manage`` scale carried by users and, from
phase 2, by API keys. ``read`` covers every GET, ``edit`` adds modification of
existing records, and ``manage`` adds creation and deletion.
* **Flags** -- ``is_admin`` (administers other users) and ``is_owner`` (exactly one per
instance; promotes admins and cannot be demoted by anyone else). Both imply
``manage``.
"""
+139
View File
@@ -0,0 +1,139 @@
"""API key format, generation and presentation.
A key is presented as ``spoolman_<prefix>.<secret>``. The marker makes a leaked key
recognisable in a log or a paste -- secret scanners key off exactly this kind of fixed
prefix -- and the separated prefix is what makes the lookup a single indexed query
rather than a scan that verifies every stored hash in turn.
The separator is a full stop rather than an underscore because both halves are url-safe
base64, whose alphabet already contains ``-`` and ``_``; splitting on one of those would
cut the key in an unpredictable place.
Keys are hashed for storage with the same plain SHA-256 used for session tokens, and for
the same reason: the secret half is 256 bits from a cryptographic random source, so
there is no dictionary to attack and a work factor would buy nothing while costing tens
of milliseconds on the hot path of every machine request. See
:func:`spoolman.auth.hashing.hash_token`.
"""
import secrets
from typing import Final
from starlette.requests import Request
from starlette.websockets import WebSocket
from spoolman.auth.levels import Level, covers
MARKER: Final = "spoolman_"
SEPARATOR: Final = "."
# 9 bytes is 12 base64 characters, which fits the 16 character prefix column with room
# to spare and leaves 72 bits of collision resistance on a value that only has to be
# unique, not unguessable.
PREFIX_BYTES: Final = 9
SECRET_BYTES: Final = 32
# Both headers are accepted. X-API-Key is what Spoolman's own metrics gate already reads,
# and bearer is what most HTTP clients and Prometheus scrape configs express natively.
API_KEY_HEADER: Final = "x-api-key"
AUTHORIZATION_HEADER: Final = "authorization"
BEARER_SCHEME: Final = "bearer"
# Longest key we will even look at. A key is a fixed ~57 characters; anything much
# larger is either not a key or an attempt to make us hash a megabyte per request.
MAX_KEY_LENGTH: Final = 256
def new_prefix() -> str:
"""Generate the public half of a key.
Returns:
str: A 12 character url-safe identifier.
"""
return secrets.token_urlsafe(PREFIX_BYTES)
def format_key(prefix: str, secret: str) -> str:
"""Assemble the string a caller presents.
Args:
prefix: The public identifier.
secret: The secret half.
Returns:
str: The complete key.
"""
return f"{MARKER}{prefix}{SEPARATOR}{secret}"
def generate() -> tuple[str, str]:
"""Generate a new key.
Returns:
tuple: The prefix to store, and the complete key, which is the only time the
secret exists in a readable form.
"""
prefix = new_prefix()
return prefix, format_key(prefix, secrets.token_urlsafe(SECRET_BYTES))
def prefix_of(presented: str) -> str | None:
"""Extract the prefix from a presented key.
Args:
presented: The string a caller sent.
Returns:
Optional[str]: The prefix, or None if the string is not shaped like a key. A
None here means no database query happens at all, which is what keeps a flood
of junk credentials from becoming a flood of lookups.
"""
if not presented or len(presented) > MAX_KEY_LENGTH or not presented.startswith(MARKER):
return None
head, separator, tail = presented.partition(SEPARATOR)
if not separator or not tail:
return None
prefix = head[len(MARKER) :]
return prefix or None
def presented_key(conn: Request | WebSocket) -> str:
"""Read the API key a request carries, if any.
Args:
conn: The request or websocket.
Returns:
str: The presented key, or an empty string.
"""
presented = conn.headers.get(API_KEY_HEADER, "").strip()
if presented:
return presented
scheme, _, value = conn.headers.get(AUTHORIZATION_HEADER, "").partition(" ")
if scheme.lower() == BEARER_SCHEME:
return value.strip()
return ""
def effective_level(key_level: Level, user_level: Level) -> Level:
"""Work out what a key may actually do.
A key never exceeds its owner. The cap is applied here, on every request, rather
than being baked into the stored level at creation time, so that demoting a user
weakens their existing keys immediately instead of leaving credentials in the wild
that outrank the account they belong to.
Args:
key_level: The level the key was issued with.
user_level: The level its owner currently holds.
Returns:
Level: The weaker of the two.
"""
return user_level if covers(key_level, user_level) else key_level
+207
View File
@@ -0,0 +1,207 @@
"""The audit trail.
What gets recorded is anything that changes who can do what, plus every sign-in attempt.
Ordinary data changes -- creating a spool, using filament -- are deliberately not
recorded here. Those are already visible in the data itself, and mixing them in would
bury the handful of security-relevant events under thousands of routine ones, which is
how audit logs stop being read.
Writing is best effort. :func:`record` never raises and never blocks the operation it is
describing: an instance whose audit table is somehow unwritable should still let people
sign in. The failure is logged instead, which is the one place an operator will see it.
Each write uses its own database session rather than the caller's. Login attempts have
already committed by the time they are audited, several call sites have no session in
hand at all, and a shared session would mean an audit write could roll back the
operation it was recording. The cost is one extra short transaction on paths that
already do several.
"""
import datetime
import json
import logging
from enum import StrEnum
from typing import Any, Final
from scheduler.asyncio.scheduler import Scheduler
from starlette.requests import Request
from starlette.websockets import WebSocket
from spoolman import env
from spoolman.auth.principal import Principal, PrincipalKind
from spoolman.auth.ratelimit import client_ip
from spoolman.database import auth_audit as auth_audit_db
from spoolman.settings import parse_setting
logger = logging.getLogger(__name__)
USER_AGENT_HEADER: Final = "user-agent"
RETENTION_SETTING: Final = "auth_audit_retention_days"
# Used when the retention setting cannot be read at all. Matches the setting's own
# default, so a database that is briefly unavailable does not silently switch the
# instance to keeping entries forever.
DEFAULT_RETENTION_DAYS: Final = 90
class AuditEvent(StrEnum):
"""Everything the audit log records.
The values are stable identifiers, stored in the database and matched against by the
listing endpoint's filter. Renaming one orphans the history that used the old name,
so add rather than rename.
"""
LOGIN_SUCCESS = "login.success"
LOGIN_FAILURE = "login.failure"
LOGIN_LOCKED = "login.locked"
LOGOUT = "logout"
INSTANCE_CLAIMED = "instance.claimed"
# Event names, not credentials. Ruff's hardcoded-password heuristic only sees the
# word "password" in the member name.
PASSWORD_CHANGED = "password.changed" # noqa: S105
PASSWORD_RESET = "password.reset" # noqa: S105
USER_CREATED = "user.created"
USER_UPDATED = "user.updated"
USER_DELETED = "user.deleted"
APIKEY_CREATED = "apikey.created"
APIKEY_REVOKED = "apikey.revoked"
APIKEY_DELETED = "apikey.deleted"
SESSIONS_REVOKED = "sessions.revoked"
def _actor_kind(principal: Principal | None) -> str:
"""Describe how the actor was authenticated."""
if principal is None:
return str(PrincipalKind.ANONYMOUS)
return str(principal.kind)
async def record(
conn: Request | WebSocket | None,
event: AuditEvent,
*,
principal: Principal | None = None,
actor_user_id: int | None = None,
target: str | None = None,
detail: dict[str, Any] | None = None,
) -> None:
"""Record an event, if auditing applies.
Does nothing when authentication is disabled. An instance that has never turned auth
on has no accounts, no sessions and no keys, so there is nothing meaningful to
attribute an event to, and writing rows to a table nothing reads would just grow the
database of every existing user.
Args:
conn: The request or websocket the event happened on, for the address and user
agent. May be None for events that did not arise from a request.
event: What happened.
principal: Who did it, if a principal had been resolved.
actor_user_id: Who did it, when the user is known but no principal exists --
a failed sign-in against a real account, for instance.
target: What was acted on.
detail: Anything else worth keeping. Stored as a JSON object.
"""
if not env.is_auth_enabled():
return
try:
# Imported here rather than at module scope: this module is reachable from the
# command line tool, and get_db_session belongs to a module that assumes a
# configured database.
from spoolman.database.database import get_db_session # noqa: PLC0415
if actor_user_id is None and principal is not None:
actor_user_id = principal.user_id
generator = get_db_session()
db = await anext(generator)
try:
await auth_audit_db.record(
db=db,
event=str(event),
actor_kind=_actor_kind(principal),
actor_user_id=actor_user_id,
target=target,
ip=client_ip(conn) if conn is not None else None,
user_agent=conn.headers.get(USER_AGENT_HEADER) if conn is not None else None,
detail=json.dumps(detail) if detail else None,
)
finally:
await generator.aclose()
except Exception:
# Never propagates. See the module docstring.
logger.exception("Failed to write audit log entry for %s.", event)
async def retention_days() -> int:
"""Read how long audit entries are kept.
Returns:
int: The retention window in days. Zero means keep everything.
"""
from spoolman.database import setting as setting_db # noqa: PLC0415
from spoolman.database.database import get_db_session # noqa: PLC0415
from spoolman.exceptions import ItemNotFoundError # noqa: PLC0415
definition = parse_setting(RETENTION_SETTING)
try:
generator = get_db_session()
db = await anext(generator)
try:
try:
row = await setting_db.get(db, definition)
except ItemNotFoundError:
return int(json.loads(definition.default))
return int(json.loads(row.value))
finally:
await generator.aclose()
except Exception:
logger.exception("Failed to read the %s setting, keeping the default.", RETENTION_SETTING)
return DEFAULT_RETENTION_DAYS
async def _prune_task() -> None:
"""Apply the configured retention window to the audit log."""
from spoolman.database.database import get_db_session # noqa: PLC0415
days = await retention_days()
if days <= 0:
return
cutoff = datetime.datetime.utcnow().replace(microsecond=0) - datetime.timedelta(days=days)
generator = get_db_session()
db = await anext(generator)
try:
removed = await auth_audit_db.prune_older_than(db, cutoff)
finally:
await generator.aclose()
if removed:
logger.info("Pruned %d audit log entries older than %d days.", removed, days)
def schedule_tasks(scheduler: Scheduler) -> None:
"""Schedule the audit log retention sweep.
Only scheduled when authentication is enabled, since nothing writes to the table
otherwise and the job would be a nightly no-op on every existing instance.
Args:
scheduler: The scheduler to use.
"""
if not env.is_auth_enabled():
return
logger.info("Scheduling automatic audit log pruning.")
# Half an hour after the nightly backup rather than alongside it, so the two do not
# contend for SQLite's single write lock.
scheduler.daily(datetime.time(hour=0, minute=30, second=0), _prune_task) # type: ignore[arg-type]
+144
View File
@@ -0,0 +1,144 @@
"""Session cookie and CSRF handling.
The session cookie holds an opaque token and is ``HttpOnly``, so script cannot read it.
Because it is sent automatically on same-origin requests it is an ambient credential and
needs CSRF protection, which is done by double submit: a second, script-readable cookie
carries a random value that the client must echo in a request header. An attacker's page
can cause the browser to send the cookies but cannot read them to construct the header.
API keys, once they arrive in phase 2, are exempt -- they are not ambient, so a
cross-site page has no way to attach one.
"""
from datetime import timedelta
from typing import Final
from starlette.requests import Request
from starlette.responses import Response
from starlette.websockets import WebSocket
from spoolman import env
from spoolman.auth import ratelimit
from spoolman.auth.hashing import tokens_equal
SESSION_COOKIE: Final = "spoolman_session"
CSRF_COOKIE: Final = "spoolman_csrf"
CSRF_HEADER: Final = "X-CSRF-Token"
# Sessions without "remember me" expire this long after they were last used.
IDLE_LIFETIME: Final = timedelta(hours=12)
# Sessions with "remember me" last this long, sliding forward as they are used.
REMEMBER_LIFETIME: Final = timedelta(days=90)
# Methods that do not change state, and so do not require a CSRF token.
SAFE_METHODS: Final = frozenset({"GET", "HEAD", "OPTIONS"})
def cookie_path() -> str:
"""Get the path cookies are scoped to.
Returns:
str: The deployment's base path with a trailing slash.
"""
return env.get_base_path() + "/"
def cookie_secure(conn: Request | WebSocket) -> bool:
"""Decide whether cookies should carry the Secure flag.
Setting Secure on a plain-HTTP deployment makes the browser silently discard the
cookie, which presents as a login that appears to succeed and then does nothing. The
automatic path therefore only concludes HTTPS from positive evidence: either the
request arrived over TLS, or a proxy we have been told to trust says it did.
Args:
conn: The request or websocket.
Returns:
bool: Whether to set the Secure flag.
"""
override = env.get_cookie_secure_override()
if override is not None:
return override
if conn.url.scheme in {"https", "wss"}:
return True
return ratelimit.forwarded_proto(conn) == "https"
def set_session_cookies(
response: Response,
conn: Request,
*,
token: str,
csrf: str,
remember: bool,
) -> None:
"""Attach the session and CSRF cookies to a response.
Args:
response: The response to modify.
conn: The request being answered, used to derive the Secure flag.
token: The plaintext session token.
csrf: The plaintext CSRF token.
remember: Whether the session should outlive the browser session.
"""
secure = cookie_secure(conn)
path = cookie_path()
max_age = int(REMEMBER_LIFETIME.total_seconds()) if remember else None
response.set_cookie(
SESSION_COOKIE,
token,
max_age=max_age,
path=path,
secure=secure,
httponly=True,
samesite="lax",
)
# Readable by script on purpose: the client has to copy it into a request header.
response.set_cookie(
CSRF_COOKIE,
csrf,
max_age=max_age,
path=path,
secure=secure,
httponly=False,
samesite="lax",
)
def clear_session_cookies(response: Response, conn: Request) -> None:
"""Remove the session and CSRF cookies.
Args:
response: The response to modify.
conn: The request being answered, used to derive the Secure flag.
"""
secure = cookie_secure(conn)
path = cookie_path()
for name in (SESSION_COOKIE, CSRF_COOKIE):
response.delete_cookie(name, path=path, secure=secure, httponly=name == SESSION_COOKIE, samesite="lax")
def verify_csrf(request: Request) -> bool:
"""Check the CSRF double-submit for a state-changing request.
Args:
request: The request to check.
Returns:
bool: True if the request may proceed.
"""
if request.method.upper() in SAFE_METHODS:
return True
cookie = request.cookies.get(CSRF_COOKIE, "")
header = request.headers.get(CSRF_HEADER, "")
if not cookie or not header:
return False
return tokens_equal(cookie, header)
+138
View File
@@ -0,0 +1,138 @@
"""Proof that every API route carries a permission gate.
Gating routes one at a time is fail-open: forget one and it silently serves
unauthenticated traffic, and nothing tells you until someone notices. This module
inverts that. It walks the route table and raises if any route lacks a gate, so a
forgotten one stops the process at import rather than becoming a hole.
It runs at import of :mod:`spoolman.api.v1.router`, which is the earliest possible
moment -- nothing registers routes after that -- and means the failure happens before
uvicorn binds a port, before migrations run, and equally when the module is imported by
the docs generator or a test.
"""
import logging
from collections.abc import Iterator
from typing import Final
from fastapi import FastAPI
from fastapi.dependencies.models import Dependant
from fastapi.routing import APIRoute, APIWebSocketRoute
from starlette.routing import BaseRoute
from spoolman.auth.dependencies import AUTH_LEVEL_ATTR
from spoolman.auth.levels import Level
logger = logging.getLogger(__name__)
WEBSOCKET_METHOD: Final = "WEBSOCKET"
def _walk(routes: list[BaseRoute]) -> Iterator[BaseRoute]:
"""Yield every route, descending into included routers.
FastAPI does not flatten ``include_router`` into the parent's route list; it stores
a wrapper object holding the original router. Iterating ``app.routes`` alone
therefore sees only the handful of routes declared directly on the app and would
declare everything else covered.
Args:
routes: The route list to walk.
Yields:
BaseRoute: Each concrete route.
"""
for route in routes:
nested = getattr(route, "original_router", None)
if nested is not None:
yield from _walk(nested.routes)
else:
yield route
def _gate_level(dependant: Dependant) -> Level | None:
"""Find the permission level a route's dependencies enforce.
Reads ``dependant.dependencies`` rather than the route's raw ``dependencies``
argument, so that a gate declared as a handler parameter counts too.
Args:
dependant: The route's resolved dependency tree.
Returns:
Optional[Level]: The level enforced, or None if no gate is present.
"""
for sub in dependant.dependencies:
level = getattr(sub.call, AUTH_LEVEL_ATTR, None)
if level is not None:
return level
nested = _gate_level(sub)
if nested is not None:
return nested
return None
def find_uncovered(app: FastAPI, allow: frozenset[tuple[str, str]]) -> tuple[list[str], dict[str, Level]]:
"""Sort an app's routes into gated and ungated.
Args:
app: The application to inspect.
allow: Method and path pairs that are intentionally public.
Returns:
tuple: The ungated route descriptions, and the level of each gated route.
"""
missing: list[str] = []
covered: dict[str, Level] = {}
for route in _walk(app.routes):
if isinstance(route, APIWebSocketRoute):
methods = (WEBSOCKET_METHOD,)
level = getattr(route.endpoint, AUTH_LEVEL_ATTR, None)
elif isinstance(route, APIRoute):
# HEAD is added implicitly alongside GET and shares its gate.
methods = tuple(sorted(method for method in route.methods if method != "HEAD"))
level = _gate_level(route.dependant)
else:
# Starlette's own routes: /openapi.json, /docs, /docs/oauth2-redirect and
# /redoc. They serve the schema, not data.
continue
for method in methods:
if (method, route.path) in allow:
continue
if level is None:
missing.append(f"{method} {route.path}")
else:
covered[f"{method} {route.path}"] = level
return missing, covered
def assert_routes_covered(app: FastAPI, allow: frozenset[tuple[str, str]]) -> dict[str, Level]:
"""Raise unless every route on an app carries a permission gate.
Args:
app: The application to inspect.
allow: Method and path pairs that are intentionally public.
Raises:
RuntimeError: If any route lacks a gate.
Returns:
dict: The level enforced on each gated route.
"""
missing, covered = find_uncovered(app, allow)
if missing:
listed = "\n ".join(sorted(missing))
raise RuntimeError(
"Some API routes have no authentication gate, which would serve them "
"unauthenticated whenever authentication is enabled. Add a "
"require_level dependency, or a ws_authenticated decorator for websockets, "
f"or list the route as intentionally public:\n {listed}",
)
return covered
+357
View File
@@ -0,0 +1,357 @@
"""Permission gates for API routes.
HTTP routes are gated by adding ``dependencies=[Depends(require_level(Level.X))]`` to
the route decorator. Websocket routes cannot use that mechanism -- see
:func:`ws_authenticated` for why -- and are gated with a decorator instead.
Both mechanisms tag the callable they produce with :data:`AUTH_LEVEL_ATTR`, which is
what :mod:`spoolman.auth.coverage` reads to prove at import time that no route was left
ungated.
"""
import functools
import json
import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Final, ParamSpec, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.requests import Request
from starlette.websockets import WebSocket
from spoolman import env
from spoolman.auth import apikey, cookies
from spoolman.auth.levels import Level, parse_level
from spoolman.auth.principal import ANONYMOUS_READER, UNRESTRICTED, Principal, PrincipalKind
from spoolman.database import auth_api_key as auth_api_key_db
from spoolman.database import auth_session as auth_session_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database import models
from spoolman.database.database import get_db_session
from spoolman.exceptions import AuthenticationRequiredError, ItemNotFoundError, PermissionDeniedError
from spoolman.settings import parse_setting
logger = logging.getLogger(__name__)
AUTH_LEVEL_ATTR: Final = "__spoolman_auth_level__"
# Websocket close codes. The codebase's convention is 4000 plus the HTTP status it
# stands in for, following the existing 4040 in spoolman/api/v1/setting.py.
WS_UNAUTHENTICATED: Final = 4401
WS_FORBIDDEN: Final = 4403
# Where a resolved principal is cached, so several gates on one request cost one lookup.
REQUEST_STATE_ATTR: Final = "spoolman_principal"
P = ParamSpec("P")
R = TypeVar("R")
ANONYMOUS_READ_SETTING: Final = "auth_anonymous_read"
@asynccontextmanager
async def _db_session() -> AsyncIterator[AsyncSession]:
"""Open a database session outside FastAPI's dependency system.
``get_db_session`` is an async generator. Driving it with ``async for`` and then
breaking or returning leaves it suspended at its yield, so the ``finally`` that
closes the session never runs on the event loop -- the garbage collector reaches it
later and SQLAlchemy raises out of a context where it cannot await. Closing the
generator explicitly keeps that deterministic.
Note this skips the generator's own trailing commit, which is deliberate: callers
here commit explicitly where they mean to write.
Yields:
AsyncSession: The database session.
"""
generator = get_db_session()
session = await anext(generator)
try:
yield session
finally:
await generator.aclose()
async def anonymous_read_enabled() -> bool:
"""Check the setting that grants unauthenticated read access.
Fails closed. Any problem reaching the setting -- the database being unavailable,
the row holding something that is not a boolean -- denies access rather than
granting it, because the failure mode of guessing wrong here is serving the whole
database to anonymous callers.
"""
# Imported lazily: spoolman.database.setting pulls in the websocket manager, and at
# module scope that would drag it into every import of this package, including the
# command line tool.
from spoolman.database import setting as setting_db # noqa: PLC0415
definition = parse_setting(ANONYMOUS_READ_SETTING)
try:
async with _db_session() as db:
try:
row = await setting_db.get(db, definition)
except ItemNotFoundError:
# Never set, so it holds its default of false.
return False
return json.loads(row.value) is True
except Exception:
logger.exception("Failed to read the %s setting, denying anonymous access.", ANONYMOUS_READ_SETTING)
return False
def _principal_for_user(user: models.AuthUser, session_id: int) -> Principal:
"""Build a principal from a user row and the session that authenticated it."""
return Principal(
kind=PrincipalKind.USER,
level=parse_level(user.level),
user_id=user.id,
username=user.username,
is_admin=user.is_admin,
is_owner=user.is_owner,
session_id=session_id,
)
def _principal_for_api_key(key: models.AuthApiKey, user: models.AuthUser) -> Principal:
"""Build a principal from an API key and the user it belongs to.
A key never carries administrative rights, however senior its owner. Everything
behind :func:`require_admin` -- creating users, reading the audit log, minting
further keys -- therefore needs a signed-in session. That means a leaked key cannot
be used to entrench itself by creating another account or another key, and it keeps
the blast radius of the credential that lives in a printer's config file to the data
it was issued for.
"""
return Principal(
kind=PrincipalKind.APIKEY,
level=apikey.effective_level(parse_level(key.level), parse_level(user.level)),
user_id=user.id,
username=user.username,
is_admin=False,
is_owner=False,
api_key_id=key.id,
)
async def api_key_principal(conn: Request | WebSocket) -> Principal | None:
"""Resolve an API key, ignoring every other kind of credential.
Separate from :func:`resolve_principal` because /metrics needs exactly this and
nothing else: a scraper presents a key or a shared token, never a cookie, and
honouring a session there would quietly make the endpoint readable by any browser
tab that happens to be signed in.
Args:
conn: The request or websocket being authenticated.
Returns:
Optional[Principal]: The principal, or None if no usable key was presented.
"""
presented = apikey.presented_key(conn)
if not presented:
return None
async with _db_session() as db:
key = await auth_api_key_db.resolve(db, presented)
if key is None:
return None
user = await auth_user_db.get_by_id(db, key.user_id)
if not user.is_active:
return None
return _principal_for_api_key(key, user)
async def resolve_principal(conn: Request | WebSocket) -> Principal | None:
"""Work out who is making a request.
Resolution order is the API key header, then the session cookie, then anonymous read
if the setting allows it. Certificates join this list in phase 5.
An explicitly presented key wins over the cookie a browser attaches by itself. The
ordering only matters when both are present, which in practice is a signed-in
operator testing a key from the browser's console; resolving to the key is what makes
that test tell them the truth about what the key can do.
This opens its own short-lived database session rather than depending on
``get_db_session``. A route-level dependency is solved before the handler's own
parameters, so declaring one here would open a session on every request -- including
/health and /info, which currently need no database at all and would start failing
when the database is down.
Args:
conn: The request or websocket being authenticated.
Returns:
Optional[Principal]: The principal, or None if no credential was accepted.
"""
cached = getattr(conn.state, REQUEST_STATE_ATTR, None) if hasattr(conn, "state") else None
if cached is not None:
return cached
principal: Principal | None = await api_key_principal(conn)
if principal is None:
token = conn.cookies.get(cookies.SESSION_COOKIE, "")
if token:
async with _db_session() as db:
session = await auth_session_db.resolve(db, token)
if session is not None:
user = await auth_user_db.get_by_id(db, session.user_id)
if user.is_active:
principal = _principal_for_user(user, session.id)
if principal is None and await anonymous_read_enabled():
principal = ANONYMOUS_READER
if principal is not None and hasattr(conn, "state"):
setattr(conn.state, REQUEST_STATE_ATTR, principal)
return principal
def require_level(
level: Level,
*,
allow_anonymous: bool = True,
) -> Callable[[Request], Awaitable[Principal]]:
"""Build a dependency that admits requests at or above a permission level.
Args:
level: The level required.
allow_anonymous: Whether the anonymous-read setting can satisfy this route.
Turned off for endpoints that expose the database in bulk.
Returns:
Callable: A FastAPI dependency yielding the principal.
"""
async def gate(request: Request) -> Principal:
if not env.is_auth_enabled():
return UNRESTRICTED
principal = await resolve_principal(request)
if principal is None:
raise AuthenticationRequiredError("Authentication required.")
if principal.kind is PrincipalKind.ANONYMOUS and not allow_anonymous:
raise AuthenticationRequiredError("Authentication required.")
if not principal.covers(level):
raise PermissionDeniedError(f"This operation requires the '{level}' permission level.")
if principal.kind is PrincipalKind.USER and not cookies.verify_csrf(request):
# 403 rather than 401 on purpose: the credential is valid, so telling the
# client it is unauthenticated would sign the user out over what is usually
# a stale tab rather than an attack.
#
# Only USER is checked. A session cookie is ambient -- the browser attaches
# it to a cross-site form post without being asked -- which is the entire
# premise of CSRF. An API key is not: it lives in a header that a page on
# another origin has no way to set, so demanding a second factor of a
# machine credential would only break every non-browser client for nothing.
raise PermissionDeniedError("CSRF token missing or invalid.")
return principal
setattr(gate, AUTH_LEVEL_ATTR, level)
return gate
def require_user() -> Callable[[Request], Awaitable[Principal]]:
"""Build a dependency that requires a real signed-in user.
This is the gate for anything that acts on the account itself -- changing its
password, minting or revoking its API keys. An API key is refused even though it
belongs to a user, because a credential that can mint another credential can never
really be revoked: whoever holds a leaked key would simply issue themselves a fresh
one before the original was withdrawn.
Returns:
Callable: A FastAPI dependency yielding the principal.
"""
inner = require_level(Level.READ, allow_anonymous=False)
async def gate(request: Request) -> Principal:
principal = await inner(request)
if principal.kind is PrincipalKind.ANONYMOUS:
raise AuthenticationRequiredError("Authentication required.")
if principal.kind is PrincipalKind.APIKEY:
# 403, not 401: the key is a perfectly valid credential, it is simply the
# wrong kind for this. Answering 401 would tell the client to go and get a
# credential it already has.
raise PermissionDeniedError("This operation requires a signed-in user, not an API key.")
return principal
setattr(gate, AUTH_LEVEL_ATTR, Level.READ)
return gate
def require_admin() -> Callable[[Request], Awaitable[Principal]]:
"""Build a dependency that requires administrator rights.
Returns:
Callable: A FastAPI dependency yielding the principal.
"""
inner = require_level(Level.MANAGE, allow_anonymous=False)
async def gate(request: Request) -> Principal:
principal = await inner(request)
if not env.is_auth_enabled():
return principal
if not principal.is_admin:
raise PermissionDeniedError("This operation requires administrator rights.")
return principal
setattr(gate, AUTH_LEVEL_ATTR, Level.MANAGE)
return gate
def ws_authenticated(level: Level) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
"""Gate a websocket handler.
The handshake is accepted *before* any rejection close, which looks wrong and is
deliberate. uvicorn translates a close sent before the accept into an HTTP 403
handshake rejection and discards the close code entirely, and Starlette routes
WebSocketException raised from a dependency through that same path. Neither can
deliver 4401 or 4403 to the client. Accepting first and then closing is the only
way the browser observes the real code, which is what lets the client tell "you are
signed out, stop retrying" apart from "the network blipped, back off and retry".
A socket that is accepted and immediately closed never joins the websocket manager,
so it costs nothing.
Decorated handlers must not call ``websocket.accept()`` themselves.
Args:
level: The level required to subscribe.
Returns:
Callable: The decorator.
"""
def decorate(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
websocket = kwargs["websocket"]
assert isinstance(websocket, WebSocket) # noqa: S101
await websocket.accept()
if env.is_auth_enabled():
principal = await resolve_principal(websocket)
if principal is None:
await websocket.close(code=WS_UNAUTHENTICATED, reason="Authentication required.")
return None
if not principal.covers(level):
await websocket.close(code=WS_FORBIDDEN, reason="Insufficient permission level.")
return None
return await func(*args, **kwargs)
setattr(wrapper, AUTH_LEVEL_ATTR, level)
return wrapper # type: ignore[return-value]
return decorate
+251
View File
@@ -0,0 +1,251 @@
"""Password and token hashing.
Passwords use :func:`hashlib.scrypt`, a memory-hard KDF in the standard library.
The obvious alternative is to depend on a hashing library, and that was considered and
declined for phase 1. ``argon2-cffi`` is the strongest option but its bindings have no
armv7 wheel, so that image would compile a C extension under QEMU in CI. ``bcrypt`` 5.0
does now publish armv7 wheels, but truncates passwords at 72 bytes. ``passlib`` has had
no release since 2020. None of them addresses the part of login that actually leaks
information -- see :func:`dummy_verify` -- which is application-level either way.
What a library would replace is the encoding and parsing below. It is deliberately kept
behind this module's interface so the decision can be revisited without touching
callers.
Every hash records the parameters it was produced with, so the cost can be raised later
without a migration: :func:`verify_password` re-derives using the parameters embedded in
the stored string, and :func:`needs_rehash` reports when a stored hash predates a change
so the login path can opportunistically upgrade it.
Deriving a hash blocks for tens to hundreds of milliseconds depending on hardware
(measured at 49 ms on a desktop, several times that on a Raspberry Pi). Spoolman runs as
a single process, so a synchronous call in a request handler would stall every other
request and every websocket ping for that whole time. scrypt releases the GIL, so the
``_async`` wrappers below genuinely offload to a worker thread; request handlers must
use those and never the synchronous forms.
"""
import base64
import hashlib
import hmac
import secrets
from typing import Final
import anyio.to_thread
# Cost parameters. n is the CPU/memory cost, r the block size, p the parallelisation.
# Memory use is roughly 128 * r * n bytes, so this costs ~16 MiB and ~50 ms per hash on
# a desktop. OWASP's headline scrypt recommendation is n=2**17, which is eight times
# this and would be multi-second on the low-power ARM boards that Spoolman commonly runs
# on -- unacceptable for an interactive login. Their memory-constrained alternative
# raises p instead, which costs the same wall-clock time without the memory hardness.
SCRYPT_N: Final = 2**14
SCRYPT_R: Final = 8
SCRYPT_P: Final = 1
SCRYPT_DKLEN: Final = 32
SALT_BYTES: Final = 16
# OpenSSL caps scrypt's allocation at 32 MiB when maxmem is left at 0, which n=2**14
# fits under. It is passed explicitly so that raising SCRYPT_N later fails loudly at the
# parameter change rather than obscurely inside OpenSSL.
SCRYPT_MAXMEM: Final = 64 * 1024 * 1024
ALGORITHM: Final = "scrypt"
TOKEN_BYTES: Final = 32
def _b64encode(raw: bytes) -> str:
"""Encode bytes as unpadded url-safe base64."""
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _b64decode(value: str) -> bytes:
"""Decode unpadded url-safe base64."""
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + padding)
def _derive(password: str, salt: bytes, n: int, r: int, p: int, dklen: int) -> bytes:
"""Run scrypt with explicit parameters."""
return hashlib.scrypt(
password.encode(),
salt=salt,
n=n,
r=r,
p=p,
dklen=dklen,
maxmem=SCRYPT_MAXMEM,
)
def _format(salt: bytes, derived: bytes, n: int, r: int, p: int) -> str:
"""Build the stored representation of a hash."""
return f"{ALGORITHM}${n}${r}${p}${_b64encode(salt)}${_b64encode(derived)}"
def _parse(encoded: str) -> tuple[int, int, int, bytes, bytes]:
"""Split a stored hash into its parameters, salt and digest.
Args:
encoded: The stored hash string.
Raises:
ValueError: If the string is not a well-formed scrypt hash.
Returns:
tuple: n, r, p, salt and the expected digest.
"""
parts = encoded.split("$")
expected_parts = 6
if len(parts) != expected_parts or parts[0] != ALGORITHM:
raise ValueError("Malformed password hash.")
try:
n, r, p = int(parts[1]), int(parts[2]), int(parts[3])
return n, r, p, _b64decode(parts[4]), _b64decode(parts[5])
except (ValueError, TypeError) as exc:
raise ValueError("Malformed password hash.") from exc
def hash_password(password: str) -> str:
"""Hash a password for storage.
Args:
password: The plaintext password.
Returns:
str: The encoded hash, including the parameters used.
"""
salt = secrets.token_bytes(SALT_BYTES)
derived = _derive(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P, SCRYPT_DKLEN)
return _format(salt, derived, SCRYPT_N, SCRYPT_R, SCRYPT_P)
def verify_password(password: str, encoded: str) -> bool:
"""Check a password against a stored hash.
Args:
password: The plaintext password to check.
encoded: The stored hash.
Returns:
bool: True if the password matches. False if it does not, and also if the stored
hash is unreadable -- a corrupt hash must never authenticate anyone.
"""
try:
n, r, p, salt, expected = _parse(encoded)
except ValueError:
return False
derived = _derive(password, salt, n, r, p, len(expected))
return hmac.compare_digest(derived, expected)
def needs_rehash(encoded: str) -> bool:
"""Check whether a stored hash was produced with outdated parameters.
Args:
encoded: The stored hash.
Returns:
bool: True if the hash should be recomputed on the next successful login.
"""
try:
n, r, p, _, expected = _parse(encoded)
except ValueError:
return True
return (n, r, p, len(expected)) != (SCRYPT_N, SCRYPT_R, SCRYPT_P, SCRYPT_DKLEN)
# A real hash of a random password, derived once at import. Verifying against this costs
# the same as verifying a real account, which is what lets the login path spend equal
# time on unknown and known usernames.
_DUMMY_ENCODED: Final = hash_password(secrets.token_urlsafe(32))
def dummy_verify() -> None:
"""Spend the same time a real verification would.
Called on login paths that fail before reaching a password check -- unknown
username, disabled account, or an account with no password set -- so that response
timing does not reveal which accounts exist.
"""
verify_password("", _DUMMY_ENCODED)
async def hash_password_async(password: str) -> str:
"""Hash a password on a worker thread.
Args:
password: The plaintext password.
Returns:
str: The encoded hash.
"""
return await anyio.to_thread.run_sync(hash_password, password)
async def verify_password_async(password: str, encoded: str) -> bool:
"""Check a password on a worker thread.
Args:
password: The plaintext password to check.
encoded: The stored hash.
Returns:
bool: True if the password matches.
"""
return await anyio.to_thread.run_sync(verify_password, password, encoded)
async def dummy_verify_async() -> None:
"""Spend a verification's worth of time on a worker thread."""
await anyio.to_thread.run_sync(dummy_verify)
def new_token() -> str:
"""Generate an opaque credential.
Returns:
str: A url-safe 256-bit random token.
"""
return secrets.token_urlsafe(TOKEN_BYTES)
def hash_token(token: str) -> str:
"""Hash a token for storage.
A plain SHA-256 with no salt and no stretching, unlike passwords. Tokens are 256
bits of output from a cryptographic random source, so there is no dictionary to
attack and nothing for a work factor to buy; the digest is here so that a database
leak does not hand over usable session cookies. The fixed 64-character output also
lets the column be indexed for lookup.
Args:
token: The plaintext token.
Returns:
str: The hex digest.
"""
return hashlib.sha256(token.encode()).hexdigest()
def tokens_equal(left: str, right: str) -> bool:
"""Compare two tokens without leaking their contents through timing.
Args:
left: One token.
right: The other.
Returns:
bool: True if equal.
"""
return hmac.compare_digest(left, right)
+66
View File
@@ -0,0 +1,66 @@
"""The permission level scale.
Three ordered levels gate the API: ``read`` covers every GET and websocket
subscription, ``edit`` adds modification of existing records, and ``manage`` adds
creation and deletion.
``PUT /spool/{id}/use`` deliberately sits at ``edit`` rather than ``manage``: it is
Moonraker's and OctoPrint's hot path, so a printer can be given an ``edit`` credential
that tracks filament usage but cannot delete anything.
"""
from enum import StrEnum
class Level(StrEnum):
"""A permission level."""
READ = "read"
EDIT = "edit"
MANAGE = "manage"
# Ranks are kept in a separate mapping rather than expressed with comparison operators
# on Level itself. StrEnum inherits str's operators, so defining __lt__/__ge__ would
# silently shadow string ordering and give "edit" < "manage" a second, alphabetical
# meaning that happens to agree here and would stop agreeing the moment a level is
# renamed. Callers use covers() instead.
_RANKS: dict[Level, int] = {
Level.READ: 0,
Level.EDIT: 1,
Level.MANAGE: 2,
}
def covers(actual: Level, required: Level) -> bool:
"""Check whether a level satisfies a requirement.
Args:
actual: The level held.
required: The level demanded.
Returns:
bool: True if `actual` is at least as permissive as `required`.
"""
return _RANKS[actual] >= _RANKS[required]
def parse_level(value: str) -> Level:
"""Parse a level name.
Args:
value: The level name, case-insensitive.
Raises:
ValueError: If the name is not a known level.
Returns:
Level: The parsed level.
"""
try:
return Level(value.strip().lower())
except ValueError as exc:
known = ", ".join(level.value for level in Level)
raise ValueError(f"Unknown permission level '{value}'. Expected one of: {known}.") from exc
+66
View File
@@ -0,0 +1,66 @@
"""The authenticated identity behind a request."""
from dataclasses import dataclass
from enum import StrEnum
from typing import Final
from spoolman.auth.levels import Level, covers
class PrincipalKind(StrEnum):
"""How a principal was established."""
USER = "user"
APIKEY = "apikey"
CERT = "cert"
ANONYMOUS = "anonymous"
DISABLED = "disabled"
@dataclass(frozen=True, slots=True)
class Principal:
"""Who is making a request, and what they may do.
The absence of a credential is represented by ``None`` rather than by a Principal,
so a caller can never accidentally treat "nobody" as a permission holder.
"""
kind: PrincipalKind
level: Level
user_id: int | None = None
username: str | None = None
is_admin: bool = False
is_owner: bool = False
session_id: int | None = None
api_key_id: int | None = None
# Multi-tenancy seam (see AUTHENTICATION_PLAN.md). Always None today. It exists now
# so that every auth database function already threads a tenant through its
# signature, and adding tenancy later becomes a filter inside those functions
# rather than a change to forty routers.
tenant_id: int | None = None
def covers(self, required: Level) -> bool:
"""Check whether this principal satisfies a level requirement.
Args:
required: The level demanded.
Returns:
bool: True if permitted.
"""
return covers(self.level, required)
# Returned by every gate when SPOOLMAN_AUTH_ENABLED is unset, before any database access
# happens. This is what keeps the auth-disabled request path identical to an instance
# built before authentication existed.
UNRESTRICTED: Final = Principal(
kind=PrincipalKind.DISABLED,
level=Level.MANAGE,
is_admin=True,
is_owner=True,
)
# Granted when the auth_anonymous_read setting is on and no credential was presented.
ANONYMOUS_READER: Final = Principal(kind=PrincipalKind.ANONYMOUS, level=Level.READ)
+181
View File
@@ -0,0 +1,181 @@
"""Login throttling and client address resolution.
The windows are in-process dictionaries. That is sound here because Spoolman's
deployment contract is exactly one process -- the same assumption the websocket manager
and the scheduler already make -- but it does mean the counters reset on restart. Account
lockout is therefore also persisted on ``auth_user``; this module only provides the
cheap first line of defence that never touches the database.
"""
import time
from collections import deque
from dataclasses import dataclass
from datetime import timedelta
from ipaddress import IPv4Network, IPv6Network, ip_address, ip_network
from typing import Final
from starlette.requests import Request
from starlette.websockets import WebSocket
from spoolman import env
FORWARDED_FOR: Final = "x-forwarded-for"
FORWARDED_PROTO: Final = "x-forwarded-proto"
@dataclass(frozen=True, slots=True)
class Decision:
"""The outcome of a rate limit check."""
allowed: bool
retry_after: int = 0
class SlidingWindow:
"""Counts events per key over a moving time window."""
def __init__(self, *, limit: int, window: timedelta) -> None:
"""Create a window.
Args:
limit: How many events are allowed within the window.
window: How far back the window reaches.
"""
self._limit = limit
self._window = window.total_seconds()
self._events: dict[str, deque[float]] = {}
def _prune(self, key: str, now: float) -> deque[float] | None:
"""Drop expired events for a key, forgetting the key entirely once empty."""
events = self._events.get(key)
if events is None:
return None
cutoff = now - self._window
while events and events[0] <= cutoff:
events.popleft()
if not events:
del self._events[key]
return None
return events
def check(self, key: str) -> Decision:
"""Test whether an event would be allowed, without recording it.
Args:
key: The identity being limited.
Returns:
Decision: Whether to allow, and how long to wait if not.
"""
now = time.monotonic()
events = self._prune(key, now)
if events is None or len(events) < self._limit:
return Decision(allowed=True)
retry_after = int(events[0] + self._window - now) + 1
return Decision(allowed=False, retry_after=max(retry_after, 1))
def hit(self, key: str) -> None:
"""Record an event against a key.
Args:
key: The identity being limited.
"""
now = time.monotonic()
self._prune(key, now)
self._events.setdefault(key, deque()).append(now)
def clear(self, key: str) -> None:
"""Forget a key's history, called after a successful login.
Args:
key: The identity to reset.
"""
self._events.pop(key, None)
# Keyed by client address, to blunt spraying across many usernames from one source.
#
# There is deliberately no per-username window here. Per-account throttling is the
# persisted failed_logins/locked_until pair on auth_user, which is graduated (a minute,
# doubling to a quarter of an hour) and survives a restart. A second in-memory window
# keyed by username would shadow that schedule with a flat one and make the graduation
# unreachable. This window exists for the case the persisted counter cannot see: guesses
# spread across many usernames, including ones that do not exist.
ip_window: Final = SlidingWindow(limit=20, window=timedelta(minutes=15))
def _parse_network(entry: str) -> IPv4Network | IPv6Network | None:
"""Parse one trusted-proxy entry, returning None if it is not an address or network."""
try:
return ip_network(entry, strict=False)
except ValueError:
return None
def _trusted_networks() -> list[IPv4Network | IPv6Network]:
"""Parse SPOOLMAN_TRUSTED_PROXIES, skipping entries that are not addresses."""
parsed = (_parse_network(entry) for entry in env.get_trusted_proxies())
return [network for network in parsed if network is not None]
def is_trusted_peer(peer: str | None) -> bool:
"""Check whether forwarding headers from an address may be believed.
Args:
peer: The address the connection actually came from.
Returns:
bool: True if the address is covered by SPOOLMAN_TRUSTED_PROXIES.
"""
if not peer:
return False
try:
address = ip_address(peer)
except ValueError:
return False
return any(address in network for network in _trusted_networks())
def client_ip(conn: Request | WebSocket) -> str:
"""Determine the address a request came from.
X-Forwarded-For is honoured only when the immediate peer is a configured trusted
proxy. Without that check any client could spoof the header and evade rate limiting
by varying it.
Args:
conn: The request or websocket.
Returns:
str: The client address, or an empty string if it cannot be determined.
"""
peer = conn.client.host if conn.client else ""
if is_trusted_peer(peer):
forwarded = conn.headers.get(FORWARDED_FOR)
if forwarded:
return forwarded.split(",")[0].strip()
return peer
def forwarded_proto(conn: Request | WebSocket) -> str | None:
"""Read the originating scheme reported by a trusted proxy.
Args:
conn: The request or websocket.
Returns:
Optional[str]: The scheme, or None if there is no trustworthy header.
"""
if not is_trusted_peer(conn.client.host if conn.client else ""):
return None
proto = conn.headers.get(FORWARDED_PROTO)
if not proto:
return None
return proto.split(",")[0].strip().lower()
+128
View File
@@ -0,0 +1,128 @@
"""The instance secret key.
Resolution order is ``SPOOLMAN_SECRET_KEY_FILE``, then ``SPOOLMAN_SECRET_KEY``, then an
auto-generated ``secret.key`` in the data directory. The generated file is created with
mode 0600 via an exclusive open, rather than a write followed by a chmod, so its
contents are never briefly readable under a permissive umask.
Phase 1 does not use the key for anything cryptographic: session tokens are opaque
256-bit random values stored as SHA-256 digests, and CSRF protection is a double-submit
cookie comparison. The key exists now because it is consumed from phase 3 onward (OIDC
state signing) and phase 4 (Fernet encryption of TOTP secrets), and introducing it later
would force every phase-1 adopter to reconfigure. Resolving it at startup also proves,
at a point where the error message is actionable, that the data directory is writable
and that any configured key file exists and is readable.
"""
import logging
import os
import secrets
import stat
from pathlib import Path
from spoolman import env
logger = logging.getLogger(__name__)
KEY_FILENAME = "secret.key"
KEY_BYTES = 32
__key: bytes | None = None
def _generate(path: Path) -> bytes:
"""Create a new key file with owner-only permissions.
Args:
path: Where to write the key.
Returns:
bytes: The generated key.
"""
value = secrets.token_hex(KEY_BYTES)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(value)
except BaseException:
path.unlink(missing_ok=True)
raise
logger.info("Generated a new secret key at %s.", path)
return value.encode()
def _warn_if_readable(path: Path) -> None:
"""Warn when an existing key file is readable by anyone but its owner.
Deliberately a warning rather than an error: some bind-mounted volumes cannot
represent 0600, and refusing to start would be worse than the exposure.
Args:
path: The key file to inspect.
"""
try:
mode = path.stat().st_mode
except OSError:
return
if mode & (stat.S_IRWXG | stat.S_IRWXO):
logger.warning(
"Secret key file %s is readable by users other than its owner. Restrict it with 'chmod 600 %s'.",
path,
path,
)
def get_secret_key() -> bytes:
"""Get the instance secret key, generating one if needed.
The result is memoised, so the file is read or created at most once per process.
Raises:
ValueError: If a configured key is empty.
Returns:
bytes: The secret key.
"""
global __key # noqa: PLW0603
if __key is not None:
return __key
configured = env.get_secret_key()
if configured is not None:
if not configured:
raise ValueError("Failed to parse SPOOLMAN_SECRET_KEY variable: The key is empty.")
__key = configured.encode()
return __key
path = env.get_data_dir() / KEY_FILENAME
if path.exists():
_warn_if_readable(path)
existing = path.read_text(encoding="utf-8").strip()
if existing:
__key = existing.encode()
return __key
logger.warning("Secret key file %s is empty, generating a new key.", path)
path.unlink()
__key = _generate(path)
return __key
def ensure_secret_key() -> None:
"""Resolve the secret key early so configuration problems surface at startup.
Called from the server's startup sequence when authentication is enabled.
"""
get_secret_key()
def reset_cache() -> None:
"""Forget the memoised key.
Only useful to tests, which change the environment between cases.
"""
global __key # noqa: PLW0603
__key = None
+327
View File
@@ -0,0 +1,327 @@
"""Command line administration for authentication.
Run as ``python -m spoolman.cli``, rather than as an installed console script, because
the Docker image runs Spoolman from source:
docker exec -it spoolman python -m spoolman.cli auth status
This is the way back in when nobody can sign in -- a forgotten owner password, a
disabled account, an instance whose owner has left. It is the reason no per-user
recovery codes exist.
It talks to the database directly using the same environment configuration as the
server, and never runs migrations: if the schema is missing it says so and stops, since
the fix is to start the server once and let it migrate.
It also works while SPOOLMAN_AUTH_ENABLED is unset, which is deliberate -- the intended
first run is to create an owner and only then turn authentication on.
"""
# ruff: noqa: T201
import argparse
import asyncio
import secrets
import sys
from datetime import datetime
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth.hashing import hash_password
from spoolman.auth.levels import Level, parse_level
from spoolman.database import auth_session as auth_session_db
from spoolman.database import auth_user as auth_user_db
from spoolman.database import database, models
# Long enough that a generated password is not worth attacking, short enough to retype
# from a terminal into a browser.
GENERATED_PASSWORD_BYTES = 12
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_NO_SCHEMA = 2
async def _require_schema() -> None:
"""Stop unless the auth tables exist."""
def _tables(connection: sqlalchemy.Connection) -> list[str]:
return sqlalchemy.inspect(connection).get_table_names()
async with database.get_engine().connect() as connection:
tables = await connection.run_sync(_tables)
if "auth_user" not in tables:
print(
"Spoolman's authentication tables are missing. Start the Spoolman server "
"once so it can migrate the database, then run this command again.",
)
raise SystemExit(EXIT_NO_SCHEMA)
def _generate_password() -> str:
"""Make a password that is safe to hand to someone once."""
return secrets.token_urlsafe(GENERATED_PASSWORD_BYTES)
def _describe(user: models.AuthUser) -> str:
"""Format a user as one line for `user list`."""
flags = []
if user.is_owner:
flags.append("owner")
if user.is_admin:
flags.append("admin")
if not user.is_active:
flags.append("disabled")
if user.must_change_password:
flags.append("must-change-password")
if user.locked_until is not None and user.locked_until > datetime.utcnow():
flags.append(f"locked until {user.locked_until:%Y-%m-%d %H:%M} UTC")
last = f"{user.last_login:%Y-%m-%d %H:%M}" if user.last_login else "never"
return f" {user.username:<24} {user.level:<7} last login: {last:<17} {' '.join(flags)}"
async def _cmd_auth_status(args: argparse.Namespace, db: AsyncSession) -> int: # noqa: ARG001
"""Report the state of authentication on this instance."""
from spoolman import env # noqa: PLC0415
users, total = await auth_user_db.find(db=db)
owners = [u for u in users if u.is_owner]
print(f"Authentication enabled : {'yes' if env.is_auth_enabled() else 'no (SPOOLMAN_AUTH_ENABLED is not set)'}")
print(f"User accounts : {total}")
print(f"Owner : {owners[0].username if owners else 'none - the instance is unclaimed'}")
if total == 0:
print()
print("Nobody can sign in yet. Either create an account here, or start the server")
print("with authentication enabled and claim it from the web interface.")
return EXIT_OK
async def _cmd_user_list(args: argparse.Namespace, db: AsyncSession) -> int: # noqa: ARG001
"""List every account."""
users, total = await auth_user_db.find(db=db)
if total == 0:
print("No user accounts exist.")
return EXIT_OK
print(f"{total} user account(s):")
for user in users:
print(_describe(user))
return EXIT_OK
async def _cmd_user_create(args: argparse.Namespace, db: AsyncSession) -> int:
"""Create an account and print its generated password."""
existing = await auth_user_db.get_by_username(db, args.username)
if existing is not None:
print(f"A user named {existing.username!r} already exists.")
return EXIT_ERROR
if args.owner:
existing_users, _ = await auth_user_db.find(db=db)
owners = [u for u in existing_users if u.is_owner]
if owners:
print(
f"This instance is already owned by {owners[0].username!r}. There is exactly one owner; "
"transfer ownership from the web interface instead.",
)
return EXIT_ERROR
# Administering other users implies being able to manage data, so an owner or admin
# is always at manage. Without this, "user create <name> --owner" would take the
# default level and produce an owner who cannot change anything.
is_admin = args.admin or args.owner
level = parse_level(args.level) if args.level is not None else Level.READ
if is_admin and level is not Level.MANAGE:
if args.level is not None:
print(f"Ignoring --level {level}: an admin or owner is always at {Level.MANAGE}.")
level = Level.MANAGE
password = _generate_password()
user = await auth_user_db.create(
db=db,
username=args.username,
password_hash=hash_password(password),
level=level,
is_admin=is_admin,
is_owner=args.owner,
display_name=args.display_name,
)
print(f"Created {user.username!r} with level {user.level}.")
print(f" Password: {password}")
print(" This is shown once. Give it to the user and have them change it.")
return EXIT_OK
async def _cmd_user_reset_password(args: argparse.Namespace, db: AsyncSession) -> int:
"""Give an account a new one-time password."""
user = await auth_user_db.get_by_username(db, args.username)
if user is None:
print(f"No user named {args.username!r}.")
return EXIT_ERROR
password = _generate_password()
await auth_user_db.set_password(
db=db,
user=user,
password_hash=hash_password(password),
must_change=True,
)
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
print(f"Reset the password for {user.username!r}.")
print(f" Password: {password}")
print(f" They must change it at next sign-in. {revoked} existing session(s) were ended.")
return EXIT_OK
async def _cmd_user_set_level(args: argparse.Namespace, db: AsyncSession) -> int:
"""Change an account's permission level."""
user = await auth_user_db.get_by_username(db, args.username)
if user is None:
print(f"No user named {args.username!r}.")
return EXIT_ERROR
level = parse_level(args.level)
user.level = str(level)
await db.commit()
print(f"{user.username!r} is now at level {level}.")
return EXIT_OK
async def _cmd_user_enable(args: argparse.Namespace, db: AsyncSession) -> int:
"""Re-enable an account and clear any lockout."""
user = await auth_user_db.get_by_username(db, args.username)
if user is None:
print(f"No user named {args.username!r}.")
return EXIT_ERROR
user.is_active = True
user.failed_logins = 0
user.locked_until = None
await db.commit()
print(f"{user.username!r} is enabled, and any lockout has been cleared.")
return EXIT_OK
async def _cmd_user_disable(args: argparse.Namespace, db: AsyncSession) -> int:
"""Disable an account and end its sessions."""
user = await auth_user_db.get_by_username(db, args.username)
if user is None:
print(f"No user named {args.username!r}.")
return EXIT_ERROR
if user.is_owner:
print("The owner cannot be disabled. Transfer ownership first.")
return EXIT_ERROR
user.is_active = False
await db.commit()
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
print(f"{user.username!r} is disabled. {revoked} session(s) were ended.")
return EXIT_OK
async def _cmd_session_revoke_all(args: argparse.Namespace, db: AsyncSession) -> int:
"""Sign out one user, or everyone."""
if args.user:
user = await auth_user_db.get_by_username(db, args.user)
if user is None:
print(f"No user named {args.user!r}.")
return EXIT_ERROR
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
print(f"Ended {revoked} session(s) for {user.username!r}.")
return EXIT_OK
users, _ = await auth_user_db.find(db=db)
revoked = 0
for user in users:
revoked += await auth_session_db.revoke_all_for_user(db, user.id)
print(f"Ended {revoked} session(s) across all users.")
return EXIT_OK
def _build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="python -m spoolman.cli",
description=(
"Administer Spoolman authentication from the host. Use this to create the "
"first account, or to get back in when nobody can sign in."
),
)
commands = parser.add_subparsers(dest="group", required=True)
auth = commands.add_parser("auth", help="Inspect authentication state.")
auth_sub = auth.add_subparsers(dest="command", required=True)
auth_sub.add_parser("status", help="Show whether auth is on, and who owns the instance.").set_defaults(
func=_cmd_auth_status,
)
user = commands.add_parser("user", help="Manage user accounts.")
user_sub = user.add_subparsers(dest="command", required=True)
user_sub.add_parser("list", help="List all accounts.").set_defaults(func=_cmd_user_list)
create = user_sub.add_parser("create", help="Create an account and print a generated password.")
create.add_argument("username")
create.add_argument(
"--level",
default=None,
choices=[str(level) for level in Level],
help=f"Permission level. Defaults to {Level.READ}, or {Level.MANAGE} with --admin or --owner.",
)
create.add_argument("--admin", action="store_true", help="Let this user administer other users.")
create.add_argument("--owner", action="store_true", help="Make this user the instance owner. Implies --admin.")
create.add_argument("--display-name", default=None)
create.set_defaults(func=_cmd_user_create)
reset = user_sub.add_parser("reset-password", help="Set a new one-time password and end their sessions.")
reset.add_argument("username")
reset.set_defaults(func=_cmd_user_reset_password)
set_level = user_sub.add_parser("set-level", help="Change an account's permission level.")
set_level.add_argument("username")
set_level.add_argument("level", choices=[str(level) for level in Level])
set_level.set_defaults(func=_cmd_user_set_level)
enable = user_sub.add_parser("enable", help="Re-enable an account and clear any lockout.")
enable.add_argument("username")
enable.set_defaults(func=_cmd_user_enable)
disable = user_sub.add_parser("disable", help="Disable an account and end its sessions.")
disable.add_argument("username")
disable.set_defaults(func=_cmd_user_disable)
session = commands.add_parser("session", help="Manage login sessions.")
session_sub = session.add_subparsers(dest="command", required=True)
revoke = session_sub.add_parser("revoke-all", help="Sign out everyone, or one user.")
revoke.add_argument("--user", default=None, help="Limit to this username.")
revoke.set_defaults(func=_cmd_session_revoke_all)
return parser
async def _run(args: argparse.Namespace) -> int:
"""Connect to the database and dispatch the command."""
database.setup_db(database.get_connection_url())
await _require_schema()
generator = database.get_db_session()
db = await anext(generator)
try:
return await args.func(args, db)
finally:
await generator.aclose()
def main() -> None:
"""Run the command line interface."""
args = _build_parser().parse_args()
try:
sys.exit(asyncio.run(_run(args)))
except SystemExit:
raise
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(EXIT_ERROR)
if __name__ == "__main__":
main()
+229
View File
@@ -0,0 +1,229 @@
"""Helper functions for interacting with API key database objects.
As with :mod:`spoolman.database.auth_user`, nothing here emits a websocket event.
``websocket_manager.send`` fans out to every subscriber, so broadcasting key activity
would tell every connected client when and how often each machine credential is used,
and the creation event would carry the prefix that identifies it. Do not add a notifier
here.
"""
import logging
from datetime import datetime, timedelta
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth import apikey
from spoolman.auth.hashing import hash_token, tokens_equal
from spoolman.auth.levels import Level
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
logger = logging.getLogger(__name__)
# How stale last_used must be before a request bothers to update it.
#
# The same reasoning as auth_session.RENEW_THRESHOLD, and it matters more here: an API
# key is what a printer polls with, several times a minute, forever. Recording every one
# of those would turn a read-only request into a write on a database whose default is a
# single SQLite file behind one write lock. "Last used" to the nearest five minutes is
# all this field is ever read for.
TOUCH_THRESHOLD = timedelta(minutes=5)
# Longest a key may be issued for, in days. Not a security boundary -- an expiry is a
# convenience, and a key can always be revoked -- but an unbounded value would let a
# typo produce a date SQLite stores and other backends reject.
MAX_EXPIRY_DAYS = 3650
async def create(
*,
db: AsyncSession,
user_id: int,
name: str,
level: Level,
expires_days: int | None = None,
) -> tuple[models.AuthApiKey, str]:
"""Issue a new API key.
Unlike passwords, the secret is generated here rather than passed in. There is
nothing for a caller to supply -- a key is not chosen, it is minted -- and hashing
it is a single SHA-256 rather than the tens of milliseconds a password costs, so
none of the offloading that :mod:`spoolman.auth.hashing` exists for applies. This
mirrors :func:`spoolman.database.auth_session.create`.
Args:
db: The database session.
user_id: Who the key belongs to.
name: A human label, so the owner can tell their keys apart.
level: The level to issue at. The caller is responsible for refusing to issue
above its own; the cap against the owner's current level is applied at
every request instead, in :func:`spoolman.auth.apikey.effective_level`.
expires_days: How long the key should live, or None for no expiry.
Returns:
tuple: The stored key and the complete plaintext key, which is the only time
the secret exists in a readable form.
"""
prefix, plaintext = apikey.generate()
now = datetime.utcnow().replace(microsecond=0)
expires = now + timedelta(days=expires_days) if expires_days else None
key = models.AuthApiKey(
user_id=user_id,
name=name,
level=str(level),
prefix=prefix,
key_hash=hash_token(plaintext),
created=now,
expires=expires,
last_used=None,
revoked=False,
)
db.add(key)
await db.commit()
return key, plaintext
async def resolve(db: AsyncSession, presented: str) -> models.AuthApiKey | None:
"""Look up a live key by the string a caller presented.
Args:
db: The database session.
presented: The complete key from the request header.
Returns:
Optional[models.AuthApiKey]: The key, or None if it is unknown, revoked or
expired.
"""
prefix = apikey.prefix_of(presented)
if prefix is None:
return None
stmt = select(models.AuthApiKey).where(models.AuthApiKey.prefix == prefix)
key = (await db.execute(stmt)).scalar_one_or_none()
if key is None:
return None
# Constant time even though both sides are hex digests of the same length: the
# comparison is cheap and a timing oracle on the stored hash is not worth leaving
# open for the sake of an early exit.
if not tokens_equal(hash_token(presented), key.key_hash):
return None
if key.revoked:
return None
now = datetime.utcnow().replace(microsecond=0)
if key.expires is not None and key.expires <= now:
# Left in place rather than deleted, unlike an expired session. A key is
# something a person made and named, and having it disappear from the list
# instead of showing as expired reads as data loss.
return None
if key.last_used is None or now - key.last_used >= TOUCH_THRESHOLD:
key.last_used = now
await db.commit()
return key
async def get_by_id(db: AsyncSession, key_id: int) -> models.AuthApiKey:
"""Get an API key by its unique ID.
Args:
db: The database session.
key_id: The key ID.
Raises:
ItemNotFoundError: If no such key exists.
Returns:
models.AuthApiKey: The key.
"""
key = await db.get(models.AuthApiKey, key_id)
if key is None:
raise ItemNotFoundError(f"No API key with ID {key_id} found.")
return key
async def find_for_user(*, db: AsyncSession, user_id: int) -> list[models.AuthApiKey]:
"""List a user's keys, newest first.
Revoked and expired keys are included. The owner needs to see that a key they
revoked is in fact revoked, and a key that stopped working needs to be
distinguishable from one that was never created.
Args:
db: The database session.
user_id: Whose keys to list.
Returns:
list[models.AuthApiKey]: The keys.
"""
stmt = (
select(models.AuthApiKey)
.where(models.AuthApiKey.user_id == user_id)
.order_by(models.AuthApiKey.created.desc(), models.AuthApiKey.id.desc())
)
return list((await db.execute(stmt)).scalars().all())
async def count_for_user(db: AsyncSession, user_id: int) -> int:
"""Count the keys a user holds, revoked ones included.
Args:
db: The database session.
user_id: Whose keys to count.
Returns:
int: The number of keys.
"""
stmt = select(func.count()).select_from(models.AuthApiKey).where(models.AuthApiKey.user_id == user_id)
return (await db.execute(stmt)).scalar_one()
async def revoke(*, db: AsyncSession, key: models.AuthApiKey) -> None:
"""Stop a key from authenticating, keeping the row.
Args:
db: The database session.
key: The key to revoke.
"""
key.revoked = True
await db.commit()
async def delete_key(*, db: AsyncSession, key: models.AuthApiKey) -> None:
"""Remove a key entirely.
Args:
db: The database session.
key: The key to delete.
"""
await db.delete(key)
await db.commit()
async def delete_all_for_user(db: AsyncSession, user_id: int) -> int:
"""Remove every key belonging to a user.
Used when the user itself is deleted: the foreign key would otherwise leave rows
pointing at an account that no longer exists.
Args:
db: The database session.
user_id: Whose keys to remove.
Returns:
int: How many keys were removed.
"""
result = await db.execute(delete(models.AuthApiKey).where(models.AuthApiKey.user_id == user_id))
await db.commit()
return result.rowcount or 0
+169
View File
@@ -0,0 +1,169 @@
"""Helper functions for interacting with audit log database objects.
As with the other ``auth_`` modules, nothing here emits a websocket event. The audit log
is the most sensitive table in the schema -- it records who signed in, from where, and
what they changed -- and ``websocket_manager.send`` fans out to every subscriber
including anonymous readers. Do not add a notifier here.
"""
import datetime
import logging
from sqlalchemy import Select, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import models
logger = logging.getLogger(__name__)
# Caps on the free-form columns, so a hostile user agent or a long username cannot
# overflow the column and fail the insert -- which, on the login path, would turn an
# audit write into a failed sign-in.
TARGET_MAX = 128
USER_AGENT_MAX = 256
IP_MAX = 64
EVENT_MAX = 64
def _clip(value: str | None, limit: int) -> str | None:
"""Truncate a value to fit its column, treating the empty string as absent."""
if not value:
return None
return value[:limit]
async def record(
*,
db: AsyncSession,
event: str,
actor_kind: str,
actor_user_id: int | None = None,
target: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
detail: str | None = None,
) -> models.AuthAuditLog:
"""Append an entry to the audit log.
Args:
db: The database session.
event: What happened. See :class:`spoolman.auth.audit.AuditEvent`.
actor_kind: How the actor was authenticated, or "anonymous" if not at all.
actor_user_id: Which account acted, if the actor was a known user.
target: What was acted on -- a username, a key name, a session count.
ip: Where the request came from.
user_agent: What the request identified itself as.
detail: A JSON object with anything else worth keeping.
Returns:
models.AuthAuditLog: The stored entry.
"""
entry = models.AuthAuditLog(
date=datetime.datetime.utcnow().replace(microsecond=0),
event=event[:EVENT_MAX],
actor_user_id=actor_user_id,
actor_kind=actor_kind,
target=_clip(target, TARGET_MAX),
ip=_clip(ip, IP_MAX),
user_agent=_clip(user_agent, USER_AGENT_MAX),
detail=detail,
)
db.add(entry)
await db.commit()
return entry
def _filtered(stmt: Select, *, event: str | None, actor_user_id: int | None) -> Select:
"""Apply the optional filters shared by the listing and its count."""
if event:
stmt = stmt.where(models.AuthAuditLog.event == event)
if actor_user_id is not None:
stmt = stmt.where(models.AuthAuditLog.actor_user_id == actor_user_id)
return stmt
async def find(
*,
db: AsyncSession,
limit: int | None = None,
offset: int = 0,
event: str | None = None,
actor_user_id: int | None = None,
) -> tuple[list[models.AuthAuditLog], int]:
"""List audit entries, newest first.
Args:
db: The database session.
limit: The maximum number to return.
offset: How many to skip.
event: Only return entries for this event.
actor_user_id: Only return entries by this user.
Returns:
tuple: The entries, and the total number matching the filters.
"""
stmt = _filtered(select(models.AuthAuditLog), event=event, actor_user_id=actor_user_id)
# Ordered by id as well as date: the timestamp is stored to the second, so several
# entries from one request would otherwise come back in an arbitrary order and
# paginate inconsistently.
stmt = stmt.order_by(models.AuthAuditLog.date.desc(), models.AuthAuditLog.id.desc())
count_stmt = _filtered(
select(func.count()).select_from(models.AuthAuditLog),
event=event,
actor_user_id=actor_user_id,
)
total = (await db.execute(count_stmt)).scalar_one()
if offset:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
rows = list((await db.execute(stmt)).scalars().all())
return rows, total
async def prune_older_than(db: AsyncSession, cutoff: datetime.datetime) -> int:
"""Delete audit entries older than a point in time.
Args:
db: The database session.
cutoff: Entries dated before this are removed.
Returns:
int: How many entries were removed.
"""
result = await db.execute(delete(models.AuthAuditLog).where(models.AuthAuditLog.date < cutoff))
await db.commit()
return result.rowcount or 0
async def clear_actor(db: AsyncSession, user_id: int) -> int:
"""Detach a user's audit entries from their account.
Called when the account is deleted. The entries themselves are kept -- an audit log
that loses its record of what an account did the moment that account is removed is
not an audit log -- but the foreign key has to stop pointing at a row that no longer
exists. The username is already recorded in ``target`` on the entries where it
matters, so the trail stays readable.
Args:
db: The database session.
user_id: Whose entries to detach.
Returns:
int: How many entries were detached.
"""
rows = (
(await db.execute(select(models.AuthAuditLog).where(models.AuthAuditLog.actor_user_id == user_id)))
.scalars()
.all()
)
for row in rows:
row.actor_user_id = None
await db.commit()
return len(rows)
+194
View File
@@ -0,0 +1,194 @@
"""Helper functions for interacting with login session database objects.
As with :mod:`spoolman.database.auth_user`, nothing here emits a websocket event -- the
subscriber pool is every connected client, and session activity is nobody else's
business.
"""
import logging
from datetime import datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth.cookies import IDLE_LIFETIME, REMEMBER_LIFETIME
from spoolman.auth.hashing import hash_token, new_token
from spoolman.database import models
logger = logging.getLogger(__name__)
# How stale last_seen must be before a request bothers to slide the session forward.
#
# Without this, every authenticated request becomes a database write. On SQLite -- the
# default, a single file behind one write lock -- that serialises the whole application
# behind session bookkeeping and would undo the list-page latency work that the
# selectin loading in models.py exists for. A minute of imprecision against a twelve
# hour idle window is not worth a write per request.
RENEW_THRESHOLD = timedelta(minutes=1)
# Cap on how much of a user agent string is retained for the session list.
USER_AGENT_MAX = 256
def lifetime_for(*, remember: bool) -> timedelta:
"""Get how long a session should live from now.
Args:
remember: Whether the user asked to stay signed in.
Returns:
timedelta: The session lifetime.
"""
return REMEMBER_LIFETIME if remember else IDLE_LIFETIME
async def create(
*,
db: AsyncSession,
user_id: int,
remember: bool,
user_agent: str | None = None,
ip: str | None = None,
) -> tuple[models.AuthSession, str]:
"""Start a new session.
Args:
db: The database session.
user_id: Who the session belongs to.
remember: Whether the session should outlive the browser session.
user_agent: The client's user agent, for the session list.
ip: The client's address, for the session list.
Returns:
tuple: The stored session and the plaintext token, which is the only time the
token exists in a readable form.
"""
token = new_token()
now = datetime.utcnow().replace(microsecond=0)
session = models.AuthSession(
user_id=user_id,
token_hash=hash_token(token),
created=now,
expires=now + lifetime_for(remember=remember),
last_seen=now,
remember=remember,
user_agent=user_agent[:USER_AGENT_MAX] if user_agent else None,
ip=ip or None,
)
db.add(session)
await db.commit()
return session, token
async def resolve(db: AsyncSession, token: str) -> models.AuthSession | None:
"""Look up a live session by its token, sliding its expiry forward.
An expired session is deleted as it is encountered, which keeps the table tidy
without needing a scheduled sweep.
Args:
db: The database session.
token: The plaintext token from the cookie.
Returns:
Optional[models.AuthSession]: The session, or None if unknown or expired.
"""
if not token:
return None
stmt = select(models.AuthSession).where(models.AuthSession.token_hash == hash_token(token))
session = (await db.execute(stmt)).scalar_one_or_none()
if session is None:
return None
now = datetime.utcnow().replace(microsecond=0)
if session.expires <= now:
await db.delete(session)
await db.commit()
return None
if now - session.last_seen >= RENEW_THRESHOLD:
session.last_seen = now
session.expires = now + lifetime_for(remember=session.remember)
await db.commit()
return session
async def revoke(db: AsyncSession, session_id: int) -> None:
"""End one session.
Args:
db: The database session.
session_id: Which session to end.
"""
await db.execute(delete(models.AuthSession).where(models.AuthSession.id == session_id))
await db.commit()
async def revoke_by_token(db: AsyncSession, token: str) -> None:
"""End the session identified by a token.
Args:
db: The database session.
token: The plaintext token from the cookie.
"""
if not token:
return
await db.execute(delete(models.AuthSession).where(models.AuthSession.token_hash == hash_token(token)))
await db.commit()
async def revoke_all_for_user(db: AsyncSession, user_id: int) -> int:
"""End every session belonging to a user.
Args:
db: The database session.
user_id: Whose sessions to end.
Returns:
int: How many sessions were ended.
"""
result = await db.execute(delete(models.AuthSession).where(models.AuthSession.user_id == user_id))
await db.commit()
return result.rowcount or 0
async def find_for_user(*, db: AsyncSession, user_id: int) -> list[models.AuthSession]:
"""List a user's live sessions, most recently used first.
Args:
db: The database session.
user_id: Whose sessions to list.
Returns:
list[models.AuthSession]: The sessions.
"""
stmt = (
select(models.AuthSession)
.where(models.AuthSession.user_id == user_id)
.order_by(models.AuthSession.last_seen.desc())
)
return list((await db.execute(stmt)).scalars().all())
async def prune_expired(db: AsyncSession) -> int:
"""Delete every session that has expired.
Args:
db: The database session.
Returns:
int: How many sessions were removed.
"""
now = datetime.utcnow().replace(microsecond=0)
result = await db.execute(delete(models.AuthSession).where(models.AuthSession.expires <= now))
await db.commit()
return result.rowcount or 0
+331
View File
@@ -0,0 +1,331 @@
"""Helper functions for interacting with user database objects.
Unlike the vendor, filament and spool modules, nothing here emits a websocket event.
``websocket_manager.send`` fans out to every subscriber, so broadcasting account changes
would hand every connected client the details of every other account. Account state is
read back over the authenticated REST endpoints instead. Do not add a notifier here.
"""
import logging
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth.levels import Level
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
logger = logging.getLogger(__name__)
# Failed logins allowed before an account starts locking.
LOCKOUT_THRESHOLD = 5
# The first lockout, doubling for each subsequent failure.
LOCKOUT_BASE = timedelta(minutes=1)
# The longest an account is ever locked for. A lockout that grows without bound turns a
# password guess into a denial of service against the real owner.
LOCKOUT_MAX = timedelta(minutes=15)
def normalize_username(username: str) -> str:
"""Reduce a username to its stored form.
Args:
username: The username as typed.
Returns:
str: The trimmed, lowercased username.
"""
return username.strip().lower()
async def create(
*,
db: AsyncSession,
username: str,
password_hash: str | None,
level: Level,
is_admin: bool = False,
is_owner: bool = False,
display_name: str | None = None,
must_change_password: bool = False,
) -> models.AuthUser:
"""Add a new user to the database.
Takes an already-derived hash rather than a plaintext password. Deriving one blocks
for tens of milliseconds, and this module is called from async request handlers
where that would stall the whole process; callers pick
:func:`spoolman.auth.hashing.hash_password_async` or its synchronous twin according
to where they run.
Args:
db: The database session.
username: The username, stored lowercased.
password_hash: The encoded password hash, or None for an account that cannot
sign in with a password.
level: The permission level to grant.
is_admin: Whether the user administers other users.
is_owner: Whether the user owns the instance.
display_name: An optional friendly name.
must_change_password: Whether to force a password change at next sign-in.
Returns:
models.AuthUser: The created user.
"""
user = models.AuthUser(
username=normalize_username(username),
display_name=display_name,
email=None,
password_hash=password_hash,
level=str(level),
is_admin=is_admin,
is_owner=is_owner,
is_active=True,
must_change_password=must_change_password,
totp_secret=None,
totp_enabled=False,
oidc_issuer=None,
oidc_subject=None,
failed_logins=0,
locked_until=None,
registered=datetime.utcnow().replace(microsecond=0),
last_login=None,
)
db.add(user)
await db.commit()
return user
async def get_by_id(db: AsyncSession, user_id: int) -> models.AuthUser:
"""Get a user from the database by the unique ID.
Args:
db: The database session.
user_id: The user ID.
Raises:
ItemNotFoundError: If no such user exists.
Returns:
models.AuthUser: The user.
"""
user = await db.get(models.AuthUser, user_id)
if user is None:
raise ItemNotFoundError(f"No user with ID {user_id} found.")
return user
async def get_by_username(db: AsyncSession, username: str) -> models.AuthUser | None:
"""Get a user from the database by username.
Returns None rather than raising, because the caller is usually the login path and a
missing user there is an expected outcome, not an error.
Args:
db: The database session.
username: The username, in any case.
Returns:
Optional[models.AuthUser]: The user, or None.
"""
stmt = select(models.AuthUser).where(models.AuthUser.username == normalize_username(username))
return (await db.execute(stmt)).scalar_one_or_none()
async def count(db: AsyncSession) -> int:
"""Count the user accounts that exist.
Args:
db: The database session.
Returns:
int: The number of accounts.
"""
stmt = select(func.count()).select_from(models.AuthUser)
return (await db.execute(stmt)).scalar_one()
async def find(
*,
db: AsyncSession,
limit: int | None = None,
offset: int = 0,
) -> tuple[list[models.AuthUser], int]:
"""List user accounts.
Args:
db: The database session.
limit: The maximum number to return.
offset: How many to skip.
Returns:
tuple: The users, and the total number that exist.
"""
stmt = select(models.AuthUser).order_by(models.AuthUser.username)
total = await count(db)
if offset:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
rows = list((await db.execute(stmt)).scalars().all())
return rows, total
# Fields an administrator may change through :func:`update`. A whitelist rather than
# open setattr, so that a bug or a crafted request in the API layer cannot reach
# password_hash, is_owner, or the lockout counters.
UPDATABLE_FIELDS = frozenset({"level", "is_admin", "is_active", "display_name"})
async def update(*, db: AsyncSession, user: models.AuthUser, changes: dict[str, object]) -> models.AuthUser:
"""Apply administrative changes to an account.
Args:
db: The database session.
user: The user to modify.
changes: Field names and their new values.
Raises:
ValueError: If a field outside :data:`UPDATABLE_FIELDS` is named.
Returns:
models.AuthUser: The modified user.
"""
unknown = set(changes) - UPDATABLE_FIELDS
if unknown:
raise ValueError(f"Cannot update field(s): {', '.join(sorted(unknown))}.")
for field, value in changes.items():
setattr(user, field, value)
await db.commit()
return user
async def delete_user(*, db: AsyncSession, user: models.AuthUser) -> None:
"""Remove an account.
The caller is responsible for clearing what references it first -- sessions, API
keys and audit entries all carry a foreign key to this row. Deliberately not done
here: the audit log's rows are kept and merely detached, which is a policy decision
that belongs with the caller rather than buried in a delete.
Args:
db: The database session.
user: The user to remove.
"""
await db.delete(user)
await db.commit()
async def set_password(
*,
db: AsyncSession,
user: models.AuthUser,
password_hash: str,
must_change: bool = False,
) -> None:
"""Replace a user's password.
Args:
db: The database session.
user: The user to modify.
password_hash: The encoded hash of the new password.
must_change: Whether to force another change at next sign-in.
"""
user.password_hash = password_hash
user.must_change_password = must_change
# A password change resets the lockout: the credential being guessed no longer
# exists, so continuing to hold the account shut serves no purpose.
user.failed_logins = 0
user.locked_until = None
await db.commit()
async def rehash_password(*, db: AsyncSession, user: models.AuthUser, password_hash: str) -> None:
"""Store a re-derived hash of the same password.
Used by the login path when the cost parameters have been raised since the stored
hash was produced. Deliberately leaves must_change_password, failed_logins and
locked_until alone: nothing about the user's credential has changed.
Args:
db: The database session.
user: The user to modify.
password_hash: The newly encoded hash.
"""
user.password_hash = password_hash
await db.commit()
def lockout_for(failed_logins: int) -> timedelta | None:
"""Work out how long an account should be locked after a number of failures.
Args:
failed_logins: The running count of consecutive failures.
Returns:
Optional[timedelta]: How long to lock for, or None if below the threshold.
"""
if failed_logins < LOCKOUT_THRESHOLD:
return None
doublings = failed_logins - LOCKOUT_THRESHOLD
return min(LOCKOUT_BASE * (2**doublings), LOCKOUT_MAX)
async def record_login_failure(*, db: AsyncSession, user: models.AuthUser) -> None:
"""Note a failed sign-in, locking the account once failures pile up.
Persisted rather than kept only in the in-memory window so that a lockout survives a
restart.
Args:
db: The database session.
user: The user who failed to sign in.
"""
user.failed_logins += 1
lockout = lockout_for(user.failed_logins)
if lockout is not None:
user.locked_until = datetime.utcnow().replace(microsecond=0) + lockout
await db.commit()
async def record_login_success(*, db: AsyncSession, user: models.AuthUser) -> None:
"""Note a successful sign-in and clear any lockout.
Args:
db: The database session.
user: The user who signed in.
"""
user.failed_logins = 0
user.locked_until = None
user.last_login = datetime.utcnow().replace(microsecond=0)
await db.commit()
def is_locked(user: models.AuthUser) -> bool:
"""Check whether an account is currently locked out.
Args:
user: The user to check.
Returns:
bool: True if sign-in should be refused.
"""
if user.locked_until is None:
return False
return user.locked_until > datetime.utcnow()
+16 -1
View File
@@ -169,6 +169,21 @@ def setup_db(connection_url: URL) -> None:
__db.connect()
def get_engine() -> AsyncEngine:
"""Get the connected engine.
Raises:
RuntimeError: If the database has not been set up.
Returns:
AsyncEngine: The engine.
"""
if __db is None or __db.engine is None:
raise RuntimeError("DB is not setup.")
return __db.engine
async def backup_global_db(num_backups: int = 5) -> Path | None:
"""Backup the database and rotate existing backups.
@@ -219,7 +234,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
scheduler.daily(datetime.time(hour=0, minute=0, second=0), _backup_task) # type: ignore[arg-type]
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async def get_db_session() -> AsyncGenerator[AsyncSession]:
"""Get a DB session to be used with FastAPI's dependency system.
Yields:
+128 -11
View File
@@ -1,7 +1,6 @@
"""SQLAlchemy data models."""
from datetime import datetime
from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.ext.asyncio import AsyncAttrs
@@ -33,9 +32,9 @@ class Vendor(Base):
name: Mapped[str] = mapped_column(String(64))
empty_spool_weight: Mapped[float | None] = mapped_column(comment="The weight of an empty spool.")
comment: Mapped[str | None] = mapped_column(String(1024))
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
filaments: Mapped[list[Filament]] = relationship(back_populates="vendor")
external_id: Mapped[str | None] = mapped_column(String(256))
extra: Mapped[list["VendorField"]] = relationship(
extra: Mapped[list[VendorField]] = relationship(
back_populates="vendor",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
@@ -49,8 +48,8 @@ class Filament(Base):
registered: Mapped[datetime] = mapped_column()
name: Mapped[str | None] = mapped_column(String(64))
vendor_id: Mapped[int | None] = mapped_column(ForeignKey("vendor.id"))
vendor: Mapped[Optional["Vendor"]] = relationship(back_populates="filaments")
spools: Mapped[list["Spool"]] = relationship(back_populates="filament")
vendor: Mapped[Vendor | None] = relationship(back_populates="filaments")
spools: Mapped[list[Spool]] = relationship(back_populates="filament")
material: Mapped[str | None] = mapped_column(String(64))
price: Mapped[float | None] = mapped_column()
density: Mapped[float] = mapped_column()
@@ -65,7 +64,7 @@ class Filament(Base):
multi_color_hexes: Mapped[str | None] = mapped_column(String(128))
multi_color_direction: Mapped[str | None] = mapped_column(String(16))
external_id: Mapped[str | None] = mapped_column(String(256))
extra: Mapped[list["FilamentField"]] = relationship(
extra: Mapped[list[FilamentField]] = relationship(
back_populates="filament",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
@@ -81,7 +80,7 @@ class Spool(Base):
last_used: Mapped[datetime | None] = mapped_column()
price: Mapped[float | None] = mapped_column()
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"))
filament: Mapped["Filament"] = relationship(back_populates="spools")
filament: Mapped[Filament] = relationship(back_populates="spools")
initial_weight: Mapped[float | None] = mapped_column()
spool_weight: Mapped[float | None] = mapped_column()
used_weight: Mapped[float] = mapped_column()
@@ -89,7 +88,7 @@ class Spool(Base):
lot_nr: Mapped[str | None] = mapped_column(String(64))
comment: Mapped[str | None] = mapped_column(String(1024))
archived: Mapped[bool | None] = mapped_column()
extra: Mapped[list["SpoolField"]] = relationship(
extra: Mapped[list[SpoolField]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
@@ -108,7 +107,7 @@ class VendorField(Base):
__tablename__ = "vendor_field"
vendor_id: Mapped[int] = mapped_column(ForeignKey("vendor.id"), primary_key=True, index=True)
vendor: Mapped["Vendor"] = relationship(back_populates="extra")
vendor: Mapped[Vendor] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
@@ -117,7 +116,7 @@ class FilamentField(Base):
__tablename__ = "filament_field"
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"), primary_key=True, index=True)
filament: Mapped["Filament"] = relationship(back_populates="extra")
filament: Mapped[Filament] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
@@ -126,6 +125,124 @@ class SpoolField(Base):
__tablename__ = "spool_field"
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), primary_key=True, index=True)
spool: Mapped["Spool"] = relationship(back_populates="extra")
spool: Mapped[Spool] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
# The authentication tables below are all prefixed `auth_`, because `user` and `session`
# are reserved words in PostgreSQL and MySQL and quoting them at every call site is a
# footgun not worth taking.
#
# None of them declare a relationship(). Auth lookups are single-row queries by an
# indexed column, so there is nothing to eager-load; a `sessions` collection on AuthUser
# would fetch every session row on every login for no benefit, and lazy-loading one
# under asyncio raises MissingGreenlet. Related rows are queried and deleted explicitly.
#
# Enum-valued columns are stored as String, matching `multi_color_direction` above --
# 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.
#
# Multi-tenancy seam (see AUTHENTICATION_PLAN.md): every table uses an integer surrogate
# primary key so a tenant_id foreign key is a pure add-column later. Uniqueness is
# enforced by unique *indexes* rather than named UNIQUE constraints, so
# `auth_user.username` can become a composite (tenant_id, username) index without having
# to drop a constraint whose generated name differs per dialect. `auth_api_key.prefix`
# stays globally unique even then: it is a lookup key, not a name.
class AuthUser(Base):
"""A user account. Used from phase 1."""
__tablename__ = "auth_user"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(64), index=True, unique=True, comment="Stored lowercased.")
display_name: Mapped[str | None] = mapped_column(String(128))
email: Mapped[str | None] = mapped_column(String(256), comment="Used for OIDC matching only, never delivery.")
password_hash: Mapped[str | None] = mapped_column(Text(), comment="Null for OIDC- or certificate-only accounts.")
level: Mapped[str] = mapped_column(String(16), comment="read, edit or manage. See spoolman.auth.levels.")
is_admin: Mapped[bool] = mapped_column()
is_owner: Mapped[bool] = mapped_column()
is_active: Mapped[bool] = mapped_column()
must_change_password: Mapped[bool] = mapped_column()
totp_secret: Mapped[str | None] = mapped_column(Text(), comment="Fernet-encrypted. Phase 4.")
totp_enabled: Mapped[bool] = mapped_column()
oidc_issuer: Mapped[str | None] = mapped_column(String(256), comment="Phase 3.")
oidc_subject: Mapped[str | None] = mapped_column(String(256), comment="Phase 3.")
failed_logins: Mapped[int] = mapped_column()
locked_until: Mapped[datetime | None] = mapped_column()
registered: Mapped[datetime] = mapped_column()
last_login: Mapped[datetime | None] = mapped_column()
class AuthSession(Base):
"""A server-side login session. Used from phase 1.
Sessions are opaque random tokens stored as their SHA-256 digest rather than JWTs,
so that they can be listed and revoked.
"""
__tablename__ = "auth_session"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), index=True, unique=True, comment="SHA-256 hex of the token.")
created: Mapped[datetime] = mapped_column()
expires: Mapped[datetime] = mapped_column()
last_seen: Mapped[datetime] = mapped_column()
remember: Mapped[bool] = mapped_column()
user_agent: Mapped[str | None] = mapped_column(String(256))
ip: Mapped[str | None] = mapped_column(String(64))
class AuthApiKey(Base):
"""A machine credential. Table created in phase 1, used from phase 2."""
__tablename__ = "auth_api_key"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
name: Mapped[str] = mapped_column(String(64))
level: Mapped[str] = mapped_column(String(16))
prefix: Mapped[str] = mapped_column(String(16), index=True, unique=True, comment="Indexes the row at lookup.")
key_hash: Mapped[str] = mapped_column(Text())
created: Mapped[datetime] = mapped_column()
expires: Mapped[datetime | None] = mapped_column()
last_used: Mapped[datetime | None] = mapped_column()
revoked: Mapped[bool] = mapped_column()
class AuthAuditLog(Base):
"""An audit trail entry. Table created in phase 1, used from phase 2."""
__tablename__ = "auth_audit_log"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
date: Mapped[datetime] = mapped_column(index=True)
event: Mapped[str] = mapped_column(String(64))
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("auth_user.id"))
actor_kind: Mapped[str] = mapped_column(String(16))
target: Mapped[str | None] = mapped_column(String(128))
ip: Mapped[str | None] = mapped_column(String(64))
user_agent: Mapped[str | None] = mapped_column(String(256))
detail: Mapped[str | None] = mapped_column(Text(), comment="JSON object.")
class AuthCertificate(Base):
"""An mTLS client certificate mapped to a user. Table created in phase 1, used from phase 5."""
__tablename__ = "auth_certificate"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
name: Mapped[str] = mapped_column(String(64))
fingerprint_sha256: Mapped[str] = mapped_column(
String(95),
index=True,
unique=True,
comment="Colon-separated uppercase hex, 95 chars.",
)
subject_dn: Mapped[str] = mapped_column(String(512))
created: Mapped[datetime] = mapped_column()
last_used: Mapped[datetime | None] = mapped_column()
+2 -2
View File
@@ -3,7 +3,7 @@
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC, datetime
import sqlalchemy
from sqlalchemy import case, func
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
def utc_timezone_naive(dt: datetime) -> datetime:
"""Convert a datetime object to UTC and remove timezone info."""
return dt.astimezone(tz=timezone.utc).replace(tzinfo=None)
return dt.astimezone(tz=UTC).replace(tzinfo=None)
async def create(
+159
View File
@@ -486,3 +486,162 @@ def is_legacy_client_enabled() -> bool:
raise ValueError(
f"Failed to parse SPOOLMAN_LEGACY_CLIENT variable: Unknown value '{legacy_client}'.",
)
#
# Authentication.
#
# All of the below are opt-in: with SPOOLMAN_AUTH_ENABLED unset, the request path is
# identical to an instance built before authentication existed.
#
def _parse_bool(name: str, default: str) -> bool:
"""Parse a TRUE/FALSE/1/0 environment variable.
The older boolean accessors in this module each inline this logic with a bespoke
error message that users quote in bug reports, so they are deliberately left alone.
New variables use this helper.
Args:
name: The environment variable to read.
default: The value to assume when the variable is unset.
Raises:
ValueError: If the variable is set to something other than TRUE/FALSE/1/0.
Returns:
bool: The parsed value.
"""
value = os.getenv(name, default).upper()
if value in {"FALSE", "0"}:
return False
if value in {"TRUE", "1"}:
return True
raise ValueError(f"Failed to parse {name} variable: Unknown value '{value}'.")
def _read_env_or_file(name: str) -> str | None:
"""Read a secret from ``<name>_FILE`` if set, falling back to ``<name>``.
Follows the same two-step convention as :func:`get_password`, with one deliberate
difference: the file contents are stripped. A key or token file is almost always
produced by a shell redirect, which appends a newline, and an unstripped trailing
newline would silently yield a different secret than the one the operator wrote.
Args:
name: The base environment variable name, without the ``_FILE`` suffix.
Raises:
ValueError: If the file is missing or cannot be read.
Returns:
Optional[str]: The secret, or None if neither variable is set.
"""
file_path = os.getenv(name + "_FILE")
if file_path is not None:
file = Path(file_path)
if not file.exists() or not file.is_file():
raise ValueError(
f'Failed to parse {name}_FILE variable: File "{file_path}" does not exist.',
)
try:
with file.open(encoding="utf-8") as f:
return f.read().strip()
except OSError as exc:
raise ValueError(
f'Failed to parse {name}_FILE variable: Failed to read from file "{file_path}": {exc!s}.',
) from exc
return os.getenv(name)
def is_auth_enabled() -> bool:
"""Get whether authentication is enforced.
Returns False if no environment variable was set, which keeps zero-config boot and
every existing integration working untouched.
Returns:
bool: Whether authentication is enabled.
"""
return _parse_bool("SPOOLMAN_AUTH_ENABLED", "FALSE")
def get_secret_key() -> str | None:
"""Get the configured server secret key.
Returns None when neither SPOOLMAN_SECRET_KEY nor SPOOLMAN_SECRET_KEY_FILE is set,
in which case a key is generated and persisted in the data directory instead. See
:mod:`spoolman.auth.secret`.
Returns:
Optional[str]: The secret key, or None if unset.
"""
return _read_env_or_file("SPOOLMAN_SECRET_KEY")
def get_trusted_proxies() -> list[str]:
"""Get the reverse proxies whose forwarding headers may be believed.
Accepts a comma-separated list of IP addresses or CIDR networks. Empty by default,
which means X-Forwarded-For and X-Forwarded-Proto are never trusted.
Returns:
list[str]: The trusted proxy addresses or networks.
"""
proxies = os.getenv("SPOOLMAN_TRUSTED_PROXIES", "")
return [entry.strip() for entry in proxies.split(",") if entry.strip()]
def is_metrics_public() -> bool:
"""Get whether the metrics endpoint stays reachable without credentials.
Only consulted when authentication is enabled; with authentication off, metrics are
public regardless.
Returns:
bool: Whether metrics are public.
"""
return _parse_bool("SPOOLMAN_METRICS_PUBLIC", "FALSE")
def get_metrics_token() -> str | None:
"""Get the shared token that grants access to the metrics endpoint.
Returns:
Optional[str]: The metrics token, or None if unset.
"""
return _read_env_or_file("SPOOLMAN_METRICS_TOKEN")
def get_cookie_secure_override() -> bool | None:
"""Get the operator's override for the Secure flag on session cookies.
Defaults to AUTO, which derives the flag from the request scheme. An override is
needed only for proxies that do not set X-Forwarded-Proto. Guessing True on a
plain-HTTP LAN deployment would make the browser discard the cookie and break login
with no visible error, so the automatic path never assumes HTTPS.
Raises:
ValueError: If set to something other than AUTO/TRUE/FALSE/1/0.
Returns:
Optional[bool]: True or False to force the flag, None to derive it.
"""
value = os.getenv("SPOOLMAN_COOKIE_SECURE", "AUTO").upper()
if value == "AUTO":
return None
if value in {"FALSE", "0"}:
return False
if value in {"TRUE", "1"}:
return True
raise ValueError(f"Failed to parse SPOOLMAN_COOKIE_SECURE variable: Unknown value '{value}'.")
+8
View File
@@ -15,3 +15,11 @@ class ItemCreateError(Exception):
class SpoolMeasureError(Exception):
pass
class AuthenticationRequiredError(Exception):
"""No usable credential was presented. Mapped to HTTP 401."""
class PermissionDeniedError(Exception):
"""A credential was presented but does not permit the operation. Mapped to HTTP 403."""
+2 -2
View File
@@ -38,7 +38,7 @@ async def flatten_sqlalchemy_object(obj: models.Base, parent_key: str = "", sep:
return fields
async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: SupportsWrite[str]) -> None:
"""Export a list of objects as CSV to a writer. Nested objects are flattened with dot-separated keys."""
# Flatten each object and get all column names
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
@@ -57,7 +57,7 @@ async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: "Suppor
csv_writer.writerow(flattened_obj)
async def dump_as_json(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
async def dump_as_json(sqlalchemy_objects: Iterable[models.Base], writer: SupportsWrite[str]) -> None:
"""Export a list of objects as JSON to a writer. Nested objects are flattened with dot-separated keys."""
# Flatten each object and get all column names
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
+112 -7
View File
@@ -1,12 +1,13 @@
"""Main entrypoint to the server."""
import hmac
import logging
import subprocess
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import PlainTextResponse, RedirectResponse, Response
@@ -15,6 +16,8 @@ from scheduler.asyncio.scheduler import Scheduler
from spoolman import env, externaldb
from spoolman.api.v1.router import app as v1_app
from spoolman.auth import audit, secret
from spoolman.auth.levels import Level
from spoolman.client import SinglePageApplication
from spoolman.database import database
from spoolman.prometheus.metrics import registry
@@ -57,6 +60,54 @@ app.add_middleware(GZipMiddleware)
app.mount(env.get_base_path() + "/api/v1", v1_app)
async def metrics_access_permitted(request: Request) -> bool:
"""Check whether a caller may read the metrics endpoint.
Metrics stay public while authentication is off, so existing Prometheus scrape
configs keep working untouched. With authentication on, a caller needs either the
shared SPOOLMAN_METRICS_TOKEN or an API key, unless the operator opts back out with
SPOOLMAN_METRICS_PUBLIC.
Both, rather than one or the other. The shared token is a deployment-level secret
that needs no database and no account, which is what an operator scraping their own
instance wants and what survives the database being down. An API key is attributable
and revocable through the interface, which is what you want when the scraper belongs
to somebody else. Neither is checked before the other for any security reason; the
token is simply free to check.
Only API keys are accepted, not session cookies, so that a signed-in browser tab
does not silently make the endpoint readable. See
:func:`spoolman.auth.dependencies.api_key_principal`.
Args:
request: The incoming request.
Returns:
bool: Whether to serve the metrics.
"""
if not env.is_auth_enabled() or env.is_metrics_public():
return True
presented = request.headers.get("x-api-key", "")
if not presented:
authorization = request.headers.get("authorization", "")
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer":
presented = value.strip()
if not presented:
return False
expected = env.get_metrics_token()
if expected and hmac.compare_digest(presented, expected):
return True
from spoolman.auth.dependencies import api_key_principal # noqa: PLC0415
principal = await api_key_principal(request)
return principal is not None and principal.covers(Level.READ)
# WA for prometheus /metrics bind with SinglePageApp at root
@app.get(
env.get_base_path() + "/metrics",
@@ -66,9 +117,11 @@ app.mount(env.get_base_path() + "/api/v1", v1_app)
"Get app metrics for prometheusIf enabled SPOOLMAN_METRICS_ENABLED returned metrics by Spools and Filaments"
),
)
def get_metrics() -> bytes:
async def get_metrics(request: Request) -> Response:
"""Return prometheus metrics."""
return generate_latest(registry)
if not await metrics_access_permitted(request):
return PlainTextResponse("Unauthorized", status_code=401)
return PlainTextResponse(generate_latest(registry))
base_path = env.get_base_path()
@@ -126,10 +179,19 @@ else:
def add_cors_middleware() -> None:
"""Add CORS middleware to the FastAPI app based on environment settings."""
origins = []
origins: list[str] = []
origin_regex: str | None = None
if env.is_debug_mode():
logger.warning("Running in debug mode, allowing all origins.")
origins = ["*"]
# Reflect whatever Origin asked, rather than sending a literal "*".
#
# allow_credentials=True below makes Starlette emit "*" verbatim, and browsers
# reject that outright for any request carrying credentials. Nothing noticed
# while the API was anonymous, but it silently breaks cookie-authenticated
# requests from the Vite dev server on port 5174 to the backend on 8000, which
# is the whole point of debug mode. Echoing the origin is what the wildcard was
# already trying to express.
logger.warning("Running in debug mode, reflecting all origins.")
origin_regex = ".*"
elif env.is_cors_defined():
cors_origins = env.get_cors_origin()
if cors_origins:
@@ -138,12 +200,13 @@ def add_cors_middleware() -> None:
else:
logger.warning("CORS origins are not defined, no CORS will be applied.")
if not origins:
if not origins and origin_regex is None:
return
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_origin_regex=origin_regex,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -154,6 +217,39 @@ def add_cors_middleware() -> None:
add_cors_middleware()
def log_auth_status() -> None:
"""Report the authentication configuration, and warn about combinations that surprise."""
if not env.is_auth_enabled():
return
from collections import Counter # noqa: PLC0415
from spoolman.api.v1.router import ROUTE_LEVELS # noqa: PLC0415
counts = Counter(str(level) for level in ROUTE_LEVELS.values())
logger.info(
"Authentication is enabled. %d API routes are gated (read: %d, edit: %d, manage: %d).",
len(ROUTE_LEVELS),
counts.get("read", 0),
counts.get("edit", 0),
counts.get("manage", 0),
)
if env.is_legacy_client_enabled():
# The React client has no sign-in screen and no cookie or CSRF handling, so it
# will fail every request with no way for the user to authenticate.
logger.warning(
"SPOOLMAN_LEGACY_CLIENT is set together with authentication. The legacy client "
"does not support signing in and will not work. Unset one of the two.",
)
if env.is_metrics_enabled() and not env.is_metrics_public() and not env.get_metrics_token():
logger.warning(
"Metrics are enabled but no SPOOLMAN_METRICS_TOKEN is set, so /metrics will "
"reject every scrape. Set a token, or set SPOOLMAN_METRICS_PUBLIC=TRUE.",
)
def add_file_logging() -> None:
"""Add file logging to the root logger."""
# Define a file logger with log rotation
@@ -188,6 +284,12 @@ async def startup() -> None:
logger.info("Using logs directory: %s", env.get_logs_dir().resolve())
logger.info("Using backups directory: %s", env.get_backups_dir().resolve())
# Resolve the secret key before the database work. It needs a writable data
# directory but no schema, so failing here gives an actionable error ahead of the
# migration subprocess. Guarded so an auth-disabled instance never grows the file.
if env.is_auth_enabled():
secret.ensure_secret_key()
logger.info("Setting up database...")
database.setup_db(database.get_connection_url())
@@ -202,6 +304,9 @@ async def startup() -> None:
schedule = Scheduler()
database.schedule_tasks(schedule)
externaldb.schedule_tasks(schedule)
audit.schedule_tasks(schedule)
log_auth_status()
logger.info("Startup complete.")
+12
View File
@@ -73,3 +73,15 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
register_setting("locations", SettingType.ARRAY, json.dumps([]))
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
# Never store a secret in a setting. Settings are readable through GET /setting/ and
# every write is broadcast to all websocket subscribers, so anything put here is public
# to every client that can read the API. Deployment secrets belong in environment
# variables; see spoolman/env.py.
register_setting("auth_anonymous_read", SettingType.BOOLEAN, json.dumps(obj=False))
# How many days of audit log to keep. Zero keeps everything. A setting rather than an
# environment variable because retention is a policy an administrator revises, not a
# deployment parameter, and changing it should not need a restart. Not a secret: it says
# nothing about what is in the log.
register_setting("auth_audit_retention_days", SettingType.NUMBER, json.dumps(90))
@@ -0,0 +1,24 @@
version: '3.8'
# The authentication suite. Deliberately a single SQLite variant rather than another
# pass over all four databases: nothing in spoolman/auth is database-specific, so
# re-running the whole regression suite with authentication on would quadruple the CI
# bill to re-prove the same thing.
#
# The tester mounts ./tests_auth -- not ./tests -- at the path the image's fixed
# entrypoint collects from, so this compose file runs the auth suite and only the auth
# suite, and the regression runs can never pick these tests up.
services:
spoolman:
image: donkie/spoolman:test
environment:
- SPOOLMAN_LOGGING_LEVEL=INFO
- TZ=Europe/Stockholm
- SPOOLMAN_AUTH_ENABLED=TRUE
tester:
image: donkie/spoolman-tester:latest
volumes:
- ./tests_auth:/tester/tests
environment:
- DB_TYPE=sqlite
depends_on:
- spoolman
+7 -2
View File
@@ -21,12 +21,17 @@ if __name__ == "__main__":
print("Failed to build Spoolman tester!")
sys.exit(1)
# Support input arguments for running only specific tests
# Support input arguments for running only specific tests.
#
# "sqlite-auth" is runnable but not in the default set. It runs a different suite
# (tests_integration/tests_auth) against a server with SPOOLMAN_AUTH_ENABLED, and
# nothing in spoolman/auth is database-specific, so there is no reason to pay for it
# on every run of the four-database regression sweep.
if len(sys.argv) > 1:
targets = sys.argv[1:]
# Check that all targets are valid
for target in targets:
if target not in ["postgres", "sqlite", "mariadb", "cockroachdb"]:
if target not in ["postgres", "sqlite", "mariadb", "cockroachdb", "sqlite-auth"]:
print(f"Unknown target: {target}")
sys.exit(1)
else:
+1 -1
View File
@@ -46,7 +46,7 @@ def pytest_sessionstart(session): # noqa: ARG001, ANN001
response = httpx.get(URL, timeout=1)
response.raise_for_status()
print("pytest: Spoolman now seems to be up!") # noqa: T201
except httpx.HTTPError: # noqa: PERF203
except httpx.HTTPError:
if time.time() - start_time > TIMEOUT:
raise
time.sleep(0.5)
@@ -1,7 +1,7 @@
"""Integration tests for the custom extra fields system."""
import json
from datetime import datetime, timezone
from datetime import UTC, datetime
import httpx
import pytest
@@ -136,7 +136,7 @@ def test_add_datetime_field():
json={
"name": "My datetime field",
"field_type": "datetime",
"default_value": json.dumps(datetime.now(timezone.utc).isoformat()),
"default_value": json.dumps(datetime.now(UTC).isoformat()),
},
)
assert_httpx_success(result)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Integration tests for the Filament API endpoint."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
import httpx
@@ -70,7 +70,7 @@ def test_add_filament(random_vendor: dict[str, Any]):
)
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(filament["registered"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(filament["registered"])).total_seconds())
assert diff < 60
# Clean up
+4 -4
View File
@@ -1,6 +1,6 @@
"""Integration tests for the Spool API endpoint."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
import httpx
@@ -69,7 +69,7 @@ def test_add_spool_remaining_weight(random_filament: dict[str, Any]):
)
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["registered"])).total_seconds())
assert diff < 60
# Clean up
@@ -261,7 +261,7 @@ def test_add_spool_initial_weight(random_filament: dict[str, Any]):
)
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["registered"])).total_seconds())
assert diff < 60
# Clean up
@@ -332,7 +332,7 @@ def test_add_spool_spool_weight(random_filament: dict[str, Any]):
)
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["registered"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["registered"])).total_seconds())
assert diff < 60
# Clean up
@@ -1,6 +1,6 @@
"""Integration tests for the Spool API endpoint."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
import httpx
@@ -45,11 +45,11 @@ def test_measure_spool(random_filament: dict[str, Any], measurement: float):
expected_remaining = max(measurement - spool_weight, 0)
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
# Verify that first_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
# Clean up
@@ -143,11 +143,11 @@ def test_measure_spool_sequence(random_filament: dict[str, Any], measurements: l
expected_remaining = max(m - spool_weight, 0)
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
# Verify that first_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
current_weight = current_weight - expected_use
@@ -195,11 +195,11 @@ def test_measure_spool_empty(random_empty_filament: dict[str, Any], measurements
expected_remaining = max(m - spool_weight, 0)
assert spool["remaining_weight"] == pytest.approx(expected_remaining)
# Verify that first_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
current_weight = current_weight - expected_use
+4 -4
View File
@@ -2,7 +2,7 @@
import asyncio
import math
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
import httpx
@@ -43,11 +43,11 @@ def test_use_spool_weight(random_filament: dict[str, Any], use_weight: float):
assert spool["remaining_weight"] == pytest.approx(min(max(start_weight - use_weight, 0), filament_net_weight))
# Verify that first_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["first_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["first_used"])).total_seconds())
assert diff < 60
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
# Clean up
@@ -93,7 +93,7 @@ def test_use_spool_length(random_filament: dict[str, Any], use_length: float):
assert spool["first_used"] == "2023-01-01T00:00:00Z"
# Verify that last_used has been updated
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(spool["last_used"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(spool["last_used"])).total_seconds())
assert diff < 60
# Clean up
+2 -2
View File
@@ -1,6 +1,6 @@
"""Integration tests for the Vendor API endpoint."""
from datetime import datetime, timezone
from datetime import UTC, datetime
import httpx
@@ -37,7 +37,7 @@ def test_add_vendor():
)
# Verify that registered happened almost now (within 1 minute)
diff = abs((datetime.now(tz=timezone.utc) - datetime.fromisoformat(vendor["registered"])).total_seconds())
diff = abs((datetime.now(tz=UTC) - datetime.fromisoformat(vendor["registered"])).total_seconds())
assert diff < 60
# Clean up
Whitespace-only changes.
+289
View File
@@ -0,0 +1,289 @@
"""Fixtures for the authentication integration tests.
This suite is deliberately a sibling of ``tests_integration/tests`` rather than a
subdirectory of it. The tester image's entrypoint is fixed to ``pytest --exitfirst
tests``, and the auth compose file mounts *this* directory at ``/tester/tests``. That
means the regression suite and the auth suite can never be collected in the same run:
the regression suite talks to a server with authentication off and would fail wholesale
against an authenticated one.
The URL/DbType/session-start block below is duplicated from ``tests/conftest.py`` on
purpose. Nothing there can be reused: every fixture in that file drives bare ``httpx``
module functions with no credentials, which against an authenticated server is exactly
the thing under test.
Two pieces of shared state matter for the whole run:
* The instance is claimed exactly once, by the ``setup_response`` fixture. Any test that
needs a signed-in caller depends on it, so ``test_setup.py`` can assert on the *first*
call's response no matter which file pytest happens to collect first.
* The sign-in throttle in ``spoolman.auth.ratelimit`` allows 20 failed attempts per
client address per 15 minutes, and every test here shares one address. The suite spends
roughly 8. Keep new failing-login assertions well under the remaining budget or later
tests will start seeing 429 where they expect 401.
"""
import os
import time
from collections.abc import Iterator
from contextlib import contextmanager
from enum import StrEnum
import httpx
import pytest
TIMEOUT = 30
HOST = "spoolman:" + os.environ.get("SPOOLMAN_PORT", "8000")
URL = f"http://{HOST}"
API_URL = f"{URL}/api/v1"
WS_API_URL = f"ws://{HOST}/api/v1"
SESSION_COOKIE = "spoolman_session"
CSRF_COOKIE = "spoolman_csrf"
CSRF_HEADER = "X-CSRF-Token"
# Deliberately mixed case: usernames are normalised to lowercase on the way in, and
# test_setup asserts that the stored form came back lowercased.
OWNER_USERNAME = "Owner"
OWNER_USERNAME_STORED = "owner"
OWNER_PASSWORD = "correct-horse-battery-staple"
OWNER_DISPLAY_NAME = "The Owner"
# Every failed sign-in answers with this exact text, whatever went wrong.
INVALID_CREDENTIALS = "Incorrect username or password."
# Password for accounts the tests create for themselves. Long enough to clear the
# server's ten character minimum.
READER_PASSWORD = "reader-password-1"
# Websocket close codes, from spoolman.auth.dependencies.
WS_UNAUTHENTICATED = 4401
WS_FORBIDDEN = 4403
class DbType(StrEnum):
"""Enum for database types."""
SQLITE = "sqlite"
POSTGRES = "postgres"
MYSQL = "mysql"
COCKROACHDB = "cockroachdb"
def get_db_type() -> DbType:
"""Return the database type from environment variables."""
env_db_type = os.environ.get("DB_TYPE")
if env_db_type is None:
raise RuntimeError("DB_TYPE environment variable not set")
try:
db_type = DbType(env_db_type)
except ValueError as e:
raise RuntimeError(f"Unknown database type: {env_db_type}") from e
return db_type
def pytest_sessionstart(session): # noqa: ARG001, ANN001
"""Wait for the server to start up."""
get_db_type()
start_time = time.time()
while True:
try:
print("pytest: Waiting for spoolman to be available...") # noqa: T201
response = httpx.get(URL, timeout=1)
response.raise_for_status()
print("pytest: Spoolman now seems to be up!") # noqa: T201
except httpx.HTTPError:
if time.time() - start_time > TIMEOUT:
raise
time.sleep(0.5)
else:
break
def new_client() -> httpx.Client:
"""Return a client with no credentials, rooted at the v1 API."""
return httpx.Client(base_url=API_URL, timeout=TIMEOUT)
def attach_csrf(client: httpx.Client) -> str:
"""Copy the readable CSRF cookie into the header the server checks.
This is what a browser client does by hand; the session cookie rides along
automatically, so without this step every non-GET would be rejected as CSRF.
Args:
client: The signed-in client to modify.
Returns:
str: The CSRF token that was attached.
"""
csrf = client.cookies.get(CSRF_COOKIE)
if not csrf:
pytest.fail("No CSRF cookie was set; the client is not signed in.")
client.headers[CSRF_HEADER] = csrf
return csrf
def login(
*,
username: str = OWNER_USERNAME,
password: str = OWNER_PASSWORD,
remember: bool = False,
) -> tuple[httpx.Client, httpx.Response]:
"""Sign in and return a client carrying the resulting session.
Args:
username: The username to present.
password: The password to present.
remember: Whether to ask for a long-lived session.
Returns:
tuple: The client, and the raw login response.
"""
client = new_client()
response = client.post(
"/auth/login",
json={"username": username, "password": password, "remember": remember},
)
if response.is_success:
attach_csrf(client)
return client, response
@contextmanager
def signed_in(
*,
username: str = OWNER_USERNAME,
password: str = OWNER_PASSWORD,
remember: bool = False,
) -> Iterator[tuple[httpx.Client, httpx.Response]]:
"""Sign in for the duration of a block, closing the client afterwards.
A plain ``with client:`` will not do here: httpx marks a client as opened on its
first request, and ``login`` has already made one, so entering it again raises.
Args:
username: The username to present.
password: The password to present.
remember: Whether to ask for a long-lived session.
Yields:
tuple: The client, and the raw login response.
"""
client, response = login(username=username, password=password, remember=remember)
try:
yield client, response
finally:
client.close()
def assert_code(response: httpx.Response, code: int) -> None:
"""Assert that a response has the expected status code."""
if response.status_code != code:
pytest.fail(f"Expected {code}, got {response.status_code}: {response.text}")
@pytest.fixture(scope="session")
def setup_response() -> httpx.Response:
"""Claim the instance, once per run.
Owning the only call to POST /auth/setup is what lets test_setup make assertions
about the very first claim while every other file is free to assume an owner exists.
"""
return httpx.post(
f"{API_URL}/auth/setup",
json={
"username": OWNER_USERNAME,
"password": OWNER_PASSWORD,
"display_name": OWNER_DISPLAY_NAME,
},
timeout=TIMEOUT,
)
@pytest.fixture(scope="session")
def owner(setup_response: httpx.Response) -> httpx.Client:
"""Return a client signed in as the owner, at the manage level.
Signs in afresh rather than reusing the setup response's cookies, so the session this
fixture holds is independent of anything test_setup does. It also survives an account
lockout, since a lockout only blocks new sign-ins and never invalidates a live
session -- which is what lets test_login lock the account mid-run.
"""
assert_code(setup_response, 201)
client, response = login()
assert_code(response, 200)
return client
@pytest.fixture
def anonymous() -> httpx.Client:
"""Return a client with no credentials at all."""
with new_client() as client:
yield client
def create_user(
admin: httpx.Client,
username: str,
*,
level: str = "read",
is_admin: bool = False,
password: str | None = None,
must_change_password: bool = False,
) -> tuple[int, str]:
"""Create an account and return its ID and password.
Phase 2's answer to what tests_auth could not do before: make a second account
without a command line. Every caller picks a username of its own, because the suite
shares one server and one database for the whole run.
Args:
admin: A client signed in as an administrator.
username: The username to create.
level: The permission level to grant.
is_admin: Whether the account administers other users.
password: The password to set, or None to have one generated.
must_change_password: Whether to force a change at first sign-in.
Returns:
tuple: The new user's ID, and the password it can sign in with.
"""
response = admin.post(
"/auth/user",
json={
"username": username,
"password": password,
"level": level,
"is_admin": is_admin,
"must_change_password": must_change_password,
},
)
assert_code(response, 201)
body = response.json()
# The server returns a generated password exactly once, here. If one was supplied it
# is deliberately absent from the response, so fall back to what the caller sent.
return body["user"]["id"], body.get("password") or (password or "")
@pytest.fixture
def reader(owner: httpx.Client) -> Iterator[httpx.Client]:
"""Return a client signed in as a read-level, non-administrator user.
This is what test_levels.py's docstring said phase 1 could not build. It exists for
exactly one purpose: proving that a low-level *user* is refused, which the anonymous
reader could only approximate.
"""
username = f"reader-{int(time.time() * 1000)}"
create_user(owner, username, level="read", password=READER_PASSWORD)
client, response = login(username=username, password=READER_PASSWORD)
assert_code(response, 200)
try:
yield client
finally:
client.close()
@@ -0,0 +1,129 @@
"""Integration tests: the auth_anonymous_read setting.
An operator who wants a read-only public dashboard turns this on. It grants an
unauthenticated caller a principal at the read level -- nothing more -- so it must open
exactly the read routes and no others.
The setting is a database row rather than an environment variable, which means it can be
flipped at runtime by anyone at manage level. That makes 'turning it off again really
does close the door' a property worth asserting, not assuming.
"""
import httpx
import pytest
from .conftest import assert_code
SETTING = "/setting/auth_anonymous_read"
@pytest.fixture
def anonymous_read_on(owner: httpx.Client):
"""Turn the setting on for one test, and off again afterwards.
The teardown is not optional: leaving it on would silently defeat every 401
assertion in test_unauthenticated.py, which collects after this file.
"""
assert_code(owner.post(SETTING, json="true"), 200)
try:
yield
finally:
assert_code(owner.post(SETTING, json="false"), 200)
def test_setting_defaults_to_off(owner: httpx.Client, anonymous: httpx.Client) -> None:
"""With the setting off an unauthenticated read is refused."""
result = owner.get(SETTING)
assert_code(result, 200)
assert result.json()["value"] in {"false", "null"}
assert_code(anonymous.get("/spool"), 401)
assert_code(anonymous.get("/filament"), 401)
def test_anonymous_read_opens_read_routes(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
"""With the setting on, read routes answer without any credential."""
assert_code(anonymous.get("/spool"), 200)
assert_code(anonymous.get("/filament"), 200)
assert_code(anonymous.get("/vendor"), 200)
assert_code(anonymous.get("/setting/"), 200)
assert_code(anonymous.get("/info"), 200)
def test_anonymous_read_is_advertised_in_config(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
"""A client can discover the mode before deciding whether to prompt for a sign-in."""
result = anonymous.get("/auth/config")
assert_code(result, 200)
assert result.json()["anonymous_read"] is True
def test_anonymous_session_reports_anonymous(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
"""The caller is anonymous, not authenticated, and holds read level only."""
result = anonymous.get("/auth/session")
assert_code(result, 200)
info = result.json()
assert info["authenticated"] is False
assert info["anonymous"] is True
assert info["level"] == "read"
assert info["is_admin"] is False
assert info["is_owner"] is False
def test_anonymous_read_does_not_open_writes(anonymous_read_on: None, anonymous: httpx.Client) -> None: # noqa: ARG001
"""Nothing above read opens up.
The status is 403 rather than 401: a principal was established, it simply does not
cover the level. See the note on the export test below -- the same reasoning applies
here, and the same caveat about what a client can do with the answer.
"""
assert_code(anonymous.post("/spool", json={"filament_id": 1}), 403)
assert_code(anonymous.post("/vendor", json={"name": "nope"}), 403)
assert_code(anonymous.post("/filament", json={"density": 1.25, "diameter": 1.75}), 403)
assert_code(anonymous.patch("/spool/1", json={"comment": "nope"}), 403)
assert_code(anonymous.put("/spool/1/use", json={"use_weight": 1}), 403)
assert_code(anonymous.delete("/spool/1"), 403)
assert_code(anonymous.post("/backup"), 403)
assert_code(anonymous.post("/setting/currency", json='"SEK"'), 403)
def test_anonymous_read_does_not_open_the_exports(
anonymous_read_on: None, # noqa: ARG001
anonymous: httpx.Client,
) -> None:
"""The bulk exports stay shut, which is the whole reason they sit at manage level.
'Read-only public dashboard' must not mean 'download the entire database', so this is
the assertion that gives the manage level on /export/* its purpose.
The answer is 403. AUTHENTICATION_PLAN.md describes these as endpoints that expose
the database in bulk and therefore refuse the anonymous reader outright, which
``require_level(allow_anonymous=False)`` exists to express -- but no route passes
that argument today, so the refusal comes from the level check instead. The door is
shut either way; only the status code differs. If the intent is later wired up, this
expectation becomes 401.
"""
assert_code(anonymous.get("/export/spools", params={"fmt": "json"}), 403)
assert_code(anonymous.get("/export/filaments", params={"fmt": "json"}), 403)
assert_code(anonymous.get("/export/vendors", params={"fmt": "json"}), 403)
def test_turning_the_setting_off_closes_the_door(owner: httpx.Client, anonymous: httpx.Client) -> None:
"""The grant is revoked immediately, without a restart or a cache to wait out."""
assert_code(owner.post(SETTING, json="true"), 200)
assert_code(anonymous.get("/spool"), 200)
assert_code(owner.post(SETTING, json="false"), 200)
assert_code(anonymous.get("/spool"), 401)
def test_owner_still_works_while_anonymous_read_is_on(
anonymous_read_on: None, # noqa: ARG001
owner: httpx.Client,
) -> None:
"""A real session is not downgraded to the anonymous principal."""
result = owner.get("/auth/session")
assert_code(result, 200)
info = result.json()
assert info["anonymous"] is False
assert info["level"] == "manage"
assert_code(owner.get("/export/spools", params={"fmt": "json"}), 200)
+305
View File
@@ -0,0 +1,305 @@
"""Integration tests: API keys.
The properties worth defending, in order of how badly they fail if broken:
* A key never outranks the account it belongs to, checked on every request rather than
frozen at creation. :func:`test_key_is_capped_by_owner_level` demotes a user and shows
the key it already issued weakening in the same breath.
* A key cannot manage keys, so a leak cannot be made permanent by minting a replacement.
* A key carries no administrative rights, whoever owns it.
* A key skips CSRF, because it is not an ambient credential -- and a session does not,
which is the half that proves the skip is targeted rather than a hole.
"""
import time
import httpx
from .conftest import (
CSRF_HEADER,
READER_PASSWORD,
assert_code,
create_user,
login,
new_client,
)
KEY_HEADER = "X-API-Key"
def _unique(prefix: str) -> str:
"""Build a name no other test in this run will collide with."""
return f"{prefix}-{int(time.time() * 1000)}"
def _keyed_client(key: str) -> httpx.Client:
"""Return a client that authenticates with an API key and nothing else."""
client = new_client()
client.headers[KEY_HEADER] = key
return client
def test_create_returns_the_secret_once(owner: httpx.Client) -> None:
"""The plaintext key is in the creation response and in no later one."""
response = owner.post("/auth/apikey", json={"name": _unique("once"), "level": "read"})
assert_code(response, 201)
body = response.json()
key = body["key"]
assert key.startswith("spoolman_")
assert body["info"]["prefix"] in key
assert body["info"]["revoked"] is False
assert body["info"]["expired"] is False
listed = owner.get("/auth/apikey")
assert_code(listed, 200)
entry = next(item for item in listed.json() if item["id"] == body["info"]["id"])
# The secret must not survive anywhere in the listing, under any field name.
assert key not in str(entry)
def test_key_authenticates_by_header_and_bearer(owner: httpx.Client) -> None:
"""Both presentation forms are accepted, because clients differ."""
created = owner.post("/auth/apikey", json={"name": _unique("forms"), "level": "read"})
assert_code(created, 201)
key = created.json()["key"]
with _keyed_client(key) as client:
assert_code(client.get("/spool"), 200)
with new_client() as client:
client.headers["Authorization"] = f"Bearer {key}"
assert_code(client.get("/spool"), 200)
def test_unknown_key_is_rejected() -> None:
"""A well-formed but unknown key authenticates nobody."""
with _keyed_client("spoolman_aaaaaaaaaaaa.not-a-real-secret") as client:
assert_code(client.get("/spool"), 401)
# Malformed enough that it never reaches a database lookup at all.
with _keyed_client("not-a-key") as client:
assert_code(client.get("/spool"), 401)
def test_key_skips_csrf_but_a_session_does_not(owner: httpx.Client) -> None:
"""The CSRF requirement applies to ambient credentials only.
A key travels in a header that a cross-site page cannot set, so there is nothing for
a second factor to defend against. A session cookie is attached by the browser
without being asked, so it needs one. Both halves are asserted here: dropping the
header must break the session and must not break the key.
"""
created = owner.post("/auth/apikey", json={"name": _unique("csrf"), "level": "edit"})
assert_code(created, 201)
key = created.json()["key"]
vendor = owner.post("/vendor", json={"name": _unique("csrf-vendor")})
assert_code(vendor, 200)
vendor_id = vendor.json()["id"]
try:
with _keyed_client(key) as client:
# No CSRF header anywhere on this client, and the write still lands.
assert_code(client.patch(f"/vendor/{vendor_id}", json={"comment": "by key"}), 200)
stripped = owner.headers.pop(CSRF_HEADER)
try:
assert_code(owner.patch(f"/vendor/{vendor_id}", json={"comment": "by session"}), 403)
finally:
owner.headers[CSRF_HEADER] = stripped
finally:
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
def test_key_cannot_exceed_its_level(owner: httpx.Client) -> None:
"""A read key is refused an edit route and a manage route."""
created = owner.post("/auth/apikey", json={"name": _unique("readonly"), "level": "read"})
assert_code(created, 201)
key = created.json()["key"]
with _keyed_client(key) as client:
assert_code(client.get("/spool"), 200)
assert_code(client.post("/vendor", json={"name": "should not exist"}), 403)
def test_key_cannot_manage_keys(owner: httpx.Client) -> None:
"""A key cannot mint, list or revoke keys, however senior its owner.
Without this a leaked key is unrevocable in practice: whoever holds it issues
themselves a fresh one before the original is withdrawn.
"""
created = owner.post("/auth/apikey", json={"name": _unique("nomint"), "level": "manage"})
assert_code(created, 201)
key = created.json()["key"]
key_id = created.json()["info"]["id"]
with _keyed_client(key) as client:
assert_code(client.get("/auth/apikey"), 403)
assert_code(client.post("/auth/apikey", json={"name": "child", "level": "read"}), 403)
assert_code(client.post(f"/auth/apikey/{key_id}/revoke"), 403)
assert_code(client.delete(f"/auth/apikey/{key_id}"), 403)
def test_key_carries_no_admin_rights(owner: httpx.Client) -> None:
"""A manage-level key held by the owner still cannot administer users."""
created = owner.post("/auth/apikey", json={"name": _unique("noadmin"), "level": "manage"})
assert_code(created, 201)
key = created.json()["key"]
with _keyed_client(key) as client:
assert_code(client.get("/auth/user"), 403)
assert_code(client.get("/auth/audit"), 403)
# Nor can it change the password of the account it belongs to.
assert_code(
client.post("/auth/password", json={"current_password": "x" * 10, "new_password": "y" * 12}),
403,
)
def test_cannot_issue_above_own_level(owner: httpx.Client) -> None:
"""A read-level user is refused when asking for an edit key.
Refused rather than silently capped: a key quietly issued weaker than asked for is a
support ticket six months later, when a script starts failing for no visible reason.
"""
username = _unique("issuer")
create_user(owner, username, level="read", password=READER_PASSWORD)
client, response = login(username=username, password=READER_PASSWORD)
assert_code(response, 200)
try:
refused = client.post("/auth/apikey", json={"name": _unique("too-strong"), "level": "manage"})
assert_code(refused, 403)
assert "above your own" in refused.json()["message"]
allowed = client.post("/auth/apikey", json={"name": _unique("just-right"), "level": "read"})
assert_code(allowed, 201)
finally:
client.close()
def test_key_is_capped_by_owner_level(owner: httpx.Client) -> None:
"""Demoting a user weakens the keys they already hold, immediately.
This is the property that makes a key safe to hand out: it is not a frozen grant,
it is a pointer at an account whose rights are re-read on every request.
"""
username = _unique("demoted")
user_id, password = create_user(owner, username, level="manage", password=READER_PASSWORD)
client, response = login(username=username, password=password)
assert_code(response, 200)
try:
created = client.post("/auth/apikey", json={"name": _unique("capped"), "level": "manage"})
assert_code(created, 201)
key = created.json()["key"]
key_id = created.json()["info"]["id"]
assert created.json()["info"]["effective_level"] == "manage"
with _keyed_client(key) as keyed:
vendor = keyed.post("/vendor", json={"name": _unique("capped-vendor")})
assert_code(vendor, 200)
vendor_id = vendor.json()["id"]
assert_code(owner.patch(f"/auth/user/{user_id}", json={"level": "read"}), 200)
with _keyed_client(key) as keyed:
# Same key, same request, different answer -- the cap is re-applied per call.
assert_code(keyed.get("/spool"), 200)
assert_code(keyed.post("/vendor", json={"name": "should not exist"}), 403)
listed = client.get("/auth/apikey")
assert_code(listed, 200)
entry = next(item for item in listed.json() if item["id"] == key_id)
assert entry["level"] == "manage", "the issued level is a historical fact and does not change"
assert entry["effective_level"] == "read", "what it can actually do follows the owner"
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
finally:
client.close()
def test_revoke_stops_the_key_but_keeps_the_record(owner: httpx.Client) -> None:
"""A revoked key stops authenticating and stays visible as revoked."""
created = owner.post("/auth/apikey", json={"name": _unique("revoked"), "level": "read"})
assert_code(created, 201)
key = created.json()["key"]
key_id = created.json()["info"]["id"]
with _keyed_client(key) as client:
assert_code(client.get("/spool"), 200)
revoked = owner.post(f"/auth/apikey/{key_id}/revoke")
assert_code(revoked, 200)
assert revoked.json()["revoked"] is True
with _keyed_client(key) as client:
assert_code(client.get("/spool"), 401)
listed = owner.get("/auth/apikey")
assert_code(listed, 200)
assert any(item["id"] == key_id and item["revoked"] for item in listed.json())
def test_delete_removes_the_key(owner: httpx.Client) -> None:
"""A deleted key stops working and leaves the listing."""
created = owner.post("/auth/apikey", json={"name": _unique("deleted"), "level": "read"})
assert_code(created, 201)
key = created.json()["key"]
key_id = created.json()["info"]["id"]
assert_code(owner.delete(f"/auth/apikey/{key_id}"), 200)
with _keyed_client(key) as client:
assert_code(client.get("/spool"), 401)
listed = owner.get("/auth/apikey")
assert_code(listed, 200)
assert all(item["id"] != key_id for item in listed.json())
def test_keys_are_private_to_their_owner(owner: httpx.Client, reader: httpx.Client) -> None:
"""One user cannot see or touch another's keys.
The refusal is 404 rather than 403 on purpose: answering "forbidden" would confirm
that a key with this ID exists and belongs to somebody else.
"""
created = owner.post("/auth/apikey", json={"name": _unique("private"), "level": "read"})
assert_code(created, 201)
key_id = created.json()["info"]["id"]
listed = reader.get("/auth/apikey")
assert_code(listed, 200)
assert all(item["id"] != key_id for item in listed.json())
assert_code(reader.post(f"/auth/apikey/{key_id}/revoke"), 404)
assert_code(reader.delete(f"/auth/apikey/{key_id}"), 404)
def test_key_belonging_to_a_disabled_user_stops_working(owner: httpx.Client) -> None:
"""Disabling an account disables its machine credentials too."""
username = _unique("disabled")
user_id, password = create_user(owner, username, level="edit", password=READER_PASSWORD)
client, response = login(username=username, password=password)
assert_code(response, 200)
try:
created = client.post("/auth/apikey", json={"name": _unique("orphan"), "level": "read"})
assert_code(created, 201)
key = created.json()["key"]
finally:
client.close()
with _keyed_client(key) as keyed:
assert_code(keyed.get("/spool"), 200)
assert_code(owner.patch(f"/auth/user/{user_id}", json={"is_active": False}), 200)
with _keyed_client(key) as keyed:
assert_code(keyed.get("/spool"), 401)
def test_anonymous_cannot_reach_key_endpoints(anonymous: httpx.Client) -> None:
"""Without a session there is no account to own a key."""
assert_code(anonymous.get("/auth/apikey"), 401)
assert_code(anonymous.post("/auth/apikey", json={"name": "nope", "level": "read"}), 401)
+166
View File
@@ -0,0 +1,166 @@
"""Integration tests: the audit log.
The log's value rests on three things, and each is asserted below.
It records what happened even when the request failed -- a sign-in attempt against a
real account is exactly the event an administrator wants to see, and it is the one the
caller was told nothing about.
It cannot be edited through the API. There is no write endpoint and no delete endpoint,
so a log its subjects could tamper with never exists in the first place.
It outlives its subjects. Deleting an account detaches its entries rather than removing
them, so the record of what that account did survives the account.
"""
import time
import httpx
from .conftest import (
READER_PASSWORD,
assert_code,
create_user,
login,
)
def _unique(prefix: str) -> str:
"""Build a name no other test in this run will collide with."""
return f"{prefix}-{int(time.time() * 1000)}"
def _entries(owner: httpx.Client, **params: object) -> list[dict]:
"""Read the audit log."""
response = owner.get("/auth/audit", params=params)
assert_code(response, 200)
return response.json()
def test_successful_sign_in_is_recorded(owner: httpx.Client) -> None:
"""Signing in leaves an attributable entry."""
username = _unique("audited")
create_user(owner, username, level="read", password=READER_PASSWORD)
client, response = login(username=username, password=READER_PASSWORD)
assert_code(response, 200)
client.close()
entries = _entries(owner, event="login.success", limit=100)
assert any(entry["target"] == username.lower() for entry in entries)
def test_failed_sign_in_is_recorded_with_a_reason(owner: httpx.Client) -> None:
"""The log distinguishes what the response deliberately does not.
The caller is told only "incorrect username or password", because saying more would
let anyone enumerate accounts. The administrator reading the log already knows which
accounts exist, so the entry says which case it was.
"""
username = _unique("badpass")
create_user(owner, username, level="read", password=READER_PASSWORD)
_, response = login(username=username, password="definitely-not-the-password")
assert_code(response, 401)
entries = _entries(owner, event="login.failure", limit=100)
match = next((entry for entry in entries if entry["target"] == username.lower()), None)
assert match is not None, "the failed attempt should have been recorded"
assert match["detail"]["reason"] == "bad_password"
assert match["actor_username"] == username.lower()
def test_user_and_key_changes_are_recorded(owner: httpx.Client) -> None:
"""Creating an account and issuing a key both leave entries."""
username = _unique("recorded")
user_id, _ = create_user(owner, username, level="read", password=READER_PASSWORD)
key_name = _unique("recorded-key")
created = owner.post("/auth/apikey", json={"name": key_name, "level": "read"})
assert_code(created, 201)
assert_code(owner.patch(f"/auth/user/{user_id}", json={"level": "edit"}), 200)
entries = _entries(owner, limit=200)
events = {(entry["event"], entry["target"]) for entry in entries}
assert ("user.created", username.lower()) in events
assert ("user.updated", username.lower()) in events
assert ("apikey.created", key_name) in events
# The key's prefix is recorded, so a key seen in a log can be traced to its creation.
key_entry = next(entry for entry in entries if entry["event"] == "apikey.created" and entry["target"] == key_name)
assert key_entry["detail"]["prefix"] == created.json()["info"]["prefix"]
assert key_entry["actor_kind"] == "user"
def test_entries_survive_the_account_that_made_them(owner: httpx.Client) -> None:
"""Deleting a user detaches its entries instead of erasing them.
A log that loses its record of what an account did the moment the account is removed
is not evidence of anything. The username survives in ``target``.
"""
username = _unique("ghost")
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
client, response = login(username=username, password=password)
assert_code(response, 200)
client.close()
assert_code(owner.delete(f"/auth/user/{user_id}"), 200)
entries = _entries(owner, event="login.success", limit=200)
match = next((entry for entry in entries if entry["target"] == username.lower()), None)
assert match is not None, "the sign-in should still be recorded"
assert match.get("actor_user_id") is None, "the foreign key should have been cleared"
assert match.get("actor_username") is None
def test_filtering_and_pagination(owner: httpx.Client) -> None:
"""The event filter narrows the list, and the total counts what matched."""
unfiltered = owner.get("/auth/audit", params={"limit": 1})
assert_code(unfiltered, 200)
assert len(unfiltered.json()) <= 1
total = int(unfiltered.headers["x-total-count"])
assert total > 0
filtered = owner.get("/auth/audit", params={"event": "login.success", "limit": 100})
assert_code(filtered, 200)
assert all(entry["event"] == "login.success" for entry in filtered.json())
assert int(filtered.headers["x-total-count"]) <= total
# An event nobody has produced yields an empty page rather than an error.
empty = owner.get("/auth/audit", params={"event": "no.such.event"})
assert_code(empty, 200)
assert empty.json() == []
def test_newest_first(owner: httpx.Client) -> None:
"""Entries come back with the most recent first."""
entries = _entries(owner, limit=50)
dates = [entry["date"] for entry in entries]
assert dates == sorted(dates, reverse=True)
def test_event_vocabulary_is_published(owner: httpx.Client) -> None:
"""A client can learn the event names rather than hardcoding them."""
response = owner.get("/auth/audit/events")
assert_code(response, 200)
events = response.json()
for expected in ("login.success", "login.failure", "user.created", "apikey.created"):
assert expected in events
def test_the_log_is_read_only(owner: httpx.Client) -> None:
"""There is no way to add or remove an entry through the API."""
assert owner.post("/auth/audit", json={"event": "login.success"}).status_code == 405
assert owner.delete("/auth/audit").status_code == 405
def test_non_admin_cannot_read_the_log(reader: httpx.Client) -> None:
"""The log names accounts and addresses, so reading it needs admin."""
assert_code(reader.get("/auth/audit"), 403)
assert_code(reader.get("/auth/audit/events"), 403)
def test_anonymous_cannot_read_the_log(anonymous: httpx.Client) -> None:
"""Without a credential the log answers 401."""
assert_code(anonymous.get("/auth/audit"), 401)
+88
View File
@@ -0,0 +1,88 @@
"""Integration tests: cross-site request forgery protection.
The session cookie is ambient -- the browser attaches it to any same-origin request,
including ones a hostile page caused. The defence is a double submit: a second, readable
cookie whose value must be echoed in a header. A cross-site page can make the browser
send the cookies but cannot read them, so it cannot construct the header.
The rejection is 403 and not 401 on purpose. The credential is valid; answering 401
would make a client treat a stale tab as a sign-out.
"""
import httpx
from .conftest import (
CSRF_COOKIE,
CSRF_HEADER,
assert_code,
signed_in,
)
def test_post_without_csrf_header_is_forbidden(setup_response: httpx.Response) -> None: # noqa: ARG001
"""A cookie-authenticated write with no CSRF header is refused."""
with signed_in() as (client, response):
assert_code(response, 200)
# signed_in attaches the header; drop it to model a request the client did not make.
del client.headers[CSRF_HEADER]
result = client.post("/vendor", json={"name": "CSRF test vendor"})
assert_code(result, 403)
assert "csrf" in result.json()["message"].lower()
def test_post_with_mismatched_csrf_header_is_forbidden(setup_response: httpx.Response) -> None: # noqa: ARG001
"""The header has to match the cookie, not merely be present."""
with signed_in() as (client, response):
assert_code(response, 200)
client.headers[CSRF_HEADER] = "not-the-token-from-the-cookie"
result = client.post("/vendor", json={"name": "CSRF test vendor"})
assert_code(result, 403)
def test_post_with_csrf_header_succeeds(setup_response: httpx.Response) -> None: # noqa: ARG001
"""With the cookie echoed into the header the write goes through."""
with signed_in() as (client, response):
assert_code(response, 200)
assert client.headers[CSRF_HEADER] == client.cookies.get(CSRF_COOKIE)
result = client.post("/vendor", json={"name": "CSRF test vendor"})
assert_code(result, 200)
vendor = result.json()
try:
assert vendor["name"] == "CSRF test vendor"
finally:
assert_code(client.delete(f"/vendor/{vendor['id']}"), 200)
def test_reads_do_not_need_a_csrf_token(setup_response: httpx.Response) -> None: # noqa: ARG001
"""Safe methods change nothing, so they are exempt.
Requiring a header on GET would break every plain link and bookmark for no gain.
"""
with signed_in() as (client, response):
assert_code(response, 200)
del client.headers[CSRF_HEADER]
assert_code(client.get("/spool"), 200)
assert_code(client.get("/vendor"), 200)
assert_code(client.get("/setting/"), 200)
def test_every_unsafe_method_is_checked(setup_response: httpx.Response) -> None: # noqa: ARG001
"""The check covers every state-changing method, not just POST."""
with signed_in() as (client, response):
assert_code(response, 200)
created = client.post("/vendor", json={"name": "CSRF delete test vendor"})
assert_code(created, 200)
vendor_id = created.json()["id"]
try:
del client.headers[CSRF_HEADER]
assert_code(client.delete(f"/vendor/{vendor_id}"), 403)
assert_code(client.patch(f"/vendor/{vendor_id}", json={"comment": "nope"}), 403)
assert_code(client.put("/spool/1/use", json={"use_weight": 1}), 403)
finally:
client.headers[CSRF_HEADER] = client.cookies.get(CSRF_COOKIE)
assert_code(client.delete(f"/vendor/{vendor_id}"), 200)
+188
View File
@@ -0,0 +1,188 @@
"""Integration tests: permission levels.
Phase 1 could only assert the ordering from above -- the owner, at manage, reaching
routes at all three levels -- because there was no way to create a second account
through the API, and the anonymous reader was the only low-level principal available.
Phase 2's POST /auth/user closes that. The `reader` fixture is a genuine read-level,
signed-in, non-anonymous user, which is what makes the refusing half of the ordering a
real test rather than an approximation: it distinguishes "refused for lack of level"
from "refused for being anonymous", which the two previous tests could not tell apart.
"""
import time
import httpx
import pytest
from .conftest import (
OWNER_USERNAME_STORED,
READER_PASSWORD,
assert_code,
create_user,
login,
)
def test_owner_is_manage_level(owner: httpx.Client) -> None:
"""The account that claimed the instance holds every right."""
result = owner.get("/auth/session")
assert_code(result, 200)
info = result.json()
assert info["authenticated"] is True
assert info["anonymous"] is False
assert info["level"] == "manage"
assert info["is_admin"] is True
assert info["is_owner"] is True
assert info["user"]["username"] == OWNER_USERNAME_STORED
def test_owner_can_read(owner: httpx.Client) -> None:
"""Read-level routes admit a manage-level caller."""
for path in ("/info", "/spool", "/filament", "/vendor", "/setting/", "/material", "/location"):
assert_code(owner.get(path), 200)
def test_owner_can_edit(owner: httpx.Client) -> None:
"""Edit-level routes: PATCH on a resource, and the use endpoint."""
vendor = owner.post("/vendor", json={"name": "Level test vendor"})
assert_code(vendor, 200)
vendor_id = vendor.json()["id"]
filament = owner.post(
"/filament",
json={
"name": "Level test filament",
"vendor_id": vendor_id,
"density": 1.25,
"diameter": 1.75,
"weight": 1000,
},
)
assert_code(filament, 200)
filament_id = filament.json()["id"]
spool = owner.post("/spool", json={"filament_id": filament_id, "remaining_weight": 500})
assert_code(spool, 200)
spool_id = spool.json()["id"]
try:
assert_code(owner.patch(f"/vendor/{vendor_id}", json={"comment": "edited"}), 200)
assert_code(owner.patch(f"/filament/{filament_id}", json={"comment": "edited"}), 200)
assert_code(owner.patch(f"/spool/{spool_id}", json={"comment": "edited"}), 200)
# used_weight is the spool's running total, not the amount of this one call.
# Creating the spool with remaining_weight=500 against a 1000 g filament already
# counts 500 g as used, so assert the delta rather than the absolute value.
before = owner.get(f"/spool/{spool_id}")
assert_code(before, 200)
used_before = before.json()["used_weight"]
used = owner.put(f"/spool/{spool_id}/use", json={"use_weight": 10})
assert_code(used, 200)
assert used.json()["used_weight"] == pytest.approx(used_before + 10)
finally:
assert_code(owner.delete(f"/spool/{spool_id}"), 200)
assert_code(owner.delete(f"/filament/{filament_id}"), 200)
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
def test_owner_can_manage(owner: httpx.Client) -> None:
"""Manage-level routes: create, delete, settings, and the bulk exports."""
vendor = owner.post("/vendor", json={"name": "Manage test vendor"})
assert_code(vendor, 200)
vendor_id = vendor.json()["id"]
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
# Settings are read at read level but written at manage level.
assert_code(owner.post("/setting/currency", json='"SEK"'), 200)
assert_code(owner.post("/setting/currency", json=""), 200)
# The exports hand over the whole database in one response, which is why they sit at
# manage rather than read.
for path in ("/export/spools", "/export/filaments", "/export/vendors"):
assert_code(owner.get(path, params={"fmt": "json"}), 200)
def test_owner_can_change_own_password_endpoint_level(owner: httpx.Client) -> None:
"""POST /auth/password needs a real user, and the owner is one.
Sending a wrong current password proves the gate was passed: the 400 comes from the
endpoint's own check, not from a permission error.
"""
result = owner.post(
"/auth/password",
json={"current_password": "not-the-current-password", "new_password": "a-brand-new-password"},
)
assert_code(result, 400)
def test_read_user_is_refused_edit_and_manage(owner: httpx.Client, reader: httpx.Client) -> None:
"""A signed-in read-level user reaches read routes and nothing above them.
Distinct from the anonymous-reader tests: this principal is authenticated, so a
refusal here can only be about its level. An anonymous caller failing the same
request proves less, because ``allow_anonymous`` could have been what stopped it.
"""
session = reader.get("/auth/session")
assert_code(session, 200)
assert session.json()["authenticated"] is True
assert session.json()["anonymous"] is False
assert session.json()["level"] == "read"
for path in ("/info", "/spool", "/filament", "/vendor", "/setting/"):
assert_code(reader.get(path), 200)
vendor = owner.post("/vendor", json={"name": "Read-level test vendor"})
assert_code(vendor, 200)
vendor_id = vendor.json()["id"]
try:
# edit
assert_code(reader.patch(f"/vendor/{vendor_id}", json={"comment": "nope"}), 403)
# manage
assert_code(reader.post("/vendor", json={"name": "should not exist"}), 403)
assert_code(reader.delete(f"/vendor/{vendor_id}"), 403)
assert_code(reader.post("/setting/currency", json='"USD"'), 403)
# The bulk exports sit at manage rather than read on purpose.
assert_code(reader.get("/export/spools", params={"fmt": "json"}), 403)
finally:
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
def test_edit_user_can_edit_but_not_create_or_delete(owner: httpx.Client) -> None:
"""The middle of the ordering, which nothing could exercise before.
PUT /spool/{id}/use sits at edit rather than manage deliberately: it is Moonraker's
and OctoPrint's hot path, so a printer can be given a credential that tracks filament
usage without being able to delete anything. That distinction is only testable with a
real edit-level account.
"""
username = f"editor-{int(time.time() * 1000)}"
create_user(owner, username, level="edit", password=READER_PASSWORD)
client, response = login(username=username, password=READER_PASSWORD)
assert_code(response, 200)
vendor = owner.post("/vendor", json={"name": "Edit-level test vendor"})
assert_code(vendor, 200)
vendor_id = vendor.json()["id"]
filament = owner.post(
"/filament",
json={"name": "Edit-level filament", "vendor_id": vendor_id, "density": 1.24, "diameter": 1.75, "weight": 1000},
)
assert_code(filament, 200)
filament_id = filament.json()["id"]
spool = owner.post("/spool", json={"filament_id": filament_id, "remaining_weight": 500})
assert_code(spool, 200)
spool_id = spool.json()["id"]
try:
assert_code(client.patch(f"/vendor/{vendor_id}", json={"comment": "edited by editor"}), 200)
assert_code(client.put(f"/spool/{spool_id}/use", json={"use_weight": 5}), 200)
assert_code(client.post("/vendor", json={"name": "should not exist"}), 403)
assert_code(client.delete(f"/spool/{spool_id}"), 403)
finally:
client.close()
assert_code(owner.delete(f"/spool/{spool_id}"), 200)
assert_code(owner.delete(f"/filament/{filament_id}"), 200)
assert_code(owner.delete(f"/vendor/{vendor_id}"), 200)
+182
View File
@@ -0,0 +1,182 @@
"""Integration tests: signing in.
Two properties matter more than the happy path here. The first is that every failure
looks identical, so nobody can use the endpoint to find out which usernames exist. The
second is that repeated guessing stops working, and says so with a Retry-After the
client can obey.
"""
import httpx
import pytest
from .conftest import (
API_URL,
CSRF_COOKIE,
INVALID_CREDENTIALS,
OWNER_PASSWORD,
OWNER_USERNAME,
OWNER_USERNAME_STORED,
SESSION_COOKIE,
TIMEOUT,
assert_code,
signed_in,
)
# spoolman.database.auth_user.LOCKOUT_THRESHOLD.
LOCKOUT_THRESHOLD = 5
# The first lockout, in seconds, doubling per further failure up to 900.
LOCKOUT_BASE_SECONDS = 60
# spoolman.auth.cookies.REMEMBER_LIFETIME, in seconds.
REMEMBER_LIFETIME_SECONDS = 90 * 24 * 60 * 60
def _cookie_header(response: httpx.Response, name: str) -> str:
"""Return the Set-Cookie header for one cookie."""
for header in response.headers.get_list("set-cookie"):
if header.startswith(f"{name}="):
return header
return pytest.fail(f"No {name} cookie was set. Headers: {response.headers.get_list('set-cookie')}")
def test_login_succeeds(setup_response: httpx.Response) -> None: # noqa: ARG001
"""Correct credentials return the caller's standing and set both cookies."""
with signed_in() as (client, response):
assert_code(response, 200)
info = response.json()
assert info["authenticated"] is True
assert info["level"] == "manage"
assert info["user"]["username"] == OWNER_USERNAME_STORED
assert client.cookies.get(SESSION_COOKIE)
assert client.cookies.get(CSRF_COOKIE)
# The session it handed out works.
assert_code(client.get("/spool"), 200)
def test_login_is_case_insensitive(setup_response: httpx.Response) -> None: # noqa: ARG001
"""Usernames are matched in their normalised, lowercased form."""
with signed_in(username=OWNER_USERNAME.upper()) as (_, response):
assert_code(response, 200)
assert response.json()["user"]["username"] == OWNER_USERNAME_STORED
def test_login_with_wrong_password_is_refused(setup_response: httpx.Response) -> None: # noqa: ARG001
"""A wrong password is a 401 and sets no cookies."""
result = httpx.post(
f"{API_URL}/auth/login",
json={"username": OWNER_USERNAME, "password": "definitely-not-the-password"},
timeout=TIMEOUT,
)
assert_code(result, 401)
assert result.json()["message"] == INVALID_CREDENTIALS
assert not result.headers.get_list("set-cookie")
def test_unknown_user_gives_the_same_answer(setup_response: httpx.Response) -> None: # noqa: ARG001
"""An account that does not exist is indistinguishable from a wrong password.
Identical status and body: any difference here would turn the endpoint into a
username oracle.
"""
wrong_password = httpx.post(
f"{API_URL}/auth/login",
json={"username": OWNER_USERNAME, "password": "definitely-not-the-password"},
timeout=TIMEOUT,
)
unknown_user = httpx.post(
f"{API_URL}/auth/login",
json={"username": "no-such-person", "password": "definitely-not-the-password"},
timeout=TIMEOUT,
)
assert unknown_user.status_code == wrong_password.status_code == 401
assert unknown_user.json() == wrong_password.json() == {"message": INVALID_CREDENTIALS}
def test_remember_controls_cookie_lifetime(setup_response: httpx.Response) -> None: # noqa: ARG001
"""'Remember me' is the difference between a persistent and a session cookie.
Without it the cookies must carry no Max-Age, so the browser drops them when it
closes. With it they get the 90-day lifetime the session itself is given.
"""
with signed_in(remember=False) as (_, plain):
assert_code(plain, 200)
assert "max-age" not in _cookie_header(plain, SESSION_COOKIE).lower()
assert "max-age" not in _cookie_header(plain, CSRF_COOKIE).lower()
with signed_in(remember=True) as (_, remembered):
assert_code(remembered, 200)
assert f"max-age={REMEMBER_LIFETIME_SECONDS}" in _cookie_header(remembered, SESSION_COOKIE).lower()
assert f"max-age={REMEMBER_LIFETIME_SECONDS}" in _cookie_header(remembered, CSRF_COOKIE).lower()
def test_logout_revokes_the_session(setup_response: httpx.Response) -> None: # noqa: ARG001
"""After signing out the cookie is dead server-side, not merely cleared client-side."""
with signed_in() as (client, response):
assert_code(response, 200)
token = client.cookies.get(SESSION_COOKIE)
csrf = client.cookies.get(CSRF_COOKIE)
assert_code(client.post("/auth/logout"), 200)
# Present the old token by hand: the row is gone, so it authenticates nothing.
with httpx.Client(base_url=API_URL, timeout=TIMEOUT) as fresh:
result = fresh.get(
"/spool",
headers={"Cookie": f"{SESSION_COOKIE}={token}; {CSRF_COOKIE}={csrf}"},
)
assert_code(result, 401)
def test_repeated_failures_lock_the_account(owner: httpx.Client) -> None:
"""Guessing stops working after five failures, with a Retry-After the client can obey.
The lockout is persisted on the user row, so it survives a restart, and it applies to
new sign-ins only -- the ``owner`` fixture's existing session keeps working
throughout, which is what makes the recovery below possible.
This is the only test that locks the account. It restores it before finishing by
re-setting the same password through the still-live owner session: ``set_password``
clears failed_logins and locked_until, which is the only way back in short of waiting
out the minute.
"""
# Start from a known state: a successful sign-in zeroes the persisted failure count,
# so the loop below is exactly five failures whatever ran before it.
with signed_in() as (_, reset):
assert_code(reset, 200)
try:
for attempt in range(LOCKOUT_THRESHOLD):
result = httpx.post(
f"{API_URL}/auth/login",
json={"username": OWNER_USERNAME, "password": f"wrong-guess-{attempt}"},
timeout=TIMEOUT,
)
# Every one of the first five is an ordinary credential failure; the lockout
# only takes effect from the next attempt onwards.
assert_code(result, 401)
assert result.json()["message"] == INVALID_CREDENTIALS
# Even the correct password is now refused, and with a different status: a client
# must be able to tell "wrong password, try again" from "stop trying".
locked = httpx.post(
f"{API_URL}/auth/login",
json={"username": OWNER_USERNAME, "password": OWNER_PASSWORD},
timeout=TIMEOUT,
)
assert_code(locked, 429)
retry_after = locked.headers.get("Retry-After")
assert retry_after is not None, "A 429 without Retry-After leaves the client guessing."
assert 0 < int(retry_after) <= LOCKOUT_BASE_SECONDS
finally:
# Recover, so the rest of the run can still sign in.
recovered = owner.post(
"/auth/password",
json={"current_password": OWNER_PASSWORD, "new_password": OWNER_PASSWORD},
)
assert_code(recovered, 200)
with signed_in() as (_, unlocked):
assert_code(unlocked, 200)
@@ -0,0 +1,110 @@
"""Integration tests: changing one's own password.
The endpoint takes the current password as well as the new one, so that a borrowed
session -- an unlocked laptop, a stolen cookie -- cannot be turned into permanent
ownership of the account.
Every test here restores the original password before finishing. The owner account is
shared with the rest of the suite, and a stale password would strand every later sign-in.
"""
import httpx
import pytest
from .conftest import (
OWNER_PASSWORD,
OWNER_USERNAME,
assert_code,
signed_in,
)
NEW_PASSWORD = "an-entirely-different-password"
@pytest.fixture
def restore_password(owner: httpx.Client):
"""Put the owner's password back however the test ends."""
yield
# Whichever password is current, one of these two puts it back.
for current in (NEW_PASSWORD, OWNER_PASSWORD):
result = owner.post(
"/auth/password",
json={"current_password": current, "new_password": OWNER_PASSWORD},
)
if result.status_code == 200:
break
else:
pytest.fail("Could not restore the owner password; later tests will not be able to sign in.")
def test_wrong_current_password_is_refused(owner: httpx.Client) -> None:
"""A caller who cannot produce the current password cannot replace it."""
result = owner.post(
"/auth/password",
json={"current_password": "not-the-current-password", "new_password": NEW_PASSWORD},
)
assert_code(result, 400)
assert "current password" in result.json()["message"].lower()
# The old password still works, so nothing was changed on the way to the refusal.
with signed_in() as (_, response):
assert_code(response, 200)
def test_password_change_requires_a_session(anonymous: httpx.Client) -> None:
"""Unlike the other auth endpoints, this one is gated."""
result = anonymous.post(
"/auth/password",
json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD},
)
assert_code(result, 401)
def test_password_change_succeeds_and_takes_effect(
owner: httpx.Client,
restore_password: None, # noqa: ARG001
) -> None:
"""The new password works on the next sign-in, and the old one stops working."""
result = owner.post(
"/auth/password",
json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD},
)
assert_code(result, 200)
with signed_in(password=NEW_PASSWORD) as (_, with_new):
assert_code(with_new, 200)
assert with_new.json()["is_owner"] is True
with signed_in(password=OWNER_PASSWORD) as (_, with_old):
assert_code(with_old, 401)
def test_changing_the_password_keeps_the_current_session_alive(
owner: httpx.Client,
restore_password: None, # noqa: ARG001
) -> None:
"""The session that made the change is not signed out by it.
Phase 1 does not revoke any session on a password change. Asserting the current
behaviour here means that if revocation is added later, this test is the thing that
says so out loud rather than users being silently signed out.
"""
assert_code(
owner.post("/auth/password", json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD}),
200,
)
assert_code(owner.get("/spool"), 200)
assert_code(owner.get("/auth/session"), 200)
def test_new_password_works_for_any_username_casing(
owner: httpx.Client,
restore_password: None, # noqa: ARG001
) -> None:
"""The changed password belongs to the account, whichever casing signs in with it."""
assert_code(
owner.post("/auth/password", json={"current_password": OWNER_PASSWORD, "new_password": NEW_PASSWORD}),
200,
)
with signed_in(username=OWNER_USERNAME.upper(), password=NEW_PASSWORD) as (_, response):
assert_code(response, 200)
@@ -0,0 +1,97 @@
"""Integration tests: claiming an unclaimed instance.
The first caller to reach POST /auth/setup becomes the owner. That is a one-shot,
irreversible transition, so the interesting assertions are that it happens exactly once
and that the claim really does hand out a working session.
"""
import httpx
from .conftest import (
API_URL,
CSRF_COOKIE,
OWNER_DISPLAY_NAME,
OWNER_USERNAME_STORED,
SESSION_COOKIE,
TIMEOUT,
assert_code,
)
def test_setup_claims_the_instance(setup_response: httpx.Response) -> None:
"""The first claim answers 201 with an owner-level session."""
assert_code(setup_response, 201)
info = setup_response.json()
assert info["authenticated"] is True
assert info["anonymous"] is False
assert info["level"] == "manage"
assert info["is_admin"] is True
assert info["is_owner"] is True
def test_setup_lowercases_the_username(setup_response: httpx.Response) -> None:
"""The username is stored lowercased regardless of how it was typed."""
assert_code(setup_response, 201)
user = setup_response.json()["user"]
assert user["username"] == OWNER_USERNAME_STORED
assert user["display_name"] == OWNER_DISPLAY_NAME
assert user["level"] == "manage"
assert user["is_owner"] is True
def test_setup_sets_session_and_csrf_cookies(setup_response: httpx.Response) -> None:
"""The claim is also a sign-in: both cookies come back, session one HttpOnly."""
assert_code(setup_response, 201)
set_cookie = "; ".join(setup_response.headers.get_list("set-cookie")).lower()
assert SESSION_COOKIE in set_cookie
assert CSRF_COOKIE in set_cookie
session_header = next(
header for header in setup_response.headers.get_list("set-cookie") if header.startswith(SESSION_COOKIE)
)
csrf_header = next(
header for header in setup_response.headers.get_list("set-cookie") if header.startswith(CSRF_COOKIE)
)
# The session token must not be reachable from script; the CSRF token must be, since
# the client has to copy it into a request header.
assert "httponly" in session_header.lower()
assert "httponly" not in csrf_header.lower()
def test_setup_is_refused_once_claimed(setup_response: httpx.Response) -> None:
"""A second claim is a conflict, not a second owner."""
assert_code(setup_response, 201)
result = httpx.post(
f"{API_URL}/auth/setup",
json={"username": "impostor", "password": "another-long-password"},
timeout=TIMEOUT,
)
assert_code(result, 409)
assert "claimed" in result.json()["message"].lower()
def test_setup_session_can_be_used(setup_response: httpx.Response) -> None:
"""The cookies the claim handed out actually authenticate a request."""
assert_code(setup_response, 201)
with httpx.Client(base_url=API_URL, timeout=TIMEOUT, cookies=setup_response.cookies) as client:
result = client.get("/auth/session")
assert_code(result, 200)
info = result.json()
assert info["authenticated"] is True
assert info["is_owner"] is True
assert info["user"]["username"] == OWNER_USERNAME_STORED
def test_config_reports_the_instance_claimed(setup_response: httpx.Response) -> None:
"""GET /auth/config stops advertising setup once an account exists."""
assert_code(setup_response, 201)
result = httpx.get(f"{API_URL}/auth/config", timeout=TIMEOUT)
assert_code(result, 200)
config = result.json()
assert config["enabled"] is True
assert config["setup_required"] is False
@@ -0,0 +1,256 @@
"""Integration tests: every gated route refuses a caller with no credentials.
This is the load-bearing test of the suite. ``spoolman.auth.coverage`` already proves at
import time that no route was left without a gate, but that is a static check on the
route table -- it says a dependency is attached, not that the dependency actually
rejects anybody. This file exercises the real thing over HTTP: every gated HTTP route,
each of which must answer 401 to a request carrying nothing.
The table below is written out rather than derived, so that adding a route forces a
deliberate decision about whether it is public. ``test_route_table_matches_openapi``
is the guard that keeps the table honest: it fails the moment the served schema and this
list disagree, in either direction.
Websocket routes are gated by a different mechanism and are covered in test_websocket.py.
"""
import httpx
import pytest
from .conftest import (
API_URL,
INVALID_CREDENTIALS,
SESSION_COOKIE,
TIMEOUT,
assert_code,
)
# Every gated HTTP route, as (method, request path, documented level).
#
# Path parameters are filled with values that need not exist: a permission gate is a
# route dependency and is solved before the handler's own parameters, so an unauthorised
# caller never gets far enough to produce a 404 or a 422.
GATED_ROUTES: list[tuple[str, str, str]] = [
# -- read -------------------------------------------------------------------
("GET", "/info", "read"),
("GET", "/filament", "read"),
("GET", "/filament/1", "read"),
("GET", "/spool", "read"),
("GET", "/spool/1", "read"),
("GET", "/spool/group", "read"),
("GET", "/vendor", "read"),
("GET", "/vendor/1", "read"),
("GET", "/search", "read"),
("GET", "/material", "read"),
("GET", "/article-number", "read"),
("GET", "/lot-number", "read"),
("GET", "/location", "read"),
("GET", "/external/filament", "read"),
("GET", "/external/filament/search", "read"),
("GET", "/external/material", "read"),
("GET", "/field/spool", "read"),
("GET", "/field/spool/some_field/values", "read"),
# A read-level route even though the payload is settings: reading them is how the
# client learns the currency and the extra-field definitions.
("GET", "/setting/", "read"),
("GET", "/setting/currency", "read"),
# Changing one's own password needs a real signed-in user, not merely read rights,
# but the level recorded on the gate is read.
("POST", "/auth/password", "read"),
# The API key routes sit at read for the same reason: their gate is require_user,
# which is require_level(read) plus a refusal of anonymous callers and of API keys.
# A key that could mint a key would be unrevocable in practice.
("GET", "/auth/apikey", "read"),
("POST", "/auth/apikey", "read"),
("POST", "/auth/apikey/1/revoke", "read"),
("DELETE", "/auth/apikey/1", "read"),
# -- edit -------------------------------------------------------------------
("PATCH", "/filament/1", "edit"),
("PATCH", "/spool/1", "edit"),
("PATCH", "/vendor/1", "edit"),
("PATCH", "/location/somewhere", "edit"),
("PUT", "/spool/1/use", "edit"),
("PUT", "/spool/1/measure", "edit"),
# -- manage -----------------------------------------------------------------
("POST", "/filament", "manage"),
("POST", "/spool", "manage"),
("POST", "/vendor", "manage"),
("DELETE", "/filament/1", "manage"),
("DELETE", "/spool/1", "manage"),
("DELETE", "/vendor/1", "manage"),
("POST", "/setting/currency", "manage"),
("POST", "/field/spool/some_field", "manage"),
("DELETE", "/field/spool/some_field", "manage"),
("POST", "/backup", "manage"),
# The export routes are manage rather than read on purpose: they hand over the whole
# database in one response, which is a different thing from browsing it.
("GET", "/export/spools?fmt=json", "manage"),
("GET", "/export/filaments?fmt=json", "manage"),
("GET", "/export/vendors?fmt=json", "manage"),
# User administration and the audit log are recorded as manage, which is what their
# gate enforces; require_admin adds the administrator check on top of it. An
# unauthenticated caller is refused by the level gate first, which is what this file
# asserts -- the admin half is covered in test_users.py and test_audit.py.
("GET", "/auth/user", "manage"),
("POST", "/auth/user", "manage"),
("GET", "/auth/user/1", "manage"),
("PATCH", "/auth/user/1", "manage"),
("DELETE", "/auth/user/1", "manage"),
("POST", "/auth/user/1/password", "manage"),
("POST", "/auth/user/1/revoke-sessions", "manage"),
# The audit log 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.
("GET", "/auth/audit", "manage"),
("GET", "/auth/audit/events", "manage"),
]
# The routes that must stay reachable with no credentials, mirroring PUBLIC_ROUTES in
# spoolman/api/v1/router.py. /health has to answer before anyone can possibly be signed
# in; the auth endpoints are how a credential is obtained, so gating them is circular.
PUBLIC_ROUTES: list[tuple[str, str]] = [
("GET", "/health"),
("GET", "/auth/config"),
("GET", "/auth/session"),
("POST", "/auth/setup"),
("POST", "/auth/login"),
("POST", "/auth/logout"),
]
def _strip_query(path: str) -> str:
return path.split("?", 1)[0]
def _template(path: str) -> str:
"""Turn a concrete request path back into its OpenAPI template form."""
replacements = {
"/filament/1": "/filament/{filament_id}",
"/spool/1/use": "/spool/{spool_id}/use",
"/spool/1/measure": "/spool/{spool_id}/measure",
"/spool/group": "/spool/group",
"/spool/1": "/spool/{spool_id}",
"/vendor/1": "/vendor/{vendor_id}",
"/setting/currency": "/setting/{key}",
"/location/somewhere": "/location/{location}",
"/field/spool/some_field/values": "/field/{entity_type}/{key}/values",
"/field/spool/some_field": "/field/{entity_type}/{key}",
"/field/spool": "/field/{entity_type}",
"/auth/apikey/1/revoke": "/auth/apikey/{key_id}/revoke",
"/auth/apikey/1": "/auth/apikey/{key_id}",
"/auth/user/1/revoke-sessions": "/auth/user/{user_id}/revoke-sessions",
"/auth/user/1/password": "/auth/user/{user_id}/password",
"/auth/user/1": "/auth/user/{user_id}",
}
return replacements.get(path, path)
@pytest.mark.parametrize(
("method", "path"),
[(method, path) for method, path, _ in GATED_ROUTES],
ids=[f"{method} {path}" for method, path, _ in GATED_ROUTES],
)
def test_gated_route_requires_credentials(anonymous: httpx.Client, method: str, path: str) -> None:
"""A request with no credentials is refused with 401, before any handler runs."""
result = anonymous.request(method, path, json={} if method in {"POST", "PATCH", "PUT"} else None)
assert_code(result, 401)
# The body shape is the same models.Message every other error in this API uses, not
# FastAPI's default {"detail": ...}. Clients branch on it.
assert result.json() == {"message": "Authentication required."}
def test_every_gated_route_is_listed() -> None:
"""Guard the count, so a route added without a test entry is noticed.
Phase 1 gated 40 HTTP routes. Phase 2 added thirteen: four for API keys, seven for
user administration, two for the audit log.
"""
assert len(GATED_ROUTES) == 53
@pytest.mark.parametrize(
("method", "path"),
PUBLIC_ROUTES,
ids=[f"{method} {path}" for method, path in PUBLIC_ROUTES],
)
def test_public_route_is_reachable(
setup_response: httpx.Response, # noqa: ARG001
anonymous: httpx.Client,
method: str,
path: str,
) -> None:
"""A public route answers on its own terms rather than with a permission error.
Each of these does its own checking, so 'reachable' means 'anything but 401 or 403'.
The instance is claimed first, which pins the answers: /auth/setup is a conflict
rather than a fresh claim, and a bogus /auth/login is a credential failure.
"""
if path == "/auth/setup":
body = {"username": "impostor", "password": "another-long-password"}
elif path == "/auth/login":
body = {"username": "nobody-at-all", "password": "not-the-password"}
else:
body = {}
result = anonymous.request(method, path, json=body if method == "POST" else None)
assert result.status_code not in {401, 403} or (
# A wrong password is a 401 from the credential check itself, not from a gate --
# which is the point: the route was reached.
path == "/auth/login" and result.json()["message"] == INVALID_CREDENTIALS
), f"{method} {path} answered {result.status_code}: {result.text}"
def test_health_is_the_only_public_non_auth_route() -> None:
"""Nothing outside /health and /auth/* may be public."""
for _, path in PUBLIC_ROUTES:
assert path == "/health" or path.startswith("/auth/")
def test_route_table_matches_openapi() -> None:
"""The tables above must describe exactly the routes the server serves.
Fetched from the live server rather than imported, since the tester runs in its own
container with no access to the Spoolman source. A route added or removed on either
side fails here, which is what stops this file from silently going stale.
"""
schema = httpx.get(f"{API_URL}/openapi.json", timeout=TIMEOUT)
assert_code(schema, 200)
served = {
(method.upper(), path)
for path, operations in schema.json()["paths"].items()
for method in operations
if method.upper() != "HEAD"
}
expected = {(method, _template(_strip_query(path))) for method, path, _ in GATED_ROUTES}
expected |= set(PUBLIC_ROUTES)
assert served == expected, (
f"Routes served but not in the table: {sorted(served - expected)}. "
f"Routes in the table but not served: {sorted(expected - served)}."
)
def test_session_endpoint_reports_signed_out(anonymous: httpx.Client) -> None:
"""GET /auth/session answers for a signed-out caller instead of rejecting them.
A client needs to tell 'no session' apart from 'server unreachable', so this one
endpoint must never 401.
"""
result = anonymous.get("/auth/session")
assert_code(result, 200)
info = result.json()
assert info["authenticated"] is False
assert info["anonymous"] is False
assert info["user"] is None
def test_logout_without_a_session_succeeds(anonymous: httpx.Client) -> None:
"""Signing out is idempotent, so a client can always clear its cookies."""
result = anonymous.post("/auth/logout")
assert_code(result, 200)
def test_garbage_session_cookie_is_refused(anonymous: httpx.Client) -> None:
"""A made-up session token is not a credential."""
result = anonymous.get("/spool", headers={"Cookie": f"{SESSION_COOKIE}=not-a-real-token"})
assert_code(result, 401)
+248
View File
@@ -0,0 +1,248 @@
"""Integration tests: user administration.
Two things are being defended here.
The first is that an instance cannot be administered into a state nobody can recover
from through the interface: the owner and the caller's own account are shielded from
demotion, disabling and deletion. Neither is a security boundary -- both are recoverable
with ``python -m spoolman.cli`` -- so the tests assert the refusal, not that it is
unbypassable.
The second is that the protections only fire on changes that actually take something
away. A form that re-sends an account unchanged must not be refused just because it
included a field the caller may not lower.
"""
import time
import httpx
from .conftest import (
OWNER_USERNAME_STORED,
READER_PASSWORD,
assert_code,
create_user,
login,
)
def _unique(prefix: str) -> str:
"""Build a username no other test in this run will collide with."""
return f"{prefix}-{int(time.time() * 1000)}"
def test_create_generates_a_password_when_none_is_given(owner: httpx.Client) -> None:
"""A generated password comes back once, and works."""
username = _unique("generated")
response = owner.post("/auth/user", json={"username": username, "level": "read"})
assert_code(response, 201)
body = response.json()
password = body["password"]
assert password, "a password should have been generated"
assert body["user"]["must_change_password"] is True, "generated passwords default to one-time"
client, login_response = login(username=username, password=password)
assert_code(login_response, 200)
client.close()
# And it is never retrievable again.
fetched = owner.get(f"/auth/user/{body['user']['id']}")
assert_code(fetched, 200)
assert password not in str(fetched.json())
def test_create_accepts_a_supplied_password(owner: httpx.Client) -> None:
"""When the caller supplies a password, none is generated or echoed."""
username = _unique("supplied")
response = owner.post(
"/auth/user",
json={"username": username, "password": READER_PASSWORD, "level": "edit"},
)
assert_code(response, 201)
assert response.json().get("password") is None
client, login_response = login(username=username, password=READER_PASSWORD)
assert_code(login_response, 200)
client.close()
def test_usernames_are_unique_and_normalised(owner: httpx.Client) -> None:
"""A second account with the same name, in any case, is refused."""
username = _unique("Unique")
create_user(owner, username, password=READER_PASSWORD)
clash = owner.post("/auth/user", json={"username": username.upper(), "level": "read"})
assert_code(clash, 409)
assert "already exists" in clash.json()["message"]
def test_list_includes_every_account(owner: httpx.Client) -> None:
"""The listing shows the owner alongside whatever the suite has created."""
username = _unique("listed")
create_user(owner, username, password=READER_PASSWORD)
response = owner.get("/auth/user")
assert_code(response, 200)
usernames = {user["username"] for user in response.json()}
assert OWNER_USERNAME_STORED in usernames
assert username.lower() in usernames
assert response.headers["x-total-count"] == str(len(response.json()))
def test_update_changes_level_and_flags(owner: httpx.Client) -> None:
"""Level, administrator and display name all take effect."""
user_id, _ = create_user(owner, _unique("updated"), level="read", password=READER_PASSWORD)
response = owner.patch(
f"/auth/user/{user_id}",
json={"level": "manage", "is_admin": True, "display_name": "Renamed"},
)
assert_code(response, 200)
body = response.json()
assert body["level"] == "manage"
assert body["is_admin"] is True
assert body["display_name"] == "Renamed"
def test_owner_cannot_be_demoted_disabled_or_deleted(owner: httpx.Client) -> None:
"""The account that can always fix the others cannot be taken apart."""
session = owner.get("/auth/session")
assert_code(session, 200)
owner_id = session.json()["user"]["id"]
for change in ({"level": "read"}, {"is_admin": False}, {"is_active": False}):
refused = owner.patch(f"/auth/user/{owner_id}", json=change)
assert_code(refused, 403)
assert "owner" in refused.json()["message"].lower()
assert_code(owner.delete(f"/auth/user/{owner_id}"), 403)
def test_unchanged_fields_are_not_refused(owner: httpx.Client) -> None:
"""Re-sending an account's current values is a no-op, not a violation.
The protections compare against what is stored, so a form that submits every field
-- including the owner's own level -- succeeds as long as nothing actually changes.
"""
session = owner.get("/auth/session")
assert_code(session, 200)
owner_id = session.json()["user"]["id"]
response = owner.patch(
f"/auth/user/{owner_id}",
json={"level": "manage", "is_admin": True, "is_active": True},
)
assert_code(response, 200)
assert response.json()["level"] == "manage"
def test_admin_cannot_demote_or_delete_themselves(owner: httpx.Client) -> None:
"""Removing your own rights needs a second pair of hands."""
username = _unique("selfadmin")
user_id, password = create_user(owner, username, level="manage", is_admin=True, password=READER_PASSWORD)
client, response = login(username=username, password=password)
assert_code(response, 200)
try:
for change in ({"level": "read"}, {"is_admin": False}, {"is_active": False}):
refused = client.patch(f"/auth/user/{user_id}", json=change)
assert_code(refused, 403)
assert_code(client.delete(f"/auth/user/{user_id}"), 403)
# Somebody else with the rights can, which is the point of the restriction.
assert_code(owner.patch(f"/auth/user/{user_id}", json={"is_admin": False}), 200)
finally:
client.close()
def test_disabling_an_account_ends_its_sessions(owner: httpx.Client) -> None:
"""A disabled account whose cookie still worked would not be disabled."""
username = _unique("cutoff")
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
client, response = login(username=username, password=password)
assert_code(response, 200)
try:
assert_code(client.get("/spool"), 200)
assert_code(owner.patch(f"/auth/user/{user_id}", json={"is_active": False}), 200)
assert_code(client.get("/spool"), 401)
finally:
client.close()
def test_password_reset_ends_sessions_and_sets_the_new_password(owner: httpx.Client) -> None:
"""A reset invalidates what the old password bought."""
username = _unique("reset")
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
client, response = login(username=username, password=password)
assert_code(response, 200)
try:
assert_code(client.get("/spool"), 200)
reset = owner.post(f"/auth/user/{user_id}/password", json={"must_change_password": True})
assert_code(reset, 200)
new_password = reset.json()["password"]
assert new_password
assert new_password != password
# Whoever knew the old password may be exactly who the reset defends against.
assert_code(client.get("/spool"), 401)
finally:
client.close()
fresh, response = login(username=username, password=new_password)
assert_code(response, 200)
try:
assert response.json()["user"]["must_change_password"] is True
finally:
fresh.close()
def test_revoke_sessions_without_changing_the_password(owner: httpx.Client) -> None:
"""Signing somebody out everywhere leaves their credential alone."""
username = _unique("revoked")
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
client, response = login(username=username, password=password)
assert_code(response, 200)
try:
assert_code(owner.post(f"/auth/user/{user_id}/revoke-sessions"), 200)
assert_code(client.get("/spool"), 401)
finally:
client.close()
again, response = login(username=username, password=password)
assert_code(response, 200)
again.close()
def test_delete_removes_the_account_and_its_sessions(owner: httpx.Client) -> None:
"""A deleted account cannot sign in and is gone from the listing."""
username = _unique("gone")
user_id, password = create_user(owner, username, level="read", password=READER_PASSWORD)
assert_code(owner.delete(f"/auth/user/{user_id}"), 200)
assert_code(owner.get(f"/auth/user/{user_id}"), 404)
listed = owner.get("/auth/user")
assert_code(listed, 200)
assert all(user["id"] != user_id for user in listed.json())
_, response = login(username=username, password=password)
assert_code(response, 401)
def test_non_admin_is_refused_every_user_endpoint(reader: httpx.Client) -> None:
"""Managing accounts needs administrator rights, not merely a session."""
assert_code(reader.get("/auth/user"), 403)
assert_code(reader.post("/auth/user", json={"username": "nope", "level": "read"}), 403)
assert_code(reader.patch("/auth/user/1", json={"level": "read"}), 403)
assert_code(reader.delete("/auth/user/1"), 403)
def test_anonymous_is_refused_every_user_endpoint(anonymous: httpx.Client) -> None:
"""Without any credential the endpoints answer 401, not 403."""
assert_code(anonymous.get("/auth/user"), 401)
assert_code(anonymous.post("/auth/user", json={"username": "nope", "level": "read"}), 401)
@@ -0,0 +1,135 @@
"""Integration tests: websocket authentication.
This file exists for one subtle behaviour. A websocket handler that closes the socket
*before* accepting the handshake gets translated by uvicorn into an HTTP 403 rejection
with the close code thrown away, and Starlette routes a WebSocketException raised from a
dependency down that same path. Neither can deliver 4401 or 4403 to the browser, so the
client cannot tell "you are signed out, stop retrying" from "the network blipped, back
off and retry" -- and a client that cannot tell reconnects forever.
``ws_authenticated`` therefore accepts first and closes afterwards, which looks wrong at
a glance and is the reason these assertions check the close code explicitly rather than
merely checking that the connection failed.
"""
import asyncio
import json
import httpx
import pytest
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
from .conftest import (
SESSION_COOKIE,
WS_API_URL,
WS_UNAUTHENTICATED,
assert_code,
signed_in,
)
# Every websocket route the v1 API serves. All of them subscribe at read level.
WS_ROUTES = [
"/",
"/spool",
"/spool/1",
"/filament",
"/filament/1",
"/vendor",
"/vendor/1",
"/setting",
"/setting/currency",
]
async def _close_code(path: str, cookie: str | None = None) -> int | None:
"""Connect, and report the code the server closed with.
Args:
path: The websocket path, relative to the v1 API.
cookie: A session cookie header value, or None to connect with no credentials.
Returns:
Optional[int]: The close code, or None if the socket stayed open.
"""
headers = {"Cookie": f"{SESSION_COOKIE}={cookie}"} if cookie else None
try:
async with connect(WS_API_URL + path, additional_headers=headers) as websocket:
try:
await asyncio.wait_for(websocket.recv(), timeout=2)
except TimeoutError:
# Still open and idle, which is what a healthy subscription looks like.
return None
except ConnectionClosed as exc:
return exc.code
return None
@pytest.mark.asyncio
@pytest.mark.parametrize("path", WS_ROUTES)
async def test_unauthenticated_websocket_is_closed_with_4401(
setup_response: httpx.Response, # noqa: ARG001
path: str,
) -> None:
"""The handshake is accepted, then closed with 4401 -- not rejected with HTTP 403.
Asserting the exact code is the regression test. If someone "simplifies"
ws_authenticated into raising a WebSocketException or closing before accept, the
handshake starts failing with an HTTP 403 instead and this assertion catches it.
"""
assert await _close_code(path) == WS_UNAUTHENTICATED
@pytest.mark.asyncio
async def test_authenticated_websocket_stays_open(setup_response: httpx.Response) -> None: # noqa: ARG001
"""A session cookie on the handshake gets a working subscription."""
with signed_in() as (client, response):
assert_code(response, 200)
token = client.cookies.get(SESSION_COOKIE)
assert await _close_code("/spool", cookie=token) is None
@pytest.mark.asyncio
async def test_authenticated_websocket_receives_events(setup_response: httpx.Response) -> None: # noqa: ARG001
"""The subscription is a real one, not merely a socket that was left open."""
with signed_in() as (client, response):
assert_code(response, 200)
token = client.cookies.get(SESSION_COOKIE)
async with connect(
f"{WS_API_URL}/vendor",
additional_headers={"Cookie": f"{SESSION_COOKIE}={token}"},
) as websocket:
# The subscription registers right after the handshake; give the server a
# beat before triggering the event.
await asyncio.sleep(0.2)
created = client.post("/vendor", json={"name": "Websocket test vendor"})
assert_code(created, 200)
vendor_id = created.json()["id"]
try:
event = json.loads(await asyncio.wait_for(websocket.recv(), timeout=10))
assert event["type"] == "added"
assert event["resource"] == "vendor"
assert event["payload"]["id"] == vendor_id
finally:
assert_code(client.delete(f"/vendor/{vendor_id}"), 200)
@pytest.mark.asyncio
async def test_revoked_session_cannot_open_a_websocket(setup_response: httpx.Response) -> None: # noqa: ARG001
"""A signed-out cookie is refused at the handshake, same as no cookie at all."""
with signed_in() as (client, response):
assert_code(response, 200)
token = client.cookies.get(SESSION_COOKIE)
assert_code(client.post("/auth/logout"), 200)
assert await _close_code("/spool", cookie=token) == WS_UNAUTHENTICATED
@pytest.mark.asyncio
async def test_garbage_cookie_cannot_open_a_websocket(setup_response: httpx.Response) -> None: # noqa: ARG001
"""A made-up token is not a credential over websockets either."""
assert await _close_code("/spool", cookie="not-a-real-token") == WS_UNAUTHENTICATED
Generated
+1 -610
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.10"
requires-python = ">=3.14"
[[package]]
name = "aiomysql"
@@ -35,7 +35,6 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
@@ -66,9 +65,7 @@ name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
@@ -87,56 +84,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907, upload-time = "2023-10-02T13:49:26.943Z" },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" },
{ url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" },
{ url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" },
{ url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" },
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
@@ -155,15 +108,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
@@ -182,55 +126,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
@@ -282,7 +177,6 @@ version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
@@ -325,24 +219,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
@@ -367,45 +243,6 @@ version = "3.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" },
{ url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" },
{ url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" },
{ url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" },
{ url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" },
{ url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" },
{ url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" },
{ url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" },
{ url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" },
{ url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" },
{ url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
{ url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" },
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
@@ -491,34 +328,6 @@ version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/b9/be66eb0decd730d89b9c94f930e4b8d87787b05724bb84af98bfd825f72c/httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826", size = 208805, upload-time = "2026-05-25T22:16:50.434Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f7/b4d41eaae2869d31356bc4bbf546f44fae83ff298af0a043ca0625b06773/httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77", size = 113527, upload-time = "2026-05-25T22:16:51.672Z" },
{ url = "https://files.pythonhosted.org/packages/e6/e4/77487e14fc7be47180fd0eb4267c7486d0cc59b74031839a3daf8650136b/httptools-0.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4", size = 450035, upload-time = "2026-05-25T22:16:53.313Z" },
{ url = "https://files.pythonhosted.org/packages/da/72/5a8f787e323f56fbd86c32a4be92a86776e4cfe8b4317db999f452028362/httptools-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb", size = 451101, upload-time = "2026-05-25T22:16:54.696Z" },
{ url = "https://files.pythonhosted.org/packages/ed/41/b44a25560955197674b6744cb903664300e239235a5eaa69df0890d87054/httptools-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813", size = 436140, upload-time = "2026-05-25T22:16:56.239Z" },
{ url = "https://files.pythonhosted.org/packages/74/b0/054aac84c03d7e097bf4c605fb7e74eec3d65c0276adf64ee97f3a103ff5/httptools-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba", size = 437041, upload-time = "2026-05-25T22:16:57.716Z" },
{ url = "https://files.pythonhosted.org/packages/bb/e8/86b85bbc0ac7892232f1a99ab96a9aa71936984fa06adfc0afc83ca7789e/httptools-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557", size = 90454, upload-time = "2026-05-25T22:16:58.871Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
{ url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
{ url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
{ url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
{ url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
@@ -586,61 +395,6 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
@@ -671,49 +425,6 @@ version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" },
{ url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" },
{ url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" },
{ url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" },
{ url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" },
{ url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" },
{ url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" },
{ url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" },
{ url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" },
{ url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" },
{ url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" },
{ url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" },
{ url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" },
{ url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" },
{ url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" },
{ url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
{ url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
{ url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
{ url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
{ url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
{ url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
{ url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
{ url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
{ url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
{ url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
{ url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
{ url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
{ url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
{ url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" },
{ url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" },
@@ -781,7 +492,6 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pastel" },
{ name = "pyyaml" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/92/93a4af9511b8c7c647874521d9e6c904266be98067c2ee1eb2e74520d208/poethepoet-0.48.0.tar.gz", hash = "sha256:a06f49d244fadfc2e2e7faa78b54e64a9694727e4ce1d50e08f23cea3ded74f1", size = 148679, upload-time = "2026-07-05T21:48:30.106Z" }
wheels = [
@@ -803,50 +513,6 @@ version = "2.9.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/80/49bacf9e51617d8309f6f0123e29edc793f6f5f6700c7d1f1b20782fbb37/psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4", size = 3712314, upload-time = "2026-04-20T23:33:31.363Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/98eeac7d60c43df9338287834edf9b3e69be68a2db78a57b1b81d705e735/psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56", size = 3822389, upload-time = "2026-04-20T23:33:34.178Z" },
{ url = "https://files.pythonhosted.org/packages/9f/7c/30575e75f14d5351a56a1971bb43fe7f8bf7edf1b654fb1bec65c42a8812/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433", size = 4578448, upload-time = "2026-04-20T23:33:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/65/9b/4df366d89f28c527dc39d0b6c98a5ca74e30d37ac097b73f3352147568ae/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e", size = 4273705, upload-time = "2026-04-20T23:33:39.291Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8a/c566803818eb03161ba869b6ba612bf7ad56816d98b9e5121e0a22ad6b0b/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3", size = 5893784, upload-time = "2026-04-20T23:33:41.658Z" },
{ url = "https://files.pythonhosted.org/packages/63/fe/0dfa5797e0b229e0567bc378695224caf14d547f73b05be0c80549089772/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4", size = 4109306, upload-time = "2026-04-20T23:33:43.953Z" },
{ url = "https://files.pythonhosted.org/packages/3c/89/28063adf17a4ba501eedd9890feab0c649ee4d8bd0a97df0ff1e9584feab/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256", size = 3654400, upload-time = "2026-04-20T23:33:46.115Z" },
{ url = "https://files.pythonhosted.org/packages/84/94/5a01de0aa4ead0b8d8d1aa4ec18cec0bd36d03fa714eaa5bb8a0b1b50020/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c", size = 3299215, upload-time = "2026-04-20T23:33:48.202Z" },
{ url = "https://files.pythonhosted.org/packages/7a/85/723bb085a61c6ac2dc0a0043f375f2fe7365363e27b073bad56ca5bda979/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463", size = 3047724, upload-time = "2026-04-20T23:33:50.74Z" },
{ url = "https://files.pythonhosted.org/packages/b4/67/4d8b1e0d2fc4166677380eac0edf9cdff91013aca2546e8ef7bc04b56158/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1", size = 3349183, upload-time = "2026-04-20T23:33:59.635Z" },
{ url = "https://files.pythonhosted.org/packages/73/99/21af7a5498637ea4dc91a17c281a53bc1d632fbafe00f6689fbfb32a9fed/psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03", size = 2757036, upload-time = "2026-04-20T23:34:01.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" },
{ url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" },
{ url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" },
{ url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" },
{ url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" },
{ url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" },
{ url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" },
{ url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" },
{ url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" },
{ url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" },
{ url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" },
{ url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" },
{ url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" },
{ url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" },
{ url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" },
{ url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" },
{ url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" },
{ url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" },
{ url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" },
{ url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" },
{ url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" },
{ url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" },
{ url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" },
{ url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" },
{ url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
@@ -893,65 +559,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
{ url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
{ url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
{ url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
{ url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
{ url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
{ url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
{ url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
{ url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
{ url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
@@ -982,22 +589,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
@@ -1029,12 +620,10 @@ version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
@@ -1046,9 +635,7 @@ name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
@@ -1061,44 +648,6 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
@@ -1241,34 +790,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", size = 2162087, upload-time = "2026-06-15T16:05:15.795Z" },
{ url = "https://files.pythonhosted.org/packages/6c/22/f19552eb7876774d50cfd025337ef5d67acc10cd8f29adab7716cf47c352/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", size = 3244579, upload-time = "2026-06-15T16:10:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", size = 3243515, upload-time = "2026-06-15T16:12:22.627Z" },
{ url = "https://files.pythonhosted.org/packages/74/c6/5900ec624fab3360aa2ec59b99bb2046dd79799e310bb78a0514eaa4038e/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", size = 3195492, upload-time = "2026-06-15T16:10:38.097Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2ee3c4e1ac4fd22309349823fe13f33febeab1a71db1d7e9d60293a07dcb/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", size = 3215782, upload-time = "2026-06-15T16:12:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/ce/1c/3bd72c341f1cb5faed5a7457ea840228a46be51cfbaf31a9db72fc963f11/sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", size = 2122119, upload-time = "2026-06-15T16:13:26.915Z" },
{ url = "https://files.pythonhosted.org/packages/2a/63/b6dfdd646abf91c3bedb13727226a5e765e5f8365e898d43818e6672fa46/sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", size = 2145158, upload-time = "2026-06-15T16:13:28.386Z" },
{ url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" },
{ url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" },
{ url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" },
{ url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" },
{ url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" },
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
{ url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" },
{ url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" },
{ url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" },
{ url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" },
{ url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" },
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
@@ -1322,67 +843,12 @@ version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typeguard"
version = "4.5.2"
@@ -1423,7 +889,6 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/bb/88735238d7ead151c28d5432551170f17746c70c257aa66e8d7e64eca7a3/uvicorn-0.50.1.tar.gz", hash = "sha256:ccb3061887829fd8471cfa6fc65b2594689342ee00792e5d257d34871755b09f", size = 93722, upload-time = "2026-07-06T07:52:25.238Z" }
wheels = [
@@ -1436,30 +901,6 @@ version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" },
{ url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" },
{ url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" },
{ url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" },
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
@@ -1480,55 +921,5 @@ version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]